hub_tool/
lib.rs

1//! A (very early) asynchronous Rust library for the Docker Hub API v2
2//!
3//! This library exposes a client to interact with the Docker Hub via the Docker Hub API v2,
4//! enabling and making it easier to get information about repositories, tags, et al. from the
5//! Docker Hub via Rust; as well as to e.g. perform Hub maintenance tasks.
6//!
7//! ## Usage
8//!
9//! ```rust,no_run
10//! use anyhow::Context;
11//! use hub_tool::DockerHubClient;
12//!
13//! #[tokio::main]
14//! async fn main() -> anyhow::Result<()> {
15//!     let client = DockerHubClient::new("dckr_pat_***")
16//!         .context("couldn't initialize the docker client")?;
17//!
18//!     // Fetch the repositories under a given org or username on the Docker Hub
19//!     let repositories = client.list_repositories("ollama")
20//!         .await
21//!         .context("failed while fetching the repositories")?;
22//!
23//!     // Fetch the tags for a given repository on the Docker Hub
24//!     let tags = client.list_tags("ollama", "quantize")
25//!         .await
26//!         .context("failed while fetching the tags")?;
27//!
28//!     Ok(())
29//! }
30//! ```
31
32use anyhow::Context;
33use futures::future::join_all;
34use reqwest::{header, Client};
35use serde::{Deserialize, Serialize};
36use serde_json::Value;
37use url::Url;
38
39pub mod repositories;
40pub mod tags;
41
42/// Struct that holds the client and the URL to send request to the Docker Hub
43pub struct DockerHubClient {
44    /// Contains the instace for the reqwest Client with the required headers and
45    /// configuration if any.
46    pub client: Client,
47
48    // TODO(alvarobartt): unless custom Docker Registries are supported, the URL may not be
49    // required
50    /// Holds the URL for the Docker Hub (https://hub.docker.com)
51    pub url: Url,
52}
53
54#[derive(Serialize, Deserialize, Debug)]
55pub struct ApiResult<T> {
56    /// Count of the total values that are available, not the `results` length
57    count: usize,
58
59    /// The URL to query next if any, meaning that there are more results available to fetch;
60    /// note that it can be null meaning that all the results have already been fetched; otherwise
61    /// it contains the URL with the query values for `page` and `page_size`
62    next: Option<String>,
63
64    /// The URL to query the previous listing of results; similar to `next` but the other way
65    /// around
66    previous: Option<String>,
67
68    /// A vector with the query results based on the type T
69    results: Vec<T>,
70}
71
72impl DockerHubClient {
73    /// Creates a new instance of DockerHubClient with the provided authentication
74    ///
75    /// This method creates a new instance of the DockerHubClient with the provided token,
76    /// which should have read access to the Docker Hub, to be able to call the rest of the
77    /// methods within this struct. This method will configure and setup the HTTP client that
78    /// will be used within the rest of the methods to send requests to the Docker Hub.
79    pub fn new(token: &str) -> anyhow::Result<Self> {
80        let url = Url::parse("https://hub.docker.com").context("couldn't parse docker hub url")?;
81
82        let mut headers = header::HeaderMap::new();
83        headers.insert(
84            header::AUTHORIZATION,
85            header::HeaderValue::from_str(&format!("Bearer {}", token))
86                .context("couldn't add authorization header with provided token")?,
87        );
88
89        let client = Client::builder()
90            .default_headers(headers)
91            .build()
92            .context("couldn't build the reqwest client")?;
93
94        Ok(Self { client, url })
95    }
96}
97
98pub async fn fetch<T>(client: &Client, url: &Url) -> anyhow::Result<Vec<T>>
99where
100    T: for<'de> Deserialize<'de> + Send + 'static,
101{
102    let result = match client.get(url.clone()).send().await {
103        Ok(response) => match response.json::<Value>().await {
104            Ok(out) => serde_json::from_value::<ApiResult<T>>(out)
105                .context("parsing the output json into an `ApiResult<T>` struct failed")?,
106            Err(e) => anyhow::bail!("failed with error {e}"),
107        },
108        Err(e) => anyhow::bail!("failed with error {e}"),
109    };
110
111    if let Some(_) = result.next {
112        let page_size = result.results.len();
113        let pages = (result.count + page_size - 1) / page_size;
114
115        // TODO: avoid spawning a bunch of tasks
116        let mut tasks = Vec::new();
117        for page in 2..pages {
118            let new_url = url.clone();
119            let new_client = client.clone();
120            tasks.push(tokio::spawn(async move {
121                match new_client
122                    .get(new_url)
123                    .query(&[("page", page), ("page_size", page_size)])
124                    .send()
125                    .await
126                {
127                    Ok(response) => match response.json::<Value>().await {
128                        Ok(out) => serde_json::from_value::<ApiResult<T>>(out).context(
129                            "parsing the output json into an `ApiResult<T>` struct failed",
130                        ),
131                        Err(e) => anyhow::bail!("failed with error {e}"),
132                    },
133                    Err(e) => anyhow::bail!("failed with error {e}"),
134                }
135            }));
136        }
137
138        let mut results = result.results;
139
140        let futures = join_all(tasks).await;
141        for future in futures {
142            match future {
143                Ok(Ok(result)) => {
144                    results.extend(result.results);
145                }
146                Ok(Err(e)) => {
147                    anyhow::bail!("failed to fetch: {:?}", e);
148                }
149                Err(e) => {
150                    anyhow::bail!("failed capturing the task future: {:?}", e);
151                }
152            }
153        }
154        Ok(results)
155    } else {
156        Ok(result.results)
157    }
158}