Compare commits

..

10 Commits

Author SHA1 Message Date
fe6244f51c feat: add challenge file
Some checks failed
Docker / build (push) Failing after 28s
Rust / test (push) Successful in 53s
Rust / check (push) Successful in 56s
2026-06-16 13:53:39 +02:00
0ffb1e8b5f chore: add make targets 2026-06-15 12:35:58 +02:00
23eaea36c1 fix: normalize pokemon lookup names 2026-06-15 12:26:26 +02:00
cf11e20f90 feat: document api contract 2026-06-15 12:26:26 +02:00
b910e77460 ci: add nightly external contract tests 2026-06-15 12:26:26 +02:00
c35f8cce14 fix: use mercxry translations api 2026-06-15 12:26:26 +02:00
d6fc3bdd4b feat: add api test cli 2026-06-15 12:26:26 +02:00
ce959b59bf fix: cover api edge cases 2026-06-15 12:26:26 +02:00
aa3b678cc5 refactor: introduce lightweight hexagonal layout 2026-06-15 12:26:26 +02:00
6fc5cc1bc8 refactor: move rate limiter into http layer 2026-06-15 12:26:26 +02:00
31 changed files with 1377 additions and 144 deletions

View File

@@ -25,6 +25,8 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
# Production hardening: add dependency and image scanning before publishing,
# for example cargo-audit plus a container scanner such as Sysdig or Trivy.
- name: Build multi-arch image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:

View File

@@ -0,0 +1,30 @@
name: External contracts
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_PROFILE_TEST_DEBUG: line-tables-only
CARGO_INCREMENTAL: 0
jobs:
external-contracts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
# These tests intentionally call real third-party APIs. Unit/integration
# tests mock providers, but a nightly contract check catches external API
# drift that would otherwise only appear at runtime.
- name: Run external contract tests
run: cargo test --locked --test external_contract -- --ignored --test-threads=1

75
Makefile Normal file
View File

@@ -0,0 +1,75 @@
SHELL := /bin/sh
CARGO ?= cargo
DOCKER ?= docker
IMAGE ?= pokedex-api
TAG ?= local
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 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 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'
@printf ' make metrics curl /metrics\n'
@printf ' make check Run fmt, clippy, and tests\n'
@printf ' make contract-test Run ignored external contract tests\n'
@printf ' make docker-buildx Build multi-arch Docker image\n'
run:
$(CARGO) run --bin pokedex-api
docker-build:
$(DOCKER) build -t $(IMAGE) .
docker-run:
$(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
pokemon:
curl -fsS $(BASE_URL)/pokemon/$(POKEMON)
translated:
curl -fsS $(BASE_URL)/pokemon/translated/$(POKEMON)
metrics:
curl -fsS $(BASE_URL)/metrics
fmt:
$(CARGO) fmt --all --check
clippy:
$(CARGO) clippy --locked --all-targets -- -D warnings
test:
$(CARGO) test --locked
check: fmt clippy test
contract-test:
$(CARGO) test --locked --test external_contract -- --ignored --test-threads=1
docker-buildx:
$(DOCKER) buildx build --platform $(PLATFORMS) --build-arg GIT_SHA=$(GIT_SHA) -t $(IMAGE):$(TAG) .

View File

@@ -2,17 +2,19 @@
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
You can run it either with Docker or with a local Rust toolchain.
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
Install Docker Desktop or an equivalent Docker runtime, then run:
```bash
docker build -t pokedex-api .
docker run --rm -p 5000:5000 pokedex-api
make docker-build
make docker-run
```
### Option B: local Rust
@@ -28,18 +30,29 @@ rustup default stable
Then run the service:
```bash
cargo run
make 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
make cli-health
make cli-pokemon
make cli-translated
make cli-pokemon POKEMON=pikachu BASE_URL=http://localhost:5000
```
## Endpoints
```bash
curl http://localhost:5000/health
curl http://localhost:5000/pokemon/mewtwo
curl http://localhost:5000/pokemon/translated/mewtwo
curl http://localhost:5000/metrics
make health
make pokemon
make translated
make metrics
```
Example response:
@@ -53,13 +66,31 @@ Example response:
}
```
## Translation rules
`/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
```mermaid
flowchart LR
CLI[CLI helper] --> HTTP[HTTP layer]
HTTP --> APP[Application service]
APP --> DOMAIN[Domain]
APP --> CLIENTS[External clients]
HTTP --> OTEL[Telemetry]
APP --> OTEL
```
See `docs/architecture.md` for the design notes.
## Configuration
| Variable | Default | Description |
| --- | --- | --- |
| --------------------------- | ------------------------------------------- | -------------------------- |
| `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://funtranslations.mercxry.me` | FunTranslations base URL |
| `FUN_TRANSLATIONS_BASE_URL` | `https://api.funtranslations.mercxry.me/v1` | FunTranslations base URL |
| `REQUEST_TIMEOUT_SECONDS` | `5` | Upstream HTTP timeout |
| `RATE_LIMIT_PER_SECOND` | `20` | Global token refill rate |
| `RATE_LIMIT_BURST` | `40` | Global burst capacity |
@@ -80,24 +111,27 @@ The service includes:
## Development checks
```bash
cargo fmt --all --check
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked
make check
```
The crate denies unsafe code and enables strict Clippy groups: `all`, `pedantic`, `nursery` and `cargo`, with only dependency-version noise allowed.
## External contract checks
Normal tests mock third-party APIs. A scheduled GitHub Actions workflow runs ignored contract tests nightly against the real PokéAPI and FunTranslations APIs to catch provider contract drift early:
```bash
make contract-test
```
## Docker multi-arch build
```bash
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg GIT_SHA="$(git rev-parse HEAD)" \
-t pokedex-api:local .
make docker-buildx
```
The Dockerfile uses `cargo-chef` and BuildKit cache mounts for dependency and target directory caching.
## Production notes
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, response caching for PokéAPI data, stronger health checks that distinguish readiness from liveness, dashboards and alerts from the OTEL metrics, and a dedicated OTEL collector pipeline. I would also separate public API traffic from `/metrics`, 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.

