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
9pub struct DockerHubClient {
11 pub client: Client,
14
15 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 creator: u64,
48
49 id: u64,
51
52 images: Vec<Image>,
53 last_updated: DateTime<Utc>,
54 last_updater: u64,
55 last_updater_username: String,
56
57 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 pub name: String,
81
82 namespace: String,
84
85 repository_type: String,
87
88 status: usize,
89
90 status_description: String,
91
92 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 affiliation: String,
110
111 media_types: Vec<String>,
112
113 content_types: Vec<String>,
114
115 categories: Vec<Category>,
116
117 storage_size: u64,
119}
120
121impl DockerHubClient {
122 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 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)) .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 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", 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 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}