1use anyhow::Context;
2use chrono::{DateTime, Utc};
3use reqwest::{header, Client};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use url::Url;
7
8pub struct DockerHubClient {
10 pub client: Client,
13
14 pub url: Url,
18}
19
20#[derive(Serialize, Deserialize, Debug)]
21pub struct Tag {
22 name: String,
24}
25
26#[derive(Serialize, Deserialize, Debug)]
27pub struct Category {
28 name: String,
29 slug: String,
30}
31
32#[derive(Serialize, Deserialize, Debug)]
33pub struct Repository {
34 pub name: String,
36
37 namespace: String,
39
40 repository_type: String,
42
43 status: usize,
44
45 status_description: String,
46
47 description: String,
50
51 is_private: bool,
52
53 star_count: usize,
54
55 pull_count: usize,
56
57 last_updated: DateTime<Utc>,
58
59 last_modified: DateTime<Utc>,
60
61 date_registered: DateTime<Utc>,
62
63 affiliation: String,
65
66 media_types: Vec<String>,
67
68 content_types: Vec<String>,
69
70 categories: Vec<Category>,
71
72 storage_size: u64,
74}
75
76impl DockerHubClient {
77 pub fn new(token: &str) -> anyhow::Result<Self> {
84 let url = Url::parse("https://hub.docker.com").context("couldn't parse docker hub url")?;
85
86 let mut headers = header::HeaderMap::new();
87 headers.insert(
88 header::AUTHORIZATION,
89 header::HeaderValue::from_str(&format!("Bearer {}", token))
90 .context("couldn't add authorization header with provided token")?,
91 );
92
93 let client = Client::builder()
94 .default_headers(headers)
95 .build()
96 .context("couldn't build the reqwest client")?;
97
98 Ok(Self { client, url })
99 }
100
101 pub async fn list_repositories(&self, org: &str) -> anyhow::Result<Vec<Repository>> {
109 let url = self
110 .url
111 .join(&format!("v2/repositories/{}", org))
112 .context("failed formatting the url with the provided org")?;
113
114 match self.client.get(url).send().await {
116 Ok(response) => match response.json::<Value>().await {
117 Ok(out) => Ok(serde_json::from_value(out["results"].clone())
118 .context("parsing the output json into a `Repository` struct failed")?),
119 Err(e) => anyhow::bail!("failed with error {e}"),
120 },
121 Err(e) => anyhow::bail!("failed with error {e}"),
122 }
123 }
124
125 pub async fn list_tags(&self, org: &str, repository: &str) -> anyhow::Result<Vec<Tag>> {
131 let url = self
132 .url
133 .join(&format!("v2/repositories/{}/{}/tags", org, repository))
134 .context("failed formatting the url with the provided org and repository")?;
135
136 match self.client.get(url).send().await {
138 Ok(response) => match response.json::<Value>().await {
139 Ok(out) => Ok(serde_json::from_value(out["results"].clone())
140 .context("parsing the output json into a `Tag` struct failed")?),
141 Err(e) => anyhow::bail!("failed with error {e}"),
142 },
143 Err(e) => anyhow::bail!("failed with error {e}"),
144 }
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use serde_json::json;
152
153 #[test]
154 fn test_repository_serde() {
155 let value = json!({
156 "name": "ollama",
157 "namespace": "ollama",
158 "repository_type": "image",
159 "status": 1,
160 "status_description": "active",
161 "description": "The easiest way to get up and running with large language models.",
162 "is_private": false,
163 "star_count": 1183,
164 "pull_count": 13256501,
165 "last_updated": "2025-03-04T04:01:22.754331Z",
166 "last_modified": "2024-10-16T13:48:34.145251Z",
167 "date_registered": "2023-06-29T23:27:34.326426Z",
168 "affiliation": "",
169 "media_types": [
170 "application/vnd.docker.container.image.v1+json",
171 "application/vnd.docker.distribution.manifest.list.v2+json",
172 "application/vnd.oci.image.config.v1+json",
173 "application/vnd.oci.image.index.v1+json"
174 ],
175 "content_types": [
176 "image"
177 ],
178 "categories": [
179 {
180 "name": "Machine Learning & AI",
181 "slug": "machine-learning-and-ai"
182 },
183 {
184 "name": "Developer Tools",
185 "slug": "developer-tools"
186 }
187 ],
188 "storage_size": 662988133055 as u64,
189 });
190
191 let repository = serde_json::from_value::<Repository>(value)
192 .context("failed to deserialize the repository payload")
193 .unwrap();
194
195 println!("{repository:#?}");
196 }
197
198 #[test]
199 fn test_tag_serde() {
200 let value = json!({
201 "creator": 14304909,
202 "id": 529481097,
203 "images": [
204 {
205 "architecture": "amd64",
206 "features": "",
207 "variant": null,
208 "digest": "sha256:96b6a4e66250499a9d87a4adf259ced7cd213e2320fb475914217f4d69abe98d",
209 "os": "linux",
210 "os_features": "",
211 "os_version": null,
212 "size": 755930694,
213 "status": "active",
214 "last_pulled": "2025-03-05T07:52:00.613197154Z",
215 "last_pushed": "2024-01-16T20:54:52Z"
216 }
217 ],
218 "last_updated": "2024-01-16T20:54:55.914808Z",
219 "last_updater": 14304909,
220 "last_updater_username": "mxyng",
221 "name": "gguf",
222 "repository": 22180121,
223 "full_size": 755930694,
224 "v2": true,
225 "tag_status": "active",
226 "tag_last_pulled": "2025-03-05T07:52:00.613197154Z",
227 "tag_last_pushed": "2024-01-16T20:54:55.914808Z",
228 "media_type": "application/vnd.oci.image.index.v1+json",
229 "content_type": "image",
230 "digest": "sha256:7c49490a9e4a7ca4326e09c4b47bc525aa0a9dfc8ea0b3a30d62af23a60db712"
231 });
232
233 let tag = serde_json::from_value::<Tag>(value)
234 .context("failed to deserialize the tag payload")
235 .unwrap();
236
237 println!("{tag:#?}");
238 }
239
240 #[tokio::test]
241 async fn test_list_repositories() -> anyhow::Result<()> {
242 let pat =
243 std::env::var("DOCKER_PAT").context("environment variable `DOCKER_PAT` is not set")?;
244 let dh =
245 DockerHubClient::new(&pat).context("the docker hub client couldn't be instantiated")?;
246
247 println!("{:#?}", dh.list_repositories("ollama").await);
248
249 Ok(())
250 }
251
252 #[tokio::test]
253 async fn test_list_tags() -> anyhow::Result<()> {
254 let pat =
255 std::env::var("DOCKER_PAT").context("environment variable `DOCKER_PAT` is not set")?;
256 let dh =
257 DockerHubClient::new(&pat).context("the docker hub client couldn't be instantiated")?;
258
259 println!("{:#?}", dh.list_tags("ollama", "ollama").await);
260
261 Ok(())
262 }
263}