Compare commits
10 Commits
0f195de9e5
...
fe6244f51c
| Author | SHA1 | Date | |
|---|---|---|---|
| fe6244f51c | |||
| 0ffb1e8b5f | |||
| 23eaea36c1 | |||
| cf11e20f90 | |||
| b910e77460 | |||
| c35f8cce14 | |||
| d6fc3bdd4b | |||
| ce959b59bf | |||
| aa3b678cc5 | |||
| 6fc5cc1bc8 |
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -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:
|
||||
|
||||
30
.github/workflows/external-contracts.yml
vendored
Normal file
30
.github/workflows/external-contracts.yml
vendored
Normal 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
75
Makefile
Normal 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) .
|
||||
86
README.md
86
README.md
@@ -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,18 +66,36 @@ 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 |
|
||||
| `REQUEST_TIMEOUT_SECONDS` | `5` | Upstream HTTP timeout |
|
||||
| `RATE_LIMIT_PER_SECOND` | `20` | Global token refill rate |
|
||||
| `RATE_LIMIT_BURST` | `40` | Global burst capacity |
|
||||
| `OTEL_SERVICE_NAME` | `pokedex-api` | OpenTelemetry service name |
|
||||
| `RUST_LOG` | `pokedex_api=info,tower_http=info` | JSON tracing log filter |
|
||||
| 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://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 |
|
||||
| `OTEL_SERVICE_NAME` | `pokedex-api` | OpenTelemetry service name |
|
||||
| `RUST_LOG` | `pokedex_api=info,tower_http=info` | JSON tracing log filter |
|
||||
|
||||
## Instrumentation
|
||||
|
||||
@@ -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.
|
||||
|
||||
BIN
TrueLayer-_Software_Engineering_Challenge_2026.pdf
Normal file
BIN
TrueLayer-_Software_Engineering_Challenge_2026.pdf
Normal file
Binary file not shown.
33
docs/architecture.md
Normal file
33
docs/architecture.md
Normal 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
170
openapi.yaml
Normal 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
3
src/application/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod pokedex;
|
||||
|
||||
pub use pokedex::{PokedexService, translation_kind_for};
|
||||
@@ -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;
|
||||
self.metrics.record_upstream_request(
|
||||
"pokeapi",
|
||||
"pokemon_species",
|
||||
pokemon.is_ok(),
|
||||
start.elapsed(),
|
||||
);
|
||||
if !matches!(pokemon, Err(AppError::BadRequest(_))) {
|
||||
self.metrics.record_upstream_request(
|
||||
"pokeapi",
|
||||
"pokemon_species",
|
||||
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
275
src/bin/pokedex-cli.rs
Normal 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
51
src/clients/body.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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,14 +40,22 @@ 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)]
|
||||
struct TranslationResponse {
|
||||
contents: TranslationContents,
|
||||
@@ -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
4
src/clients/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
mod body;
|
||||
|
||||
pub mod funtranslations;
|
||||
pub mod pokeapi;
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
91
src/error.rs
91
src/error.rs
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
273
tests/app.rs
273
tests/app.rs
@@ -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")
|
||||
|
||||
56
tests/external_contract.rs
Normal file
56
tests/external_contract.rs
Normal 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.");
|
||||
}
|
||||
8
tests/fixtures/funtranslations_shakespeare_pikachu.json
vendored
Normal file
8
tests/fixtures/funtranslations_shakespeare_pikachu.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"success": { "total": 1 },
|
||||
"contents": {
|
||||
"translated": "Electric cheeks, prithee.",
|
||||
"text": "Electric cheeks.",
|
||||
"translation": "shakespeare"
|
||||
}
|
||||
}
|
||||
8
tests/fixtures/funtranslations_yoda_mewtwo.json
vendored
Normal file
8
tests/fixtures/funtranslations_yoda_mewtwo.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"success": { "total": 1 },
|
||||
"contents": {
|
||||
"translated": "Created by science, it was.",
|
||||
"text": "Created by science.",
|
||||
"translation": "yoda"
|
||||
}
|
||||
}
|
||||
11
tests/fixtures/pokeapi_mewtwo_species.json
vendored
Normal file
11
tests/fixtures/pokeapi_mewtwo_species.json
vendored
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
11
tests/fixtures/pokeapi_pikachu_species.json
vendored
Normal file
11
tests/fixtures/pokeapi_pikachu_species.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "pikachu",
|
||||
"is_legendary": false,
|
||||
"habitat": { "name": "forest" },
|
||||
"flavor_text_entries": [
|
||||
{
|
||||
"flavor_text": "Electric cheeks.",
|
||||
"language": { "name": "en" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user