diff --git a/src/commands/repo.rs b/src/commands/repo.rs index e078a05..8897de9 100644 --- a/src/commands/repo.rs +++ b/src/commands/repo.rs @@ -1,10 +1,19 @@ +//! Command module responsible for handling the "repo" command. +//! +//! The "repo" command works with the Docker Registry API's "repository" +//! entitity available at /v2/_catalog. +//! use crate::cli::{RepoArgs, RepoCommands}; use crate::config::Config; use crate::error::ApiError; use serde::Deserialize; -const BASE_CATALOG_URI: &str = "/v2/_catalog"; +/// Path to the Docker Registry API's "repository" entity. +const BASE_CATALOG_URI: &str = "/v2/_catalog?n=1000"; +/// Main handler function for the "repo" command. +/// +/// Responsible for dispatching the various subcommands. pub async fn handler(config: &Config, args: &RepoArgs) -> Result<(), ApiError> { log::trace!("handler()"); @@ -15,42 +24,19 @@ pub async fn handler(config: &Config, args: &RepoArgs) -> Result<(), ApiError> { Ok(()) } -#[derive(Deserialize)] -struct CatalogResponse { - repositories: Vec, -} - +/// Handle the repository list command. +/// +/// Fetch the list of repository names from the Docker Registry API, and +/// simply print the resulting names to stdout. async fn handle_list(config: &Config, _args: &RepoArgs) -> Result<(), ApiError> { - log::trace!("handle_list()"); - - let mut url = config.registry_url.join(BASE_CATALOG_URI)?; - let mut responses = Vec::new(); - - loop { - log::debug!("Using url {url}"); - let response = reqwest::get(url.clone()).await?; - let headers = response.headers().to_owned(); - let body: CatalogResponse = response.json().await?; - - responses.push(body); - - if let Some(link_value) = headers.get(http::header::LINK) { - let link_str = link_value.to_str()?; - let parts: Vec<&str> = link_str.split(';').collect(); - if let Some(url_part) = parts.first() { - if let Some(uri) = url_part - .trim() - .strip_prefix('<') - .and_then(|s| s.strip_suffix('>')) - { - url = config.registry_url.join(uri)?; - } - } - } else { - break; - } + #[derive(Deserialize)] + struct Response { + repositories: Vec, } + log::trace!("handle_list()"); + + let responses: Vec = fetch_all(config, BASE_CATALOG_URI).await?; let repo_list: Vec<&str> = responses .iter() .flat_map(|r| r.repositories.iter().map(String::as_str)) @@ -62,3 +48,70 @@ async fn handle_list(config: &Config, _args: &RepoArgs) -> Result<(), ApiError> Ok(()) } + +/// Iterate over a paginated result set, collecting and returning the response +/// set. +/// +/// The Docker Registry API specifies that when making a GET request, the +/// response will be paginated using a Link response header for the Next URI. +/// The URL will be encoded using RFC5988. [https://tools.ietf.org/html/rfc5988] +/// +/// This function will continuously request the "Next" link as long as it is +/// returned, collecting and returning the deserialized response bodies as a +/// Vec. +async fn fetch_all serde::Deserialize<'de>>( + config: &Config, + path: &str, +) -> Result, ApiError> { + log::trace!("fetch_all({path:?})"); + + let mut responses: Vec = Vec::default(); + let mut uri = String::from(path); + loop { + log::debug!("GET {uri:?}"); + let url = config.registry_url.join(&uri)?; + + let resp = reqwest::get(url).await?; + let headers = resp.headers().to_owned(); + responses.push(resp.json().await?); + + if let Some(path) = parse_rfc5988(headers.get(http::header::LINK))? { + uri = path; + } else { + break; + } + } + Ok(responses) +} + +/// Given an optional header value possibly containing an RFC5988 formatted +/// URL, parse said URL into a `String`. +/// +/// If the header_value does not contain a correctly formatted RFC5988 URL, +/// or if the header_value is not properly formatted containing a URL +/// surrounded by angle brackets, separated from the link relation by a ';' +/// character, the `None` variant will be returned. +/// +/// # Errors: +/// +/// Returns and `ApiError` if there is a problem parsing contents of the +/// supplied header value. +fn parse_rfc5988(header_value: Option<&http::HeaderValue>) -> Result, ApiError> { + log::trace!("parse_rfc5988({header_value:?})"); + + if let Some(link_value) = header_value { + let link_str = link_value.to_str()?; + let parts: Vec<&str> = link_str.split(';').collect(); + if let Some(url_part) = parts.first() { + if let Some(path) = url_part + .trim() + .strip_prefix('<') + .and_then(|s| s.strip_suffix('>')) + { + return Ok(Some(String::from(path))); + } + } + } + + Ok(None) +}