33
docs/architecture.md Normal file
View File

@@ -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.

170
openapi.yaml Normal file
View File

@@ -0,0 +1,170 @@
openapi: 3.1.0
info:
title: Pokedex API
version: 0.1.0
description: REST API for the TrueLayer Pokémon challenge.
servers:
- url: http://localhost:5000
paths:
/health:
get:
summary: Health check
responses:
"200":
description: Service is alive
content:
application/json:
schema:
$ref: "#/components/schemas/HealthResponse"
/pokemon/{name}:
get:
summary: Return basic Pokémon information
parameters:
- $ref: "#/components/parameters/PokemonName"
responses:
"200":
description: Pokémon information
content:
application/json:
schema:
$ref: "#/components/schemas/PokemonInfo"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/PokemonNotFound"
"502":
$ref: "#/components/responses/UpstreamFailure"
"429":
$ref: "#/components/responses/RateLimited"
/pokemon/translated/{name}:
get:
summary: Return Pokémon information with a fun translated description
description: Uses Yoda for legendary or cave Pokémon, Shakespeare otherwise. Falls back to the standard description when translation fails.
parameters:
- $ref: "#/components/parameters/PokemonName"
responses:
"200":
description: Pokémon information with translated or fallback description
content:
application/json:
schema:
$ref: "#/components/schemas/PokemonInfo"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/PokemonNotFound"
"502":
$ref: "#/components/responses/UpstreamFailure"
"429":
$ref: "#/components/responses/RateLimited"
/metrics:
get:
summary: OpenTelemetry metrics in Prometheus text format
responses:
"200":
description: Prometheus text exposition
content:
text/plain:
schema:
type: string
components:
parameters:
PokemonName:
name: name
in: path
required: true
description: Pokémon identifier. Only ASCII letters, digits, and hyphens are accepted.
schema:
type: string
pattern: "^[A-Za-z0-9-]+$"
example: mewtwo
responses:
BadRequest:
description: Invalid request
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
example:
code: bad_request
message: pokemon name must contain only ASCII letters, digits, or hyphens
requestId: 7b2d6f7b-78ef-4cc3-85fd-5f09145fbd18
PokemonNotFound:
description: Pokémon was not found by PokéAPI
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
example:
code: pokemon_not_found
message: pokemon not found
requestId: 7b2d6f7b-78ef-4cc3-85fd-5f09145fbd18
UpstreamFailure:
description: An upstream provider failed or returned invalid data
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
examples:
unavailable:
value:
code: upstream_unavailable
message: upstream service failed
requestId: 7b2d6f7b-78ef-4cc3-85fd-5f09145fbd18
invalidData:
value:
code: upstream_invalid_data
message: upstream service returned invalid data
requestId: 7b2d6f7b-78ef-4cc3-85fd-5f09145fbd18
RateLimited:
description: Request was rate limited
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
example:
code: rate_limit_exceeded
message: rate limit exceeded
requestId: 7b2d6f7b-78ef-4cc3-85fd-5f09145fbd18
schemas:
HealthResponse:
type: object
required: [status]
properties:
status:
type: string
const: ok
PokemonInfo:
type: object
required: [name, description, habitat, isLegendary]
properties:
name:
type: string
example: mewtwo
description:
type: string
example: It was created by a scientist after years of horrific gene splicing and DNA engineering experiments.
habitat:
type: string
example: rare
isLegendary:
type: boolean
example: true
ErrorResponse:
type: object
required: [code, message]
properties:
code:
type: string
enum:
- bad_request
- pokemon_not_found
- upstream_invalid_data
- upstream_unavailable
- rate_limit_exceeded
- internal_error
message:
type: string
requestId:
type: string
description: Request correlation id from x-request-id.

3
src/application/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
mod pokedex;
pub use pokedex::{PokedexService, translation_kind_for};

View File

