feat: add api test cli

This commit is contained in:
2026-06-13 11:08:23 +02:00
parent ce959b59bf
commit d6fc3bdd4b
2 changed files with 286 additions and 0 deletions

View File

@@ -33,6 +33,17 @@ cargo run
The API listens on `0.0.0.0:5000` by default. 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 ## Endpoints
```bash ```bash

275
src/bin/pokedex-cli.rs Normal file
View File

@@ -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<I, S>(args: I) -> Result<String, CliError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let args = parse_args(args)?;
call_api(&args).await
}
async fn call_api(args: &CliArgs) -> Result<String, CliError> {
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<String, CliError> {
serde_json::from_str::<serde_json::Value>(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<I, S>(args: I) -> Result<CliArgs, CliError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
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<Command, CliError> {
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 <name>\n pokedex-cli [--base-url URL] translated <name>\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"));
}
}