fix: remove cli
All checks were successful
Rust / test (push) Successful in 29s
Rust / check (push) Successful in 34s
Docker / build (push) Successful in 1m23s

This commit is contained in:
2026-06-16 14:45:56 +02:00
parent 8268bd9692
commit 59cf0d861d
3 changed files with 2 additions and 301 deletions

View File

@@ -9,16 +9,13 @@ POKEMON ?= mewtwo
PLATFORMS ?= linux/amd64,linux/arm64 PLATFORMS ?= linux/amd64,linux/arm64
GIT_SHA ?= $(shell git rev-parse HEAD 2>/dev/null || printf unknown) 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: help:
@printf 'Available targets:\n' @printf 'Available targets:\n'
@printf ' make run Run the API locally with cargo\n' @printf ' make run Run the API locally with cargo\n'
@printf ' make docker-build Build the Docker image\n' @printf ' make docker-build Build the Docker image\n'
@printf ' make docker-run Run the Docker image on port 8000\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 health curl /health\n'
@printf ' make pokemon curl /pokemon/$${POKEMON}\n' @printf ' make pokemon curl /pokemon/$${POKEMON}\n'
@printf ' make translated curl /pokemon/translated/$${POKEMON}\n' @printf ' make translated curl /pokemon/translated/$${POKEMON}\n'
@@ -36,15 +33,6 @@ docker-build:
docker-run: docker-run:
$(DOCKER) run --rm -p 8000:8000 $(IMAGE) $(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: health:
curl -fsS $(BASE_URL)/health curl -fsS $(BASE_URL)/health

View File

@@ -39,17 +39,6 @@ make run
The API listens on `0.0.0.0:8000` by default. 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 ## Endpoints
```bash ```bash
@@ -80,7 +69,6 @@ This is a small Rust service, so the structure is intentionally a lightweight he
```mermaid ```mermaid
flowchart LR flowchart LR
CLI[CLI helper] --> HTTP
HTTP[HTTP layer: axum routes, handlers, middleware] --> Application HTTP[HTTP layer: axum routes, handlers, middleware] --> Application
Application[Application service: use cases and translation rules] --> Domain Application[Application service: use cases and translation rules] --> Domain
Application --> Clients Application --> Clients
@@ -96,7 +84,7 @@ flowchart LR
- `clients`: adapters for external providers (`PokéAPI`, `FunTranslations`). - `clients`: adapters for external providers (`PokéAPI`, `FunTranslations`).
- `http`: API transport concerns, routing, handlers, request IDs, metrics middleware, and rate limiting. - `http`: API transport concerns, routing, handlers, request IDs, metrics middleware, and rate limiting.
- `telemetry`: OpenTelemetry metrics and tracing setup. - `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 ## Deliberate trade-offs

View File

@@ -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<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: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"));
}
}