@@ -1,13 +1,15 @@
use crate::{
domain::PokemonInfo,
clients::{funtranslations::TranslationClient, pokeapi::PokeApiClient},
domain::{PokemonInfo, TranslationKind},
error::AppError,
pokemon::PokeApiClient,
telemetry::AppMetrics,
translation::{TranslationClient, TranslationKind},
telemetry::{AppMetrics, UpstreamRequestOutcome},
};
use std::time::Instant;
use tracing::{debug, instrument};
// The application service depends on concrete clients to keep this challenge
// concise. In a larger enterprise codebase these would become trait-based ports
// so adapters could be swapped without touching business logic.
#[derive(Clone)]
pub struct PokedexService {
pokemon: PokeApiClient,
@@ -46,8 +48,12 @@ impl PokedexService {
.await;
self.metrics.record_upstream_request(
"funtranslations",
translation_kind.endpoint(),
translation.is_ok(),
translation_operation(translation_kind),
if translation.is_ok() {
UpstreamRequestOutcome::Success
} else {
UpstreamRequestOutcome::Error
},
translation_start.elapsed(),
);
@@ -60,18 +66,45 @@ impl PokedexService {
}
async fn fetch_pokemon(&self, name: &str) -> Result<PokemonInfo, AppError> {
// PokéAPI species data changes rarely. In production, cache successful
// species responses with a TTL to reduce latency and upstream pressure.
let start = Instant::now();
let pokemon = self.pokemon.get_pokemon_info(name).await;
if !matches!(pokemon, Err(AppError::BadRequest(_))) {
self.metrics.record_upstream_request(
"pokeapi",
"pokemon_species",
pokemon.is_ok(),
upstream_outcome_for_pokemon(&pokemon),
start.elapsed(),
);
}
pokemon
}
}
const fn upstream_outcome_for_pokemon(
result: &Result<PokemonInfo, AppError>,
) -> UpstreamRequestOutcome {
match result {
Ok(_) => UpstreamRequestOutcome::Success,
Err(AppError::NotFound) => UpstreamRequestOutcome::NotFound,
Err(
AppError::BadRequest(_)
| AppError::InvalidUpstreamData(_)
| AppError::Upstream(_)
| AppError::Timeout
| AppError::Internal,
) => UpstreamRequestOutcome::Error,
}
}
const fn translation_operation(kind: TranslationKind) -> &'static str {
match kind {
TranslationKind::Shakespeare => "shakespeare",
TranslationKind::Yoda => "yoda",
}
}
#[must_use]
pub fn translation_kind_for(pokemon: &PokemonInfo) -> TranslationKind {
if pokemon.needs_yoda_translation() {
@@ -85,8 +118,9 @@ pub fn translation_kind_for(pokemon: &PokemonInfo) -> TranslationKind {
mod tests {
use super::{PokedexService, translation_kind_for};
use crate::{
domain::PokemonInfo, pokemon::PokeApiClient, telemetry::Telemetry,
translation::TranslationClient, translation::TranslationKind,
clients::{funtranslations::TranslationClient, pokeapi::PokeApiClient},
domain::{PokemonInfo, TranslationKind},
telemetry::Telemetry,
};
use std::time::Duration;
use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
@@ -118,8 +152,8 @@ mod tests {
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.json"))
.and(matchers::body_string_contains("Created+by+science"))
.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." }
})))
@@ -142,7 +176,7 @@ mod tests {
let translations = MockServer::start().await;
mount_species(&pokeapi, "pikachu", false, "forest", "Electric cheeks.").await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/translate/shakespeare.json"))
.and(matchers::path("/translate/shakespeare"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"contents": { "translated": "Electric cheeks, good sir." }
})))
@@ -165,7 +199,7 @@ mod tests {
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.json"))
.and(matchers::path("/translate/yoda"))
.respond_with(ResponseTemplate::new(500))
.expect(1)
.mount(&translations)

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

51
src/clients/body.rs Normal file
View File

@@ -0,0 +1,51 @@
use crate::error::AppError;
use serde::de::DeserializeOwned;
const MAX_UPSTREAM_BODY_BYTES: usize = 1024 * 1024;
pub async fn read_json<T>(
mut response: reqwest::Response,
service: &'static str,
) -> Result<T, AppError>
where
T: DeserializeOwned,
{
if response
.content_length()
.is_some_and(|length| length > MAX_UPSTREAM_BODY_BYTES as u64)
{
return Err(AppError::Upstream(format!(
"{service} response body exceeded {MAX_UPSTREAM_BODY_BYTES} bytes"
)));
}
let mut body = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|error| map_reqwest_error(&error))?
{
let next_len = body
.len()
.checked_add(chunk.len())
.ok_or(AppError::Internal)?;
if next_len > MAX_UPSTREAM_BODY_BYTES {
return Err(AppError::Upstream(format!(
"{service} response body exceeded {MAX_UPSTREAM_BODY_BYTES} bytes"
)));
}
body.extend_from_slice(&chunk);
}
serde_json::from_slice(&body).map_err(|error| {
AppError::InvalidUpstreamData(format!("{service} returned invalid JSON: {error}"))
})
}
fn map_reqwest_error(error: &reqwest::Error) -> AppError {
if error.is_timeout() {
AppError::Timeout
} else {
AppError::Upstream(error.to_string())
}
}

View File

