hub_tool/
lib.rs

1use anyhow::Context;
2use chrono::{DateTime, Utc};
3use reqwest::{header, Client};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use url::Url;
7
8/// Struct that holds the client and the URL to send request to the Docker Hub
9pub struct DockerHubClient {
10    /// Contains the instace for the reqwest Client with the required headers and
11    /// configuration if any.
12    pub client: Client,
13
14    // TODO(alvarobartt): unless custom Docker Registries are supported, the URL may not be
15    // required
16    /// Holds the URL for the Docker Hub (https://hub.docker.com)
17    pub url: Url,
18}
19
20#[derive(Serialize, Deserialize, Debug)]
21pub struct Tag {
22    /// The name of the tag for a given repository in the Docker Hub
23    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    /// The name of the repository on the Docker Hub
35    pub name: String,
36
37    /// The namespace i.e. user or organization where the repository lives in
38    namespace: String,
39
40    /// The type of repository, can be any of "image", etc.
41    repository_type: String,
42
43    status: usize,
44
45    status_description: String,
46
47    // TODO: It cannot be None, but it can be empty which is practically the same, so let's handle
48    // this in the future to have some consistency and use None() over Some("")
49    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    // TODO: same as in `description`
64    affiliation: String,
65
66    media_types: Vec<String>,
67
68    content_types: Vec<String>,
69
70    categories: Vec<Category>,
71
72    /// The size of the virtual image in bytes
73    storage_size: u64,
74}
75
76impl DockerHubClient {
77    /// Creates a new instance of DockerHubClient with the provided authentication
78    ///
79    /// This method creates a new instance of the DockerHubClient with the provided token,
80    /// which should have read access to the Docker Hub, to be able to call the rest of the
81    /// methods within this struct. This method will configure and setup the HTTP client that
82    /// will be used within the rest of the methods to send requests to the Docker Hub.
83    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    /// List all the repositories under a given org or username on the Docker Hub
102    ///
103    /// This method lists all the repositories for a given organization or user via
104    /// the `org` argument that are uploaded and publicly available on the Docker Hub.
105    /// Note that if the repository is private but the provided token has access to it,
106    /// then the repositories will be listed, otherwise only the public ones (if any)
107    /// will be listed.
108    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        // TODO(alvarobartt): handle pagination
115        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    /// List all the tags for a given repository on the Docker Hub
126    ///
127    /// This method expects both the organization or username via the `org`
128    /// argument plus the `repository` name for the repository that the tags
129    /// will be listed for.
130    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        // TODO(alvarobartt): handle pagination
137        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}