From d6fc3bdd4bc8464330edf990370a6704f753d0c5 Mon Sep 17 00:00:00 2001 From: Alberto Moretti Date: Sat, 13 Jun 2026 11:08:23 +0200 Subject: [PATCH] feat: add api test cli --- README.md | 11 ++ src/bin/pokedex-cli.rs | 275 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 src/bin/pokedex-cli.rs diff --git a/README.md b/README.md index 4ff1674..9e675ca 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,17 @@ cargo run The API listens on `0.0.0.0:5000` by default. +## CLI helper + +With the API running in another terminal, call it through the small helper binary: + +```bash +cargo run --bin pokedex-cli -- health +cargo run --bin pokedex-cli -- pokemon mewtwo +cargo run --bin pokedex-cli -- translated mewtwo +cargo run --bin pokedex-cli -- --base-url http://localhost:5000 pokemon pikachu +``` + ## Endpoints ```bash diff --git a/src/bin/pokedex-cli.rs b/src/bin/pokedex-cli.rs new file mode 100644 index 0000000..2d087de --- /dev/null +++ b/src/bin/pokedex-cli.rs @@ -0,0 +1,275 @@ +#![deny(warnings)] +#![deny(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)] +#![allow(clippy::missing_errors_doc, clippy::multiple_crate_versions)] + +use std::{env, process::ExitCode, time::Duration}; + +const DEFAULT_BASE_URL: &str = "http://localhost:5000"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::main] +async fn main() -> ExitCode { + match run(env::args().skip(1)).await { + Ok(output) => { + println!("{output}"); + ExitCode::SUCCESS + } + Err(CliError::Help) => { + eprintln!("{}", usage()); + ExitCode::SUCCESS + } + Err(CliError::Usage(message)) => { + eprintln!("{message}\n\n{}", usage()); + ExitCode::from(2) + } + Err(error) => { + eprintln!("error: {error}"); + ExitCode::FAILURE + } + } +} + +async fn run(args: I) -> Result +where + I: IntoIterator, + S: Into, +{ + let args = parse_args(args)?; + call_api(&args).await +} + +async fn call_api(args: &CliArgs) -> Result { + let client = reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .build()?; + let response = client.get(args.url()).send().await?; + let status = response.status(); + let body = response.text().await?; + + if status.is_success() { + pretty_body(&body) + } else { + Err(CliError::Api { status, body }) + } +} + +fn pretty_body(body: &str) -> Result { + serde_json::from_str::(body).map_or_else( + |_| Ok(body.to_owned()), + |value| serde_json::to_string_pretty(&value).map_err(CliError::from), + ) +} + +#[derive(Debug, Eq, PartialEq)] +struct CliArgs { + base_url: String, + command: Command, +} + +impl CliArgs { + fn url(&self) -> String { + format!("{}{}", self.base_url, self.command.path()) + } +} + +#[derive(Debug, Eq, PartialEq)] +enum Command { + Health, + Metrics, + Pokemon { name: String }, + TranslatedPokemon { name: String }, +} + +impl Command { + fn path(&self) -> String { + match self { + Self::Health => "/health".to_owned(), + Self::Metrics => "/metrics".to_owned(), + Self::Pokemon { name } => format!("/pokemon/{name}"), + Self::TranslatedPokemon { name } => format!("/pokemon/translated/{name}"), + } + } +} + +fn parse_args(args: I) -> Result +where + I: IntoIterator, + S: Into, +{ + let mut base_url = DEFAULT_BASE_URL.to_owned(); + let mut positional = Vec::new(); + let mut iter = args.into_iter().map(Into::into); + + while let Some(token) = iter.next() { + match token.as_str() { + "--help" | "-h" => return Err(CliError::Help), + "--base-url" | "-u" => { + base_url = iter + .next() + .ok_or_else(|| CliError::Usage("missing value for --base-url".to_owned()))?; + } + _ if token.starts_with('-') => { + return Err(CliError::Usage(format!("unknown option: {token}"))); + } + _ => { + positional.push(token); + positional.extend(iter); + break; + } + } + } + + Ok(CliArgs { + base_url: trim_base_url(&base_url), + command: parse_command(&positional)?, + }) +} + +fn parse_command(positional: &[String]) -> Result { + match positional { + [command] if command == "health" => Ok(Command::Health), + [command] if command == "metrics" => Ok(Command::Metrics), + [command, name] if command == "pokemon" => { + parse_pokemon_name(name).map(|name| Command::Pokemon { + name: name.to_owned(), + }) + } + [command, name] if command == "translated" => { + parse_pokemon_name(name).map(|name| Command::TranslatedPokemon { + name: name.to_owned(), + }) + } + [] => Err(CliError::Usage("missing command".to_owned())), + _ => Err(CliError::Usage("invalid command or arguments".to_owned())), + } +} + +fn parse_pokemon_name(name: &str) -> Result<&str, CliError> { + if is_valid_pokemon_name(name) { + Ok(name) + } else { + Err(CliError::Usage( + "pokemon name must contain only ASCII letters, digits, or hyphens".to_owned(), + )) + } +} + +fn is_valid_pokemon_name(name: &str) -> bool { + !name.is_empty() + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') +} + +fn trim_base_url(base_url: &str) -> String { + base_url.trim_end_matches('/').to_owned() +} + +const fn usage() -> &'static str { + "Usage:\n pokedex-cli [--base-url URL] health\n pokedex-cli [--base-url URL] metrics\n pokedex-cli [--base-url URL] pokemon \n pokedex-cli [--base-url URL] translated \n\nExamples:\n pokedex-cli pokemon mewtwo\n pokedex-cli translated mewtwo\n pokedex-cli --base-url http://localhost:5000 pokemon pikachu" +} + +#[derive(Debug, thiserror::Error)] +enum CliError { + #[error("API returned {status}: {body}")] + Api { + status: reqwest::StatusCode, + body: String, + }, + #[error(transparent)] + Http(#[from] reqwest::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error("{0}")] + Usage(String), + #[error("help requested")] + Help, +} + +#[cfg(test)] +mod tests { + use super::{CliArgs, CliError, Command, DEFAULT_BASE_URL, parse_args, run}; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers}; + + #[test] + fn parses_default_base_url_and_pokemon_command() { + let args = parse_args(["pokemon", "mewtwo"]).expect("args should parse"); + + assert_eq!( + args, + CliArgs { + base_url: DEFAULT_BASE_URL.to_owned(), + command: Command::Pokemon { + name: "mewtwo".to_owned() + } + } + ); + assert_eq!(args.url(), "http://localhost:5000/pokemon/mewtwo"); + } + + #[test] + fn parses_custom_base_url_and_translated_command() { + let args = parse_args([ + "--base-url", + "http://localhost:5000/", + "translated", + "mr-mime", + ]) + .expect("args should parse"); + + assert_eq!(args.base_url, "http://localhost:5000"); + assert_eq!( + args.url(), + "http://localhost:5000/pokemon/translated/mr-mime" + ); + } + + #[test] + fn rejects_names_that_do_not_match_the_api_contract() { + let error = parse_args(["pokemon", "foo?bar=baz"]).expect_err("args should fail"); + + assert!(matches!(error, CliError::Usage(_))); + } + + #[tokio::test] + async fn calls_api_and_pretty_prints_json() { + let server = MockServer::start().await; + Mock::given(matchers::method("GET")) + .and(matchers::path("/pokemon/mewtwo")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": "mewtwo", + "isLegendary": true + }))) + .expect(1) + .mount(&server) + .await; + let base_url = server.uri(); + + let output = run(["--base-url", &base_url, "pokemon", "mewtwo"]) + .await + .expect("request should succeed"); + + assert!(output.contains("\"name\": \"mewtwo\"")); + assert!(output.contains("\"isLegendary\": true")); + } + + #[tokio::test] + async fn returns_api_errors_with_response_body() { + let server = MockServer::start().await; + Mock::given(matchers::method("GET")) + .and(matchers::path("/pokemon/nope")) + .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({ + "error": "pokemon not found" + }))) + .mount(&server) + .await; + let base_url = server.uri(); + + let error = run(["--base-url", &base_url, "pokemon", "nope"]) + .await + .expect_err("request should fail"); + + assert!(error.to_string().contains("404")); + assert!(error.to_string().contains("pokemon not found")); + } +}