@@ -1,4 +1,4 @@
use crate::error::AppError;
use crate::{clients::body, domain::TranslationKind, error::AppError};
use serde::Deserialize;
use std::time::Duration;
use tracing::instrument;
@@ -9,22 +9,6 @@ pub struct TranslationClient {
http: reqwest::Client,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TranslationKind {
Shakespeare,
Yoda,
}
impl TranslationKind {
#[must_use]
pub const fn endpoint(self) -> &'static str {
match self {
Self::Shakespeare => "shakespeare",
Self::Yoda => "yoda",
}
}
}
impl TranslationClient {
pub fn new(base_url: impl Into<String>, timeout: Duration) -> Result<Self, AppError> {
let http = reqwest::Client::builder()
@@ -40,11 +24,11 @@ impl TranslationClient {
#[instrument(skip(self, text), fields(translation.kind = ?kind))]
pub async fn translate(&self, kind: TranslationKind, text: &str) -> Result<String, AppError> {
let url = format!("{}/translate/{}.json", self.base_url, kind.endpoint());
let url = format!("{}/translate/{}", self.base_url, endpoint_for(kind));
let response = self
.http
.post(url)
.form(&[("text", text)])
.json(&TranslationRequest { text })
.send()
.await
.map_err(|error| map_reqwest_error(&error))?;
@@ -56,12 +40,20 @@ impl TranslationClient {
)));
}
response
.json::<TranslationResponse>()
.await
.map(|payload| payload.contents.translated)
.map_err(|error| map_reqwest_error(&error))
let payload = body::read_json::<TranslationResponse>(response, "FunTranslations").await?;
if payload.contents.translated.trim().is_empty() {
Err(AppError::InvalidUpstreamData(
"FunTranslations returned an empty translation".to_owned(),
))
} else {
Ok(payload.contents.translated)
}
}
}
#[derive(serde::Serialize)]
struct TranslationRequest<'a> {
text: &'a str,
}
#[derive(Debug, Deserialize)]
@@ -74,6 +66,13 @@ struct TranslationContents {
translated: String,
}
const fn endpoint_for(kind: TranslationKind) -> &'static str {
match kind {
TranslationKind::Shakespeare => "shakespeare",
TranslationKind::Yoda => "yoda",
}
}
fn trim_trailing_slash(value: &str) -> String {
value.trim_end_matches('/').to_owned()
}
@@ -88,22 +87,23 @@ fn map_reqwest_error(error: &reqwest::Error) -> AppError {
#[cfg(test)]
mod tests {
use super::{TranslationClient, TranslationKind};
use super::{TranslationClient, endpoint_for};
use crate::domain::TranslationKind;
use std::time::Duration;
use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
#[test]
fn translation_kind_resolves_funtranslations_endpoint() {
assert_eq!(TranslationKind::Yoda.endpoint(), "yoda");
assert_eq!(TranslationKind::Shakespeare.endpoint(), "shakespeare");
assert_eq!(endpoint_for(TranslationKind::Yoda), "yoda");
assert_eq!(endpoint_for(TranslationKind::Shakespeare), "shakespeare");
}
#[tokio::test]
async fn posts_text_to_the_requested_translation_endpoint() {
let server = MockServer::start().await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/translate/yoda.json"))
.and(matchers::body_string_contains("text=Created"))
.and(matchers::path("/translate/yoda"))
.and(matchers::body_string_contains("Created"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"contents": { "translated": "Created, it was." }
})))
@@ -120,11 +120,31 @@ mod tests {
assert_eq!(translated, "Created, it was.");
}
#[tokio::test]
async fn rejects_empty_translations_so_callers_can_fallback() {
let server = MockServer::start().await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/translate/yoda"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"contents": { "translated": " " }
})))
.mount(&server)
.await;
let client = client_for(&server);
let error = client
.translate(TranslationKind::Yoda, "hello")
.await
.expect_err("empty translation should fail");
assert!(error.to_string().contains("empty translation"));
}
#[tokio::test]
async fn maps_translation_http_failures_to_upstream_errors() {
let server = MockServer::start().await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/translate/shakespeare.json"))
.and(matchers::path("/translate/shakespeare"))
.respond_with(ResponseTemplate::new(429))
.mount(&server)
.await;

4
src/clients/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
mod body;
pub mod funtranslations;
pub mod pokeapi;

View File

@@ -1,4 +1,4 @@
use crate::{domain::PokemonInfo, error::AppError};
use crate::{clients::body, domain::PokemonInfo, error::AppError};
use serde::Deserialize;
use std::time::Duration;
use tracing::instrument;
@@ -25,7 +25,11 @@ impl PokeApiClient {
#[instrument(skip(self), fields(pokemon.name = %name))]
pub async fn get_pokemon_info(&self, name: &str) -> Result<PokemonInfo, AppError> {
validate_pokemon_name(name)?;
let url = format!("{}/pokemon-species/{}", self.base_url, name);
let url = format!(
"{}/pokemon-species/{}",
self.base_url,
name.to_ascii_lowercase()
);
let response = self
.http
.get(url)
@@ -44,12 +48,9 @@ impl PokeApiClient {
)));
}
let species = response
.json::<PokemonSpeciesResponse>()
body::read_json::<PokemonSpeciesResponse>(response, "PokéAPI")
.await
.map_err(|error| map_reqwest_error(&error))?;
species.try_into()
.and_then(TryInto::try_into)
}
}
@@ -179,6 +180,32 @@ mod tests {
assert!(pokemon.is_legendary);
}
#[tokio::test]
async fn normalizes_pokemon_names_to_pokeapi_identifiers() {
let server = MockServer::start().await;
Mock::given(matchers::method("GET"))
.and(matchers::path("/pokemon-species/mewtwo"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"name": "mewtwo",
"is_legendary": true,
"habitat": { "name": "rare" },
"flavor_text_entries": [
{ "flavor_text": "Created by science.", "language": { "name": "en" } }
]
})))
.expect(1)
.mount(&server)
.await;
let client = client_for(&server);
let pokemon = client
.get_pokemon_info("Mewtwo")
.await
.expect("mixed-case pokemon names should resolve");
assert_eq!(pokemon.name, "mewtwo");
}
#[tokio::test]
async fn uses_unknown_habitat_when_pokeapi_has_no_habitat() {
let server = MockServer::start().await;
@@ -273,6 +300,24 @@ mod tests {
assert!(matches!(error, AppError::InvalidUpstreamData(_)));
}
#[tokio::test]
async fn rejects_oversized_upstream_bodies() {
let server = MockServer::start().await;
Mock::given(matchers::method("GET"))
.and(matchers::path("/pokemon-species/snorlax"))
.respond_with(ResponseTemplate::new(200).set_body_string("x".repeat(1025 * 1024)))
.mount(&server)
.await;
let client = client_for(&server);
let error = client
.get_pokemon_info("snorlax")
.await
.expect_err("oversized body should fail");
assert!(error.to_string().contains("exceeded"));
}
fn client_for(server: &MockServer) -> PokeApiClient {
PokeApiClient::new(server.uri(), Duration::from_secs(1)).expect("client should build")
}

View File

@@ -17,7 +17,7 @@ impl AppConfig {
Self {
bind_addr: default_bind_addr(),
pokeapi_base_url: "https://pokeapi.co/api/v2".to_owned(),
translations_base_url: "https://funtranslations.mercxry.me".to_owned(),
translations_base_url: "https://api.funtranslations.mercxry.me/v1".to_owned(),
request_timeout: Duration::from_secs(5),
rate_limit_per_second: 20.0,
rate_limit_burst: 40,
@@ -70,7 +70,7 @@ fn default_bind_addr() -> SocketAddr {
}
const fn validate_rate_limit(per_second: f64, burst: u32) -> Result<(), ConfigError> {
if per_second.is_sign_positive() && burst > 0 {
if per_second.is_finite() && per_second > 0.0 && burst > 0 {
Ok(())
} else {
Err(ConfigError::InvalidRateLimit)

View File

@@ -15,3 +15,9 @@ impl PokemonInfo {
self.is_legendary || self.habitat.eq_ignore_ascii_case("cave")
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TranslationKind {
Shakespeare,
Yoda,
}

View File

@@ -1,4 +1,8 @@
use axum::{Json, http::StatusCode, response::IntoResponse};
use axum::{
Json,
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use serde::Serialize;
#[derive(Debug, thiserror::Error)]
@@ -29,20 +33,20 @@ impl AppError {
Self::Internal => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let status = self.status_code();
let body = ErrorBody {
error: self.public_message(),
};
(status, Json(body)).into_response()
#[must_use]
pub const fn code(&self) -> &'static str {
match self {
Self::BadRequest(_) => "bad_request",
Self::NotFound => "pokemon_not_found",
Self::InvalidUpstreamData(_) => "upstream_invalid_data",
Self::Upstream(_) | Self::Timeout => "upstream_unavailable",
Self::Internal => "internal_error",
}
}
}
impl AppError {
fn public_message(&self) -> String {
#[must_use]
pub fn public_message(&self) -> String {
match self {
Self::BadRequest(message) => message.clone(),
Self::NotFound => "pokemon not found".to_owned(),
@@ -53,7 +57,64 @@ impl AppError {
}
}
#[derive(Serialize)]
struct ErrorBody {
error: String,
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
ApiError::new(self, None).into_response()
}
}
pub struct ApiError {
source: AppError,
request_id: Option<String>,
}
impl ApiError {
#[must_use]
pub const fn new(source: AppError, request_id: Option<String>) -> Self {
Self { source, request_id }
}
#[must_use]
pub fn from_headers(source: AppError, headers: &HeaderMap) -> Self {
Self::new(source, request_id_from_headers(headers))
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
let status = self.source.status_code();
let body = ErrorBody::new(
self.source.code(),
self.source.public_message(),
self.request_id,
);
(status, Json(body)).into_response()
}
}
#[derive(Serialize)]
pub struct ErrorBody {
code: &'static str,
message: String,
#[serde(rename = "requestId", skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
}
impl ErrorBody {
#[must_use]
pub fn new(code: &'static str, message: impl Into<String>, request_id: Option<String>) -> Self {
Self {
code,
message: message.into(),
request_id,
}
}
}
#[must_use]
pub fn request_id_from_headers(headers: &HeaderMap) -> Option<String> {
headers
.get("x-request-id")
.and_then(|value| value.to_str().ok())
.map(str::to_owned)
}

View File

@@ -1,9 +1,9 @@
use super::AppState;
use crate::{domain::PokemonInfo, error::AppError};
use crate::{domain::PokemonInfo, error::ApiError};
use axum::{
Json,
extract::{Path, State},
http::header,
http::{HeaderMap, header},
response::IntoResponse,
};
use serde::Serialize;
@@ -12,23 +12,41 @@ pub(super) async fn health() -> Json<HealthResponse> {
Json(HealthResponse { status: "ok" })
}
pub(super) async fn metrics(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
let body = state.telemetry.render_prometheus()?;
pub(super) async fn metrics(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, ApiError> {
let body = state
.telemetry
.render_prometheus()
.map_err(|error| ApiError::from_headers(error, &headers))?;
Ok(([(header::CONTENT_TYPE, "text/plain; version=0.0.4")], body))
}
pub(super) async fn get_pokemon(
State(state): State<AppState>,
Path(name): Path<String>,
) -> Result<Json<PokemonInfo>, AppError> {
state.service.get_basic(&name).await.map(Json)
headers: HeaderMap,
) -> Result<Json<PokemonInfo>, ApiError> {
state
.service
.get_basic(&name)
.await
.map(Json)
.map_err(|error| ApiError::from_headers(error, &headers))
}
pub(super) async fn get_translated_pokemon(
State(state): State<AppState>,
Path(name): Path<String>,
) -> Result<Json<PokemonInfo>, AppError> {
state.service.get_translated(&name).await.map(Json)
headers: HeaderMap,
) -> Result<Json<PokemonInfo>, ApiError> {
state
.service
.get_translated(&name)
.await
.map(Json)
.map_err(|error| ApiError::from_headers(error, &headers))
}
#[derive(Serialize)]

View File

@@ -1,4 +1,5 @@
use super::AppState;
use crate::error::{ErrorBody, request_id_from_headers};
use axum::{
Json,
extract::{MatchedPath, Request, State},
@@ -13,12 +14,18 @@ pub(super) async fn enforce_rate_limit(
req: Request,
next: middleware::Next,
) -> Response {
if state.rate_limiter.try_acquire() {
let path = req.uri().path();
if matches!(path, "/health" | "/metrics") || state.rate_limiter.try_acquire() {
next.run(req).await
} else {
let request_id = request_id_from_headers(req.headers());
(
StatusCode::TOO_MANY_REQUESTS,
Json(serde_json::json!({ "error": "rate limit exceeded" })),
Json(ErrorBody::new(
"rate_limit_exceeded",
"rate limit exceeded",
request_id,
)),
)
.into_response()
}

View File

@@ -1,9 +1,13 @@
mod handlers;
mod middleware;
mod rate_limit;
use crate::{
config::AppConfig, error::AppError, pokemon::PokeApiClient, rate_limit::RateLimiter,
service::PokedexService, telemetry::Telemetry, translation::TranslationClient,
application::PokedexService,
clients::{funtranslations::TranslationClient, pokeapi::PokeApiClient},
config::AppConfig,
error::AppError,
telemetry::Telemetry,
};
use axum::{Router, extract::Request, middleware as axum_middleware, routing::get};
use std::sync::Arc;
@@ -14,6 +18,8 @@ use tower_http::{
trace::{DefaultOnResponse, TraceLayer},
};
pub use rate_limit::RateLimiter;
#[derive(Clone)]
pub struct AppState {
pub(super) service: Arc<PokedexService>,
@@ -60,7 +66,7 @@ pub fn create_app(state: AppState) -> Router {
tracing::info_span!(
"http.request",
method = %request.method(),
uri = %request.uri(),
path = %request.uri().path(),
request_id = %request_id,
)
})

View File

@@ -1,5 +1,8 @@
use std::{sync::Mutex, time::Instant};
// This challenge uses a simple in-memory global limiter. In production,
// prefer API gateway rate limiting so limits are shared across instances and
// identity-aware policies can be keyed by API key, user, or client IP.
#[derive(Debug)]
pub struct RateLimiter {
bucket: Mutex<TokenBucket>,
@@ -7,7 +10,7 @@ pub struct RateLimiter {
impl RateLimiter {
pub fn new(refill_per_second: f64, burst: u32) -> Result<Self, RateLimitError> {
if !refill_per_second.is_sign_positive() || burst == 0 {
if !refill_per_second.is_finite() || refill_per_second <= 0.0 || burst == 0 {
return Err(RateLimitError::InvalidConfiguration);
}
@@ -69,6 +72,13 @@ mod tests {
use super::RateLimiter;
use std::time::Duration;
#[test]
fn rejects_non_positive_or_non_finite_configuration() {
assert!(RateLimiter::new(0.0, 1).is_err());
assert!(RateLimiter::new(f64::INFINITY, 1).is_err());
assert!(RateLimiter::new(1.0, 0).is_err());
}
#[test]
fn allows_requests_up_to_the_burst_capacity() {
let limiter = RateLimiter::new(100.0, 2).expect("rate limiter should build");
@@ -84,6 +94,8 @@ mod tests {
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());
}

View File

@@ -2,15 +2,13 @@
#![deny(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::missing_errors_doc, clippy::multiple_crate_versions)]
pub mod application;
pub mod clients;
pub mod config;
pub mod domain;
pub mod error;
pub mod http;
pub mod pokemon;
pub mod rate_limit;
pub mod service;
pub mod telemetry;
pub mod translation;
#[must_use]
pub const fn crate_name() -> &'static str {

View File

@@ -11,6 +11,27 @@ pub struct AppMetrics {
upstream_request_duration: opentelemetry::metrics::Histogram<f64>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum UpstreamRequestOutcome {
Success,
NotFound,
Error,
}
impl UpstreamRequestOutcome {
const fn label(self) -> &'static str {
match self {
Self::Success => "ok",
Self::NotFound => "not_found",
Self::Error => "error",
}
}
const fn is_error(self) -> bool {
matches!(self, Self::Error)
}
}
impl AppMetrics {
#[must_use]
pub fn new(meter: &Meter) -> Self {
@@ -68,10 +89,10 @@ impl AppMetrics {
&self,
service: &'static str,
operation: &'static str,
success: bool,
outcome: UpstreamRequestOutcome,
duration: Duration,
) {
let status = if success { "ok" } else { "error" };
let status = outcome.label();
let attributes = [
KeyValue::new("service", service),
KeyValue::new("operation", operation),
@@ -80,7 +101,7 @@ impl AppMetrics {
self.upstream_requests.add(1, &attributes);
self.upstream_request_duration
.record(duration.as_secs_f64(), &attributes);
if !success {
if outcome.is_error() {
self.upstream_errors.add(1, &attributes);
}
}

View File

@@ -6,7 +6,7 @@ use opentelemetry::metrics::MeterProvider;
use opentelemetry_prometheus_text_exporter::PrometheusExporter;
use opentelemetry_sdk::{Resource, metrics::SdkMeterProvider};
pub use metrics::AppMetrics;
pub use metrics::{AppMetrics, UpstreamRequestOutcome};
pub use tracing::init_tracing;
pub struct Telemetry {

View File

@@ -3,18 +3,22 @@ use axum::{
http::{Request, StatusCode, header::HeaderName},
};
use pokedex_api::{
http::{AppState, create_app},
pokemon::PokeApiClient,
rate_limit::RateLimiter,
service::PokedexService,
application::PokedexService,
clients::{funtranslations::TranslationClient, pokeapi::PokeApiClient},
http::{AppState, RateLimiter, create_app},
telemetry::Telemetry,
translation::TranslationClient,
};
use serde_json::Value;
use std::time::Duration;
use tower::ServiceExt;
use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
const POKEAPI_MEWTWO_SPECIES: &str = include_str!("fixtures/pokeapi_mewtwo_species.json");
const POKEAPI_PIKACHU_SPECIES: &str = include_str!("fixtures/pokeapi_pikachu_species.json");
const FUNTRANSLATIONS_YODA_MEWTWO: &str = include_str!("fixtures/funtranslations_yoda_mewtwo.json");
const FUNTRANSLATIONS_SHAKESPEARE_PIKACHU: &str =
include_str!("fixtures/funtranslations_shakespeare_pikachu.json");
#[tokio::test]
async fn health_endpoint_reports_ok() {
let pokeapi = MockServer::start().await;
@@ -37,7 +41,7 @@ async fn health_endpoint_reports_ok() {
async fn pokemon_endpoint_returns_basic_pokemon_information() {
let pokeapi = MockServer::start().await;
let translations = MockServer::start().await;
mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").await;
mount_species_fixture(&pokeapi, "mewtwo", POKEAPI_MEWTWO_SPECIES).await;
let app = create_app(state_for(&pokeapi, &translations));
let response = app
@@ -50,7 +54,7 @@ async fn pokemon_endpoint_returns_basic_pokemon_information() {
json_body(response).await,
serde_json::json!({
"name": "mewtwo",
"description": "Created by science.",
"description": "It was created by a scientist after years of horrific gene splicing and DNA engineering experiments.",
"habitat": "rare",
"isLegendary": true
})
@@ -69,22 +73,27 @@ async fn pokemon_endpoint_rejects_invalid_names_before_calling_upstream() {
.expect("request should be handled");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = json_body(response).await;
assert_eq!(body["code"], "bad_request");
assert_eq!(
json_body(response).await["error"],
body["message"],
"pokemon name must contain only ASCII letters, digits, or hyphens"
);
assert!(body["requestId"].is_string());
}
#[tokio::test]
async fn translated_endpoint_returns_translated_description() {
let pokeapi = MockServer::start().await;
let translations = MockServer::start().await;
mount_species(&pokeapi, "pikachu", false, "forest", "Electric cheeks.").await;
mount_species_fixture(&pokeapi, "pikachu", POKEAPI_PIKACHU_SPECIES).await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/translate/shakespeare.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"contents": { "translated": "Electric cheeks, prithee." }
})))
.and(matchers::path("/translate/shakespeare"))
.and(matchers::body_string_contains("Electric cheeks"))
.respond_with(
ResponseTemplate::new(200).set_body_string(FUNTRANSLATIONS_SHAKESPEARE_PIKACHU),
)
.expect(1)
.mount(&translations)
.await;
let app = create_app(state_for(&pokeapi, &translations));
@@ -96,8 +105,50 @@ async fn translated_endpoint_returns_translated_description() {
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
json_body(response).await["description"],
"Electric cheeks, prithee."
json_body(response).await,
serde_json::json!({
"name": "pikachu",
"description": "Electric cheeks, prithee.",
"habitat": "forest",
"isLegendary": false
})
);
}
#[tokio::test]
async fn translated_endpoint_uses_yoda_for_legendary_pokemon() {
let pokeapi = MockServer::start().await;
let translations = MockServer::start().await;
mount_species_fixture(&pokeapi, "mewtwo", POKEAPI_MEWTWO_SPECIES).await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/translate/yoda"))
.and(matchers::body_string_contains("horrific gene splicing"))
.respond_with(ResponseTemplate::new(200).set_body_string(FUNTRANSLATIONS_YODA_MEWTWO))
.expect(1)
.mount(&translations)
.await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/translate/shakespeare"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&translations)
.await;
let app = create_app(state_for(&pokeapi, &translations));
let response = app
.oneshot(request("/pokemon/translated/mewtwo"))
.await
.expect("request should be handled");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
json_body(response).await,
serde_json::json!({
"name": "mewtwo",
"description": "Created by science, it was.",
"habitat": "rare",
"isLegendary": true
})
);
}
@@ -107,7 +158,7 @@ async fn translated_endpoint_falls_back_to_original_description_when_translation
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.json"))
.and(matchers::path("/translate/yoda"))
.respond_with(ResponseTemplate::new(500))
.mount(&translations)
.await;
@@ -119,7 +170,47 @@ async fn translated_endpoint_falls_back_to_original_description_when_translation
.expect("request should be handled");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(json_body(response).await["description"], "It has no eyes.");
assert_eq!(
json_body(response).await,
serde_json::json!({
"name": "zubat",
"description": "It has no eyes.",
"habitat": "cave",
"isLegendary": false
})
);
}
#[tokio::test]
async fn translated_endpoint_falls_back_when_translation_is_empty() {
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(200).set_body_json(serde_json::json!({
"contents": { "translated": " " }
})))
.expect(1)
.mount(&translations)
.await;
let app = create_app(state_for(&pokeapi, &translations));
let response = app
.oneshot(request("/pokemon/translated/zubat"))
.await
.expect("request should be handled");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
json_body(response).await,
serde_json::json!({
"name": "zubat",
"description": "It has no eyes.",
"habitat": "cave",
"isLegendary": false
})
);
}
#[tokio::test]
@@ -182,6 +273,7 @@ async fn metrics_endpoint_exposes_http_and_upstream_otel_metrics() {
async fn rate_limit_rejects_requests_over_the_configured_burst() {
let pokeapi = MockServer::start().await;
let translations = MockServer::start().await;
mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").await;
let telemetry = Telemetry::new("test-service");
let app = create_app(AppState::new(
PokedexService::new(
@@ -196,17 +288,57 @@ async fn rate_limit_rejects_requests_over_the_configured_burst() {
let first = app
.clone()
.oneshot(request("/health"))
.oneshot(request("/pokemon/mewtwo"))
.await
.expect("request should be handled");
let second = app
.oneshot(request("/health"))
.oneshot(request("/pokemon/mewtwo"))
.await
.expect("request should be handled");
assert_eq!(first.status(), StatusCode::OK);
assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(json_body(second).await["error"], "rate limit exceeded");
let body = json_body(second).await;
assert_eq!(body["code"], "rate_limit_exceeded");
assert_eq!(body["message"], "rate limit exceeded");
assert!(body["requestId"].is_string());
}
#[tokio::test]
async fn health_and_metrics_are_not_rate_limited() {
let pokeapi = MockServer::start().await;
let translations = MockServer::start().await;
mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").await;
let telemetry = Telemetry::new("test-service");
let app = create_app(AppState::new(
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.metrics(),
),
telemetry,
RateLimiter::new(0.01, 1).expect("rate limiter should build"),
));
let pokemon = app
.clone()
.oneshot(request("/pokemon/mewtwo"))
.await
.expect("request should be handled");
let health = app
.clone()
.oneshot(request("/health"))
.await
.expect("request should be handled");
let metrics = app
.oneshot(request("/metrics"))
.await
.expect("request should be handled");
assert_eq!(pokemon.status(), StatusCode::OK);
assert_eq!(health.status(), StatusCode::OK);
assert_eq!(metrics.status(), StatusCode::OK);
}
#[tokio::test]
@@ -221,12 +353,101 @@ async fn pokemon_endpoint_maps_missing_pokemon_to_404() {
let app = create_app(state_for(&pokeapi, &translations));
let response = app
.oneshot(request("/pokemon/nope"))
.clone()
.oneshot(request_with_request_id("/pokemon/nope", "known-request-id"))
.await
.expect("request should be handled");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(json_body(response).await["error"], "pokemon not found");
assert_eq!(response.headers()["x-request-id"], "known-request-id");
let error = json_body(response).await;
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]
async fn pokemon_endpoint_maps_pokeapi_500_to_502() {
let pokeapi = MockServer::start().await;
let translations = MockServer::start().await;
Mock::given(matchers::method("GET"))
.and(matchers::path("/pokemon-species/mewtwo"))
.respond_with(ResponseTemplate::new(500))
.mount(&pokeapi)
.await;
let app = create_app(state_for(&pokeapi, &translations));
let response = app
.oneshot(request("/pokemon/mewtwo"))
.await
.expect("request should be handled");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = json_body(response).await;
assert_eq!(body["code"], "upstream_unavailable");
assert_eq!(body["message"], "upstream service failed");
}
#[tokio::test]
async fn pokemon_endpoint_maps_invalid_pokeapi_json_to_502() {
let pokeapi = MockServer::start().await;
let translations = MockServer::start().await;
Mock::given(matchers::method("GET"))
.and(matchers::path("/pokemon-species/mewtwo"))
.respond_with(ResponseTemplate::new(200).set_body_string("not-json"))
.mount(&pokeapi)
.await;
let app = create_app(state_for(&pokeapi, &translations));
let response = app
.oneshot(request("/pokemon/mewtwo"))
.await
.expect("request should be handled");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = json_body(response).await;
assert_eq!(body["code"], "upstream_invalid_data");
assert_eq!(body["message"], "upstream service returned invalid data");
}
#[tokio::test]
async fn translated_endpoint_does_not_call_funtranslations_when_pokeapi_fails() {
let pokeapi = MockServer::start().await;
let translations = MockServer::start().await;
Mock::given(matchers::method("GET"))
.and(matchers::path("/pokemon-species/mewtwo"))
.respond_with(ResponseTemplate::new(500))
.mount(&pokeapi)
.await;
Mock::given(matchers::method("POST"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&translations)
.await;
let app = create_app(state_for(&pokeapi, &translations));
let response = app
.oneshot(request("/pokemon/translated/mewtwo"))
.await
.expect("request should be handled");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
}
async fn mount_species_fixture(server: &MockServer, name: &str, body: &'static str) {
Mock::given(matchers::method("GET"))
.and(matchers::path(format!("/pokemon-species/{name}")))
.respond_with(ResponseTemplate::new(200).set_body_string(body))
.mount(server)
.await;
}
async fn mount_species(
@@ -271,6 +492,14 @@ fn request(path: &str) -> Request<Body> {
.expect("request should build")
}
fn request_with_request_id(path: &str, request_id: &str) -> Request<Body> {
Request::builder()
.uri(path)
.header(HeaderName::from_static("x-request-id"), request_id)
.body(Body::empty())
.expect("request should build")
}
async fn json_body(response: axum::response::Response) -> Value {
let bytes = body_bytes(response).await;
serde_json::from_slice(&bytes).expect("body should be valid JSON")

View File

@@ -0,0 +1,56 @@
use pokedex_api::{
clients::{funtranslations::TranslationClient, pokeapi::PokeApiClient},
domain::TranslationKind,
};
use std::time::Duration;
const POKEAPI_BASE_URL: &str = "https://pokeapi.co/api/v2";
const FUN_TRANSLATIONS_BASE_URL: &str = "https://api.funtranslations.mercxry.me/v1";
const CONTRACT_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::test]
#[ignore = "nightly external contract test: calls the real PokéAPI"]
async fn pokeapi_species_contract_still_matches_what_we_parse() {
let client =
PokeApiClient::new(POKEAPI_BASE_URL, CONTRACT_TIMEOUT).expect("client should build");
let pokemon = client
.get_pokemon_info("mewtwo")
.await
.expect("PokéAPI species contract should still parse");
assert_eq!(pokemon.name, "mewtwo");
assert_eq!(pokemon.habitat, "rare");
assert!(pokemon.is_legendary);
assert!(!pokemon.description.trim().is_empty());
}
#[tokio::test]
#[ignore = "nightly external contract test: calls the real FunTranslations API"]
async fn funtranslations_yoda_contract_still_matches_what_we_parse() {
let client = TranslationClient::new(FUN_TRANSLATIONS_BASE_URL, CONTRACT_TIMEOUT)
.expect("client should build");
let translated = client
.translate(TranslationKind::Yoda, "It was created by science.")
.await
.expect("FunTranslations Yoda contract should still parse");
assert!(!translated.trim().is_empty());
assert_ne!(translated, "It was created by science.");
}
#[tokio::test]
#[ignore = "nightly external contract test: calls the real FunTranslations API"]
async fn funtranslations_shakespeare_contract_still_matches_what_we_parse() {
let client = TranslationClient::new(FUN_TRANSLATIONS_BASE_URL, CONTRACT_TIMEOUT)
.expect("client should build");
let translated = client
.translate(TranslationKind::Shakespeare, "You have a great friend.")
.await
.expect("FunTranslations Shakespeare contract should still parse");
assert!(!translated.trim().is_empty());
assert_ne!(translated, "You have a great friend.");
}

View File

@@ -0,0 +1,8 @@
{
"success": { "total": 1 },
"contents": {
"translated": "Electric cheeks, prithee.",
"text": "Electric cheeks.",
"translation": "shakespeare"
}
}

View File

@@ -0,0 +1,8 @@
{
"success": { "total": 1 },
"contents": {
"translated": "Created by science, it was.",
"text": "Created by science.",
"translation": "yoda"
}
}

View File

@@ -0,0 +1,11 @@
{
"name": "mewtwo",
"is_legendary": true,
"habitat": { "name": "rare" },
"flavor_text_entries": [
{
"flavor_text": "It was created by a scientist after years of horrific gene splicing and DNA engineering experiments.",
"language": { "name": "en" }
}
]
}

View File

@@ -0,0 +1,11 @@
{
"name": "pikachu",
"is_legendary": false,
"habitat": { "name": "forest" },
"flavor_text_entries": [
{
"flavor_text": "Electric cheeks.",
"language": { "name": "en" }
}
]
}

View File

@@ -1,4 +1,4 @@
use pokedex_api::telemetry::Telemetry;
use pokedex_api::telemetry::{Telemetry, UpstreamRequestOutcome};
use std::time::Duration;
#[test]
@@ -7,7 +7,12 @@ fn records_http_and_upstream_metrics_in_prometheus_text_format() {
let metrics = telemetry.metrics();
metrics.record_http_request("GET", "/pokemon/{name}", 200, Duration::from_millis(10));
metrics.record_upstream_request("pokeapi", "pokemon_species", true, Duration::from_millis(5));
metrics.record_upstream_request(
"pokeapi",
"pokemon_species",
UpstreamRequestOutcome::Success,
Duration::from_millis(5),
);
let rendered = telemetry
.render_prometheus()
.expect("metrics should render as text");