Compare commits
24
Commits
main
..
fe6244f51c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe6244f51c | ||
|
|
0ffb1e8b5f | ||
|
|
23eaea36c1 | ||
|
|
cf11e20f90 | ||
|
|
b910e77460 | ||
|
|
c35f8cce14 | ||
|
|
d6fc3bdd4b | ||
|
|
ce959b59bf | ||
|
|
aa3b678cc5 | ||
|
|
6fc5cc1bc8 | ||
|
|
0f195de9e5 | ||
|
|
da910a88c7 | ||
|
|
a6dd497e8a | ||
|
|
653accc511 | ||
|
|
78ff3473ee | ||
|
|
66496ec476 | ||
|
|
c92714d13b | ||
|
|
cde25f6dbb | ||
|
|
c64b738117 | ||
|
|
e7cf7729ee | ||
|
|
0a1c6ca7ab | ||
|
|
5acf241033 | ||
|
|
3eb57cd327 | ||
|
|
b84de88585 |
@@ -19,18 +19,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Select Docker platforms
|
||||
id: docker-platforms
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${ACT:-}" = "true" ] || [ "${GITEA_ACTIONS:-}" = "true" ]; then
|
||||
echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "platforms=linux/amd64,linux/arm64" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Set up QEMU
|
||||
if: ${{ steps.docker-platforms.outputs.platforms != 'linux/amd64' }}
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
@@ -42,7 +31,7 @@ jobs:
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ steps.docker-platforms.outputs.platforms }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: false
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
|
||||
+3
-3
@@ -74,11 +74,11 @@ COPY --from=builder /usr/local/bin/pokedex-api /usr/local/bin/pokedex-api
|
||||
|
||||
ARG GIT_SHA=unknown
|
||||
ENV GIT_SHA=$GIT_SHA \
|
||||
BIND_ADDR=0.0.0.0:8000
|
||||
BIND_ADDR=0.0.0.0:5000
|
||||
|
||||
EXPOSE 8000
|
||||
EXPOSE 5000
|
||||
USER app
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:8000/health || exit 1
|
||||
CMD curl -fsS http://127.0.0.1:5000/health || exit 1
|
||||
|
||||
CMD ["pokedex-api"]
|
||||
|
||||
@@ -4,18 +4,21 @@ CARGO ?= cargo
|
||||
DOCKER ?= docker
|
||||
IMAGE ?= pokedex-api
|
||||
TAG ?= local
|
||||
BASE_URL ?= http://localhost:8000
|
||||
BASE_URL ?= http://localhost:5000
|
||||
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 health pokemon translated metrics fmt clippy test check contract-test docker-buildx
|
||||
.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
|
||||
|
||||
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 docker-run Run the Docker image on port 5000\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'
|
||||
@@ -31,7 +34,16 @@ docker-build:
|
||||
$(DOCKER) build -t $(IMAGE) .
|
||||
|
||||
docker-run:
|
||||
$(DOCKER) run --rm -p 8000:8000 $(IMAGE)
|
||||
$(DOCKER) run --rm -p 5000:5000 $(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
|
||||
|
||||
@@ -2,14 +2,10 @@
|
||||
|
||||
REST API for the TrueLayer Software Engineering Challenge. It returns basic Pokémon information from PokéAPI and, on request, a fun translated description from FunTranslations.
|
||||
|
||||
[File challenge](TrueLayer-_Software_Engineering_Challenge_2026.pdf)
|
||||
|
||||
## Requirements
|
||||
|
||||
Install these tools first:
|
||||
|
||||
- Rust, via `rustup`, for local development and tests.
|
||||
- `make`, for the documented command shortcuts.
|
||||
- Docker, for the containerized run path.
|
||||
|
||||
You can run it either with Docker or with a local Rust toolchain. Common commands are available through `make`; run `make help` for the full list.
|
||||
|
||||
### Option A: Docker
|
||||
@@ -37,7 +33,18 @@ Then run the service:
|
||||
make run
|
||||
```
|
||||
|
||||
The API listens on `0.0.0.0:8000` 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
|
||||
make cli-health
|
||||
make cli-pokemon
|
||||
make cli-translated
|
||||
make cli-pokemon POKEMON=pikachu BASE_URL=http://localhost:5000
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
@@ -63,42 +70,25 @@ Example response:
|
||||
|
||||
`/pokemon/translated/{name}` applies Yoda when the Pokémon is legendary or its habitat is `cave`; otherwise it applies Shakespeare. If translation fails or returns an empty result, the API falls back to the standard PokéAPI description.
|
||||
|
||||
# Architecture
|
||||
|
||||
This is a small Rust service, so the structure is intentionally a lightweight hexagonal/onion layout rather than a framework-heavy one.
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
HTTP[HTTP layer: axum routes, handlers, middleware] --> Application
|
||||
Application[Application service: use cases and translation rules] --> Domain
|
||||
Application --> Clients
|
||||
Clients[External clients: PokéAPI and FunTranslations]
|
||||
HTTP --> Telemetry
|
||||
Application --> Telemetry
|
||||
CLI[CLI helper] --> HTTP[HTTP layer]
|
||||
HTTP --> APP[Application service]
|
||||
APP --> DOMAIN[Domain]
|
||||
APP --> CLIENTS[External clients]
|
||||
HTTP --> OTEL[Telemetry]
|
||||
APP --> OTEL
|
||||
```
|
||||
|
||||
## Layers
|
||||
|
||||
- `domain`: core data and business concepts (`PokemonInfo`, `TranslationKind`).
|
||||
- `application`: orchestration and business rules, including Yoda vs Shakespeare selection and fallback behavior.
|
||||
- `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 entrypoint for the API.
|
||||
|
||||
## Deliberate trade-offs
|
||||
|
||||
The application service currently depends on concrete clients instead of trait-based ports. For this challenge that keeps the code easier to read and avoids abstractions that do not buy much yet. In a larger enterprise codebase, those clients would likely become traits owned by the application layer, with HTTP clients as adapters, so providers could be swapped or mocked without depending on concrete implementations.
|
||||
|
||||
Rate limiting is intentionally simple and in-memory. In production, this should usually live in an API gateway or shared rate-limiting service so limits are consistent across instances and can be keyed by API key, user, or client IP. PokéAPI species data also changes rarely, so production deployments would add a bounded LRU cache with TTL for successful species responses to avoid unnecessary random upstream hits, reduce latency, and make transient provider failures less visible to users.
|
||||
|
||||
External contract tests are separated from the normal deterministic test suite. Unit and integration tests use mocks; the nightly workflow calls real providers to detect contract drift early.
|
||||
See `docs/architecture.md` for the design notes.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --------------------------- | ------------------------------------------- | -------------------------- |
|
||||
| `BIND_ADDR` | `0.0.0.0:8000` | HTTP bind address |
|
||||
| `BIND_ADDR` | `0.0.0.0:5000` | HTTP bind address |
|
||||
| `POKEAPI_BASE_URL` | `https://pokeapi.co/api/v2` | PokéAPI base URL |
|
||||
| `FUN_TRANSLATIONS_BASE_URL` | `https://api.funtranslations.mercxry.me/v1` | FunTranslations base URL |
|
||||
| `REQUEST_TIMEOUT_SECONDS` | `5` | Upstream HTTP timeout |
|
||||
@@ -144,4 +134,4 @@ The Dockerfile uses `cargo-chef` and BuildKit cache mounts for dependency and ta
|
||||
|
||||
## Production notes
|
||||
|
||||
For a production API I would add retries with bounded exponential backoff, circuit breakers for upstream failures, stronger health checks that distinguish readiness from liveness, dashboards and alerts from the OTEL metrics, and a dedicated OTEL collector pipeline. I would keep `/metrics` private, add dependency/container scanning, define SLOs, and add contract tests against recorded upstream fixtures.
|
||||
For a production API I would add per-client or per-token rate limiting instead of one global bucket, retries with bounded exponential backoff, circuit breakers for upstream failures, and a bounded LRU/TTL cache for PokéAPI data. Species content changes rarely, so caching successful responses would avoid unnecessary random upstream hits, reduce latency, and make transient provider failures less visible to users. I would also add stronger health checks that distinguish readiness from liveness, dashboards and alerts from the OTEL metrics, and a dedicated OTEL collector pipeline. I would keep `/metrics` private, add dependency/container scanning, define SLOs, and add contract tests against recorded upstream fixtures.
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
# Architecture
|
||||
|
||||
This is a small Rust service, so the structure is intentionally a lightweight hexagonal/onion layout rather than a framework-heavy one.
|
||||
|
||||
```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
|
||||
Clients[External clients: PokéAPI and FunTranslations]
|
||||
HTTP --> Telemetry
|
||||
Application --> Telemetry
|
||||
```
|
||||
|
||||
## Layers
|
||||
|
||||
- `domain`: core data and business concepts (`PokemonInfo`, `TranslationKind`).
|
||||
- `application`: orchestration and business rules, including Yoda vs Shakespeare selection and fallback behavior.
|
||||
- `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.
|
||||
|
||||
## Deliberate trade-offs
|
||||
|
||||
The application service currently depends on concrete clients instead of trait-based ports. For this challenge that keeps the code easier to read and avoids abstractions that do not buy much yet. In a larger enterprise codebase, those clients would likely become traits owned by the application layer, with HTTP clients as adapters, so providers could be swapped or mocked without depending on concrete implementations.
|
||||
|
||||
PokéAPI species data changes rarely, so production deployments would add a bounded LRU cache with TTL for successful species responses. That would avoid unnecessary random upstream hits, reduce latency, and make transient provider failures less visible to users.
|
||||
|
||||
Rate limiting is intentionally simple and in-memory. In production, this should usually live in an API gateway or shared rate-limiting service so limits are consistent across instances and can be keyed by API key, user, or client IP.
|
||||
|
||||
External contract tests are separated from the normal deterministic test suite. Unit and integration tests use mocks; the nightly workflow calls real providers to detect contract drift early.
|
||||
+1
-1
@@ -4,7 +4,7 @@ info:
|
||||
version: 0.1.0
|
||||
description: REST API for the TrueLayer Pokémon challenge.
|
||||
servers:
|
||||
- url: http://localhost:8000
|
||||
- url: http://localhost:5000
|
||||
paths:
|
||||
/health:
|
||||
get:
|
||||
|
||||
@@ -113,3 +113,144 @@ pub fn translation_kind_for(pokemon: &PokemonInfo) -> TranslationKind {
|
||||
TranslationKind::Shakespeare
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{PokedexService, translation_kind_for};
|
||||
use crate::{
|
||||
clients::{funtranslations::TranslationClient, pokeapi::PokeApiClient},
|
||||
domain::{PokemonInfo, TranslationKind},
|
||||
telemetry::Telemetry,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
|
||||
|
||||
#[test]
|
||||
fn chooses_yoda_for_legendary_pokemon() {
|
||||
let pokemon = pokemon_info("rare", true, "Created by science.");
|
||||
|
||||
assert_eq!(translation_kind_for(&pokemon), TranslationKind::Yoda);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chooses_yoda_for_cave_pokemon() {
|
||||
let pokemon = pokemon_info("cave", false, "Sleeps upside down.");
|
||||
|
||||
assert_eq!(translation_kind_for(&pokemon), TranslationKind::Yoda);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chooses_shakespeare_for_regular_pokemon() {
|
||||
let pokemon = pokemon_info("forest", false, "Electric cheeks.");
|
||||
|
||||
assert_eq!(translation_kind_for(&pokemon), TranslationKind::Shakespeare);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn translates_legendary_pokemon_with_yoda() {
|
||||
let pokeapi = MockServer::start().await;
|
||||
let translations = MockServer::start().await;
|
||||
mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").await;
|
||||
Mock::given(matchers::method("POST"))
|
||||
.and(matchers::path("/translate/yoda"))
|
||||
.and(matchers::body_string_contains("Created by science"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"contents": { "translated": "Created by science, it was." }
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&translations)
|
||||
.await;
|
||||
let service = service_for(&pokeapi, &translations);
|
||||
|
||||
let pokemon = service
|
||||
.get_translated("mewtwo")
|
||||
.await
|
||||
.expect("translated pokemon should be returned");
|
||||
|
||||
assert_eq!(pokemon.description, "Created by science, it was.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn translates_regular_pokemon_with_shakespeare() {
|
||||
let pokeapi = MockServer::start().await;
|
||||
let translations = MockServer::start().await;
|
||||
mount_species(&pokeapi, "pikachu", false, "forest", "Electric cheeks.").await;
|
||||
Mock::given(matchers::method("POST"))
|
||||
.and(matchers::path("/translate/shakespeare"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"contents": { "translated": "Electric cheeks, good sir." }
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&translations)
|
||||
.await;
|
||||
let service = service_for(&pokeapi, &translations);
|
||||
|
||||
let pokemon = service
|
||||
.get_translated("pikachu")
|
||||
.await
|
||||
.expect("translated pokemon should be returned");
|
||||
|
||||
assert_eq!(pokemon.description, "Electric cheeks, good sir.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn falls_back_to_original_description_when_translation_fails() {
|
||||
let pokeapi = MockServer::start().await;
|
||||
let translations = MockServer::start().await;
|
||||
mount_species(&pokeapi, "zubat", false, "cave", "It has no eyes.").await;
|
||||
Mock::given(matchers::method("POST"))
|
||||
.and(matchers::path("/translate/yoda"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.expect(1)
|
||||
.mount(&translations)
|
||||
.await;
|
||||
let service = service_for(&pokeapi, &translations);
|
||||
|
||||
let pokemon = service
|
||||
.get_translated("zubat")
|
||||
.await
|
||||
.expect("translation failure should not fail the endpoint");
|
||||
|
||||
assert_eq!(pokemon.description, "It has no eyes.");
|
||||
}
|
||||
|
||||
async fn mount_species(
|
||||
server: &MockServer,
|
||||
name: &str,
|
||||
is_legendary: bool,
|
||||
habitat: &str,
|
||||
description: &str,
|
||||
) {
|
||||
Mock::given(matchers::method("GET"))
|
||||
.and(matchers::path(format!("/pokemon-species/{name}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"name": name,
|
||||
"is_legendary": is_legendary,
|
||||
"habitat": { "name": habitat },
|
||||
"flavor_text_entries": [
|
||||
{ "flavor_text": description, "language": { "name": "en" } }
|
||||
]
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn pokemon_info(habitat: &str, is_legendary: bool, description: &str) -> PokemonInfo {
|
||||
PokemonInfo {
|
||||
name: "testmon".to_owned(),
|
||||
description: description.to_owned(),
|
||||
habitat: habitat.to_owned(),
|
||||
is_legendary,
|
||||
}
|
||||
}
|
||||
|
||||
fn service_for(pokeapi: &MockServer, translations: &MockServer) -> PokedexService {
|
||||
PokedexService::new(
|
||||
PokeApiClient::new(pokeapi.uri(), Duration::from_secs(1)).expect("client should build"),
|
||||
TranslationClient::new(translations.uri(), Duration::from_secs(1))
|
||||
.expect("client should build"),
|
||||
Telemetry::new("test-service").metrics(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -66,7 +66,7 @@ fn env_or_string(name: &'static str, default: &str) -> String {
|
||||
}
|
||||
|
||||
fn default_bind_addr() -> SocketAddr {
|
||||
SocketAddr::from(([0, 0, 0, 0], 8000))
|
||||
SocketAddr::from(([0, 0, 0, 0], 5000))
|
||||
}
|
||||
|
||||
const fn validate_rate_limit(per_second: f64, burst: u32) -> Result<(), ConfigError> {
|
||||
@@ -101,7 +101,7 @@ mod tests {
|
||||
fn default_config_is_valid_for_local_development() {
|
||||
let config = AppConfig::default_local();
|
||||
|
||||
assert_eq!(config.bind_addr.port(), 8000);
|
||||
assert_eq!(config.bind_addr.port(), 5000);
|
||||
assert_eq!(config.pokeapi_base_url, "https://pokeapi.co/api/v2");
|
||||
assert!((config.rate_limit_per_second - 20.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ impl TokenBucket {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RateLimiter;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn rejects_non_positive_or_non_finite_configuration() {
|
||||
@@ -86,4 +87,16 @@ mod tests {
|
||||
assert!(limiter.try_acquire());
|
||||
assert!(!limiter.try_acquire());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refills_tokens_over_time() {
|
||||
let limiter = RateLimiter::new(100.0, 1).expect("rate limiter should build");
|
||||
|
||||
assert!(limiter.try_acquire());
|
||||
assert!(!limiter.try_acquire());
|
||||
// A production-grade limiter would inject a clock; a short sleep keeps
|
||||
// this challenge implementation simple without coupling tests to internals.
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
assert!(limiter.try_acquire());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +246,29 @@ async fn keeps_incoming_request_id_when_present() {
|
||||
assert_eq!(response.headers()["x-request-id"], "external-id");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metrics_endpoint_exposes_http_and_upstream_otel_metrics() {
|
||||
let pokeapi = MockServer::start().await;
|
||||
let translations = MockServer::start().await;
|
||||
mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").await;
|
||||
let app = create_app(state_for(&pokeapi, &translations));
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request("/pokemon/mewtwo"))
|
||||
.await
|
||||
.expect("request should be handled");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let response = app
|
||||
.oneshot(request("/metrics"))
|
||||
.await
|
||||
.expect("request should be handled");
|
||||
let body = text_body(response).await;
|
||||
|
||||
assert!(body.contains("http_server_requests"));
|
||||
assert!(body.contains("upstream_client_requests"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_rejects_requests_over_the_configured_burst() {
|
||||
let pokeapi = MockServer::start().await;
|
||||
@@ -341,6 +364,14 @@ async fn pokemon_endpoint_maps_missing_pokemon_to_404() {
|
||||
assert_eq!(error["code"], "pokemon_not_found");
|
||||
assert_eq!(error["message"], "pokemon not found");
|
||||
assert_eq!(error["requestId"], "known-request-id");
|
||||
|
||||
let metrics = app
|
||||
.oneshot(request("/metrics"))
|
||||
.await
|
||||
.expect("request should be handled");
|
||||
let body = text_body(metrics).await;
|
||||
assert!(body.contains("status=\"not_found\""));
|
||||
assert!(!body.contains("upstream_client_errors"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -474,6 +505,10 @@ async fn json_body(response: axum::response::Response) -> Value {
|
||||
serde_json::from_slice(&bytes).expect("body should be valid JSON")
|
||||
}
|
||||
|
||||
async fn text_body(response: axum::response::Response) -> String {
|
||||
String::from_utf8(body_bytes(response).await.to_vec()).expect("body should be valid UTF-8")
|
||||
}
|
||||
|
||||
async fn body_bytes(response: axum::response::Response) -> axum::body::Bytes {
|
||||
to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
use pokedex_api::telemetry::{Telemetry, UpstreamRequestOutcome};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn records_http_and_upstream_metrics_in_prometheus_text_format() {
|
||||
let telemetry = Telemetry::new("test-service");
|
||||
let metrics = telemetry.metrics();
|
||||
|
||||
metrics.record_http_request("GET", "/pokemon/{name}", 200, Duration::from_millis(10));
|
||||
metrics.record_upstream_request(
|
||||
"pokeapi",
|
||||
"pokemon_species",
|
||||
UpstreamRequestOutcome::Success,
|
||||
Duration::from_millis(5),
|
||||
);
|
||||
let rendered = telemetry
|
||||
.render_prometheus()
|
||||
.expect("metrics should render as text");
|
||||
|
||||
assert!(rendered.contains("http_server_requests"));
|
||||
assert!(rendered.contains("upstream_client_requests"));
|
||||
assert!(rendered.contains("service_name=\"test-service\""));
|
||||
}
|
||||
Reference in New Issue
Block a user