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}