diff --git a/Makefile b/Makefile index 689bac4..abbd5a5 100644 --- a/Makefile +++ b/Makefile @@ -9,16 +9,13 @@ POKEMON ?= mewtwo PLATFORMS ?= linux/amd64,linux/arm64 GIT_SHA ?= $(shell git rev-parse HEAD 2>/dev/null || printf unknown) -.PHONY: help run docker-build docker-run cli-health cli-pokemon cli-translated health pokemon translated metrics fmt clippy test check contract-test docker-buildx +.PHONY: help run docker-build docker-run health pokemon translated metrics fmt clippy test check contract-test docker-buildx help: @printf 'Available targets:\n' @printf ' make run Run the API locally with cargo\n' @printf ' make docker-build Build the Docker image\n' @printf ' make docker-run Run the Docker image on port 8000\n' - @printf ' make cli-health Call /health through the CLI helper\n' - @printf ' make cli-pokemon Call /pokemon/$${POKEMON} through the CLI helper\n' - @printf ' make cli-translated Call /pokemon/translated/$${POKEMON} through the CLI helper\n' @printf ' make health curl /health\n' @printf ' make pokemon curl /pokemon/$${POKEMON}\n' @printf ' make translated curl /pokemon/translated/$${POKEMON}\n' @@ -36,15 +33,6 @@ docker-build: docker-run: $(DOCKER) run --rm -p 8000:8000 $(IMAGE) -cli-health: - $(CARGO) run --bin pokedex-cli -- health - -cli-pokemon: - $(CARGO) run --bin pokedex-cli -- --base-url $(BASE_URL) pokemon $(POKEMON) - -cli-translated: - $(CARGO) run --bin pokedex-cli -- --base-url $(BASE_URL) translated $(POKEMON) - health: curl -fsS $(BASE_URL)/health diff --git a/README.md b/README.md index dd37271..60c0c66 100644 --- a/README.md +++ b/README.md @@ -39,17 +39,6 @@ make run The API listens on `0.0.0.0:8000` by default. -## CLI helper - -With the API running in another terminal, call it through the small helper binary: - -```bash -make cli-health -make cli-pokemon -make cli-translated -make cli-pokemon POKEMON=pikachu BASE_URL=http://localhost:8000 -``` - ## Endpoints ```bash @@ -80,7 +69,6 @@ This is a small Rust service, so the structure is intentionally a lightweight he ```mermaid flowchart LR - CLI[CLI helper] --> HTTP HTTP[HTTP layer: axum routes, handlers, middleware] --> Application Application[Application service: use cases and translation rules] --> Domain Application --> Clients @@ -96,7 +84,7 @@ flowchart LR - `clients`: adapters for external providers (`PokéAPI`, `FunTranslations`). - `http`: API transport concerns, routing, handlers, request IDs, metrics middleware, and rate limiting. - `telemetry`: OpenTelemetry metrics and tracing setup. -- `bin`: executable entrypoints for the API and the local CLI helper. +- `bin`: executable entrypoint for the API. ## Deliberate trade-offs diff --git a/src/bin/pokedex-cli.rs b/src/bin/pokedex-cli.rs deleted file mode 100644 index 444eef1..0000000 --- a/src/bin/pokedex-cli.rs +++ /dev/null @@ -1,275 +0,0 @@ -#![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:8000"; -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:8000 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:8000/pokemon/mewtwo"); - } - - #[test] - fn parses_custom_base_url_and_translated_command() { - let args = parse_args([ - "--base-url", - "http://localhost:8000/", - "translated", - "mr-mime", - ]) - .expect("args should parse"); - - assert_eq!(args.base_url, "http://localhost:8000"); - assert_eq!( - args.url(), - "http://localhost:8000/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")); - } -}