Compare commits
14
Commits
main
..
0f195de9e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f195de9e5 | ||
|
|
da910a88c7 | ||
|
|
a6dd497e8a | ||
|
|
653accc511 | ||
|
|
78ff3473ee | ||
|
|
66496ec476 | ||
|
|
c92714d13b | ||
|
|
cde25f6dbb | ||
|
|
c64b738117 | ||
|
|
e7cf7729ee | ||
|
|
0a1c6ca7ab | ||
|
|
5acf241033 | ||
|
|
3eb57cd327 | ||
|
|
b84de88585 |
@@ -19,30 +19,17 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Select Docker platforms
|
||||
id: docker-platforms
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${ACT:-}" = "true" ] || [ "${GITEA_ACTIONS:-}" = "true" ]; then
|
||||
echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "platforms=linux/amd64,linux/arm64" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Set up QEMU
|
||||
if: ${{ steps.docker-platforms.outputs.platforms != 'linux/amd64' }}
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
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:
|
||||
context: .
|
||||
platforms: ${{ steps.docker-platforms.outputs.platforms }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: false
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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
|
||||
+3
-3
@@ -74,11 +74,11 @@ COPY --from=builder /usr/local/bin/pokedex-api /usr/local/bin/pokedex-api
|
||||
|
||||
ARG GIT_SHA=unknown
|
||||
ENV GIT_SHA=$GIT_SHA \
|
||||
BIND_ADDR=0.0.0.0:8000
|
||||
BIND_ADDR=0.0.0.0:5000
|
||||
|
||||
EXPOSE 8000
|
||||
EXPOSE 5000
|
||||
USER app
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:8000/health || exit 1
|
||||
CMD curl -fsS http://127.0.0.1:5000/health || exit 1
|
||||
|
||||
CMD ["pokedex-api"]
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
SHELL := /bin/sh
|
||||
|
||||
CARGO ?= cargo
|
||||
DOCKER ?= docker
|
||||
IMAGE ?= pokedex-api
|
||||
TAG ?= local
|
||||
BASE_URL ?= http://localhost:8000
|
||||
POKEMON ?= mewtwo
|
||||
PLATFORMS ?= linux/amd64,linux/arm64
|
||||
GIT_SHA ?= $(shell git rev-parse HEAD 2>/dev/null || printf unknown)
|
||||
|
||||
.PHONY: help run docker-build docker-run health pokemon translated metrics fmt clippy test check contract-test docker-buildx
|
||||
|
||||
help:
|
||||
@printf 'Available targets:\n'
|
||||
@printf ' make run Run the API locally with cargo\n'
|
||||
@printf ' make docker-build Build the Docker image\n'
|
||||
@printf ' make docker-run Run the Docker image on port 8000\n'
|
||||
@printf ' make 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 8000:8000 $(IMAGE)
|
||||
|
||||
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) .
|
||||
@@ -4,21 +4,15 @@ REST API for the TrueLayer Software Engineering Challenge. It returns basic Pok
|
||||
|
||||
## Requirements
|
||||
|
||||
Install these tools first:
|
||||
|
||||
- Rust, via `rustup`, for local development and tests.
|
||||
- `make`, for the documented command shortcuts.
|
||||
- Docker, for the containerized run path.
|
||||
|
||||
You can run it either with Docker or with a local Rust toolchain. Common commands are available through `make`; run `make help` for the full list.
|
||||
You can run it either with Docker or with a local Rust toolchain.
|
||||
|
||||
### Option A: Docker
|
||||
|
||||
Install Docker Desktop or an equivalent Docker runtime, then run:
|
||||
|
||||
```bash
|
||||
make docker-build
|
||||
make docker-run
|
||||
docker build -t pokedex-api .
|
||||
docker run --rm -p 5000:5000 pokedex-api
|
||||
```
|
||||
|
||||
### Option B: local Rust
|
||||
@@ -34,18 +28,18 @@ rustup default stable
|
||||
Then run the service:
|
||||
|
||||
```bash
|
||||
make run
|
||||
cargo run
|
||||
```
|
||||
|
||||
The API listens on `0.0.0.0:8000` by default.
|
||||
The API listens on `0.0.0.0:5000` by default.
|
||||
|
||||
## Endpoints
|
||||
|
||||
```bash
|
||||
make health
|
||||
make pokemon
|
||||
make translated
|
||||
make metrics
|
||||
curl http://localhost:5000/health
|
||||
curl http://localhost:5000/pokemon/mewtwo
|
||||
curl http://localhost:5000/pokemon/translated/mewtwo
|
||||
curl http://localhost:5000/metrics
|
||||
```
|
||||
|
||||
Example response:
|
||||
@@ -59,53 +53,18 @@ 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
|
||||
|
||||
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
|
||||
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 entrypoint for the API.
|
||||
|
||||
## Deliberate trade-offs
|
||||
|
||||
The application service currently depends on concrete clients instead of trait-based ports. For this challenge that keeps the code easier to read and avoids abstractions that do not buy much yet. In a larger enterprise codebase, those clients would likely become traits owned by the application layer, with HTTP clients as adapters, so providers could be swapped or mocked without depending on concrete implementations.
|
||||
|
||||
Rate limiting is intentionally simple and in-memory. In production, this should usually live in an API gateway or shared rate-limiting service so limits are consistent across instances and can be keyed by API key, user, or client IP. PokéAPI species data also changes rarely, so production deployments would add a bounded LRU cache with TTL for successful species responses to avoid unnecessary random upstream hits, reduce latency, and make transient provider failures less visible to users.
|
||||
|
||||
External contract tests are separated from the normal deterministic test suite. Unit and integration tests use mocks; the nightly workflow calls real providers to detect contract drift early.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --------------------------- | ------------------------------------------- | -------------------------- |
|
||||
| `BIND_ADDR` | `0.0.0.0:8000` | 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 |
|
||||
| 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 |
|
||||
|
||||
## Instrumentation
|
||||
|
||||
@@ -121,27 +80,24 @@ The service includes:
|
||||
## Development checks
|
||||
|
||||
```bash
|
||||
make check
|
||||
cargo fmt --all --check
|
||||
cargo clippy --locked --all-targets -- -D warnings
|
||||
cargo test --locked
|
||||
```
|
||||
|
||||
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
|
||||
make docker-buildx
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--build-arg GIT_SHA="$(git rev-parse HEAD)" \
|
||||
-t pokedex-api:local .
|
||||
```
|
||||
|
||||
The Dockerfile uses `cargo-chef` and BuildKit cache mounts for dependency and target directory caching.
|
||||
|
||||
## Production notes
|
||||
|
||||
For a production API I would add retries with bounded exponential backoff, circuit breakers for upstream failures, stronger health checks that distinguish readiness from liveness, dashboards and alerts from the OTEL metrics, and a dedicated OTEL collector pipeline. I would keep `/metrics` private, add dependency/container scanning, define SLOs, and add contract tests against recorded upstream fixtures.
|
||||
For a production API I would add per-client or per-token rate limiting instead of one global bucket, retries with bounded exponential backoff, circuit breakers for upstream failures, 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.
|
||||
|
||||
-170
@@ -1,170 +0,0 @@
|
||||
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:8000
|
||||
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.
|
||||
@@ -1,3 +0,0 @@
|
||||
mod pokedex;
|
||||
|
||||
pub use pokedex::{PokedexService, translation_kind_for};
|
||||
@@ -1,115 +0,0 @@
|
||||
use crate::{
|
||||
clients::{funtranslations::TranslationClient, pokeapi::PokeApiClient},
|
||||
domain::{PokemonInfo, TranslationKind},
|
||||
error::AppError,
|
||||
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,
|
||||
translations: TranslationClient,
|
||||
metrics: AppMetrics,
|
||||
}
|
||||
|
||||
impl PokedexService {
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
pokemon: PokeApiClient,
|
||||
translations: TranslationClient,
|
||||
metrics: AppMetrics,
|
||||
) -> Self {
|
||||
Self {
|
||||
pokemon,
|
||||
translations,
|
||||
metrics,
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self), fields(pokemon.name = %name))]
|
||||
pub async fn get_basic(&self, name: &str) -> Result<PokemonInfo, AppError> {
|
||||
self.fetch_pokemon(name).await
|
||||
}
|
||||
|
||||
#[instrument(skip(self), fields(pokemon.name = %name))]
|
||||
pub async fn get_translated(&self, name: &str) -> Result<PokemonInfo, AppError> {
|
||||
let mut pokemon = self.fetch_pokemon(name).await?;
|
||||
let translation_kind = translation_kind_for(&pokemon);
|
||||
|
||||
let translation_start = Instant::now();
|
||||
let translation = self
|
||||
.translations
|
||||
.translate(translation_kind, &pokemon.description)
|
||||
.await;
|
||||
self.metrics.record_upstream_request(
|
||||
"funtranslations",
|
||||
translation_operation(translation_kind),
|
||||
if translation.is_ok() {
|
||||
UpstreamRequestOutcome::Success
|
||||
} else {
|
||||
UpstreamRequestOutcome::Error
|
||||
},
|
||||
translation_start.elapsed(),
|
||||
);
|
||||
|
||||
match translation {
|
||||
Ok(translated) => pokemon.description = translated,
|
||||
Err(error) => debug!(%error, "using original description after translation failure"),
|
||||
}
|
||||
|
||||
Ok(pokemon)
|
||||
}
|
||||
|
||||
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",
|
||||
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() {
|
||||
TranslationKind::Yoda
|
||||
} else {
|
||||
TranslationKind::Shakespeare
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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 +0,0 @@
|
||||
mod body;
|
||||
|
||||
pub mod funtranslations;
|
||||
pub mod pokeapi;
|
||||
+4
-4
@@ -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://api.funtranslations.mercxry.me/v1".to_owned(),
|
||||
translations_base_url: "https://funtranslations.mercxry.me".to_owned(),
|
||||
request_timeout: Duration::from_secs(5),
|
||||
rate_limit_per_second: 20.0,
|
||||
rate_limit_burst: 40,
|
||||
@@ -66,11 +66,11 @@ fn env_or_string(name: &'static str, default: &str) -> String {
|
||||
}
|
||||
|
||||
fn default_bind_addr() -> SocketAddr {
|
||||
SocketAddr::from(([0, 0, 0, 0], 8000))
|
||||
SocketAddr::from(([0, 0, 0, 0], 5000))
|
||||
}
|
||||
|
||||
const fn validate_rate_limit(per_second: f64, burst: u32) -> Result<(), ConfigError> {
|
||||
if per_second.is_finite() && per_second > 0.0 && burst > 0 {
|
||||
if per_second.is_sign_positive() && burst > 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConfigError::InvalidRateLimit)
|
||||
@@ -101,7 +101,7 @@ mod tests {
|
||||
fn default_config_is_valid_for_local_development() {
|
||||
let config = AppConfig::default_local();
|
||||
|
||||
assert_eq!(config.bind_addr.port(), 8000);
|
||||
assert_eq!(config.bind_addr.port(), 5000);
|
||||
assert_eq!(config.pokeapi_base_url, "https://pokeapi.co/api/v2");
|
||||
assert!((config.rate_limit_per_second - 20.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
@@ -15,9 +15,3 @@ impl PokemonInfo {
|
||||
self.is_legendary || self.habitat.eq_ignore_ascii_case("cave")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum TranslationKind {
|
||||
Shakespeare,
|
||||
Yoda,
|
||||
}
|
||||
+14
-75
@@ -1,8 +1,4 @@
|
||||
use axum::{
|
||||
Json,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum::{Json, http::StatusCode, response::IntoResponse};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -33,20 +29,20 @@ impl AppError {
|
||||
Self::Internal => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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 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 fn public_message(&self) -> String {
|
||||
impl AppError {
|
||||
fn public_message(&self) -> String {
|
||||
match self {
|
||||
Self::BadRequest(message) => message.clone(),
|
||||
Self::NotFound => "pokemon not found".to_owned(),
|
||||
@@ -57,64 +53,7 @@ impl AppError {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
struct ErrorBody {
|
||||
error: String,
|
||||
}
|
||||
|
||||
+8
-26
@@ -1,9 +1,9 @@
|
||||
use super::AppState;
|
||||
use crate::{domain::PokemonInfo, error::ApiError};
|
||||
use crate::{domain::PokemonInfo, error::AppError};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, header},
|
||||
http::header,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::Serialize;
|
||||
@@ -12,41 +12,23 @@ pub(super) async fn health() -> Json<HealthResponse> {
|
||||
Json(HealthResponse { status: "ok" })
|
||||
}
|
||||
|
||||
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))?;
|
||||
pub(super) async fn metrics(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
|
||||
let body = state.telemetry.render_prometheus()?;
|
||||
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>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<PokemonInfo>, ApiError> {
|
||||
state
|
||||
.service
|
||||
.get_basic(&name)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|error| ApiError::from_headers(error, &headers))
|
||||
) -> Result<Json<PokemonInfo>, AppError> {
|
||||
state.service.get_basic(&name).await.map(Json)
|
||||
}
|
||||
|
||||
pub(super) async fn get_translated_pokemon(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<PokemonInfo>, ApiError> {
|
||||
state
|
||||
.service
|
||||
.get_translated(&name)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|error| ApiError::from_headers(error, &headers))
|
||||
) -> Result<Json<PokemonInfo>, AppError> {
|
||||
state.service.get_translated(&name).await.map(Json)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use super::AppState;
|
||||
use crate::error::{ErrorBody, request_id_from_headers};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{MatchedPath, Request, State},
|
||||
@@ -14,18 +13,12 @@ pub(super) async fn enforce_rate_limit(
|
||||
req: Request,
|
||||
next: middleware::Next,
|
||||
) -> Response {
|
||||
let path = req.uri().path();
|
||||
if matches!(path, "/health" | "/metrics") || state.rate_limiter.try_acquire() {
|
||||
if state.rate_limiter.try_acquire() {
|
||||
next.run(req).await
|
||||
} else {
|
||||
let request_id = request_id_from_headers(req.headers());
|
||||
(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(ErrorBody::new(
|
||||
"rate_limit_exceeded",
|
||||
"rate limit exceeded",
|
||||
request_id,
|
||||
)),
|
||||
Json(serde_json::json!({ "error": "rate limit exceeded" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
+3
-9
@@ -1,13 +1,9 @@
|
||||
mod handlers;
|
||||
mod middleware;
|
||||
mod rate_limit;
|
||||
|
||||
use crate::{
|
||||
application::PokedexService,
|
||||
clients::{funtranslations::TranslationClient, pokeapi::PokeApiClient},
|
||||
config::AppConfig,
|
||||
error::AppError,
|
||||
telemetry::Telemetry,
|
||||
config::AppConfig, error::AppError, pokemon::PokeApiClient, rate_limit::RateLimiter,
|
||||
service::PokedexService, telemetry::Telemetry, translation::TranslationClient,
|
||||
};
|
||||
use axum::{Router, extract::Request, middleware as axum_middleware, routing::get};
|
||||
use std::sync::Arc;
|
||||
@@ -18,8 +14,6 @@ use tower_http::{
|
||||
trace::{DefaultOnResponse, TraceLayer},
|
||||
};
|
||||
|
||||
pub use rate_limit::RateLimiter;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub(super) service: Arc<PokedexService>,
|
||||
@@ -66,7 +60,7 @@ pub fn create_app(state: AppState) -> Router {
|
||||
tracing::info_span!(
|
||||
"http.request",
|
||||
method = %request.method(),
|
||||
path = %request.uri().path(),
|
||||
uri = %request.uri(),
|
||||
request_id = %request_id,
|
||||
)
|
||||
})
|
||||
|
||||
+4
-2
@@ -2,13 +2,15 @@
|
||||
#![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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{clients::body, domain::PokemonInfo, error::AppError};
|
||||
use crate::{domain::PokemonInfo, error::AppError};
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
use tracing::instrument;
|
||||
@@ -25,11 +25,7 @@ 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.to_ascii_lowercase()
|
||||
);
|
||||
let url = format!("{}/pokemon-species/{}", self.base_url, name);
|
||||
let response = self
|
||||
.http
|
||||
.get(url)
|
||||
@@ -48,9 +44,12 @@ impl PokeApiClient {
|
||||
)));
|
||||
}
|
||||
|
||||
body::read_json::<PokemonSpeciesResponse>(response, "PokéAPI")
|
||||
let species = response
|
||||
.json::<PokemonSpeciesResponse>()
|
||||
.await
|
||||
.and_then(TryInto::try_into)
|
||||
.map_err(|error| map_reqwest_error(&error))?;
|
||||
|
||||
species.try_into()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,32 +179,6 @@ 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;
|
||||
@@ -300,24 +273,6 @@ 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")
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
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>,
|
||||
@@ -10,7 +7,7 @@ pub struct RateLimiter {
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new(refill_per_second: f64, burst: u32) -> Result<Self, RateLimitError> {
|
||||
if !refill_per_second.is_finite() || refill_per_second <= 0.0 || burst == 0 {
|
||||
if !refill_per_second.is_sign_positive() || burst == 0 {
|
||||
return Err(RateLimitError::InvalidConfiguration);
|
||||
}
|
||||
|
||||
@@ -70,13 +67,7 @@ impl TokenBucket {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RateLimiter;
|
||||
|
||||
#[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());
|
||||
}
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn allows_requests_up_to_the_burst_capacity() {
|
||||
@@ -86,4 +77,14 @@ mod tests {
|
||||
assert!(limiter.try_acquire());
|
||||
assert!(!limiter.try_acquire());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refills_tokens_over_time() {
|
||||
let limiter = RateLimiter::new(100.0, 1).expect("rate limiter should build");
|
||||
|
||||
assert!(limiter.try_acquire());
|
||||
assert!(!limiter.try_acquire());
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
assert!(limiter.try_acquire());
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
use crate::{
|
||||
domain::PokemonInfo,
|
||||
error::AppError,
|
||||
pokemon::PokeApiClient,
|
||||
telemetry::AppMetrics,
|
||||
translation::{TranslationClient, TranslationKind},
|
||||
};
|
||||
use std::time::Instant;
|
||||
use tracing::{debug, instrument};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PokedexService {
|
||||
pokemon: PokeApiClient,
|
||||
translations: TranslationClient,
|
||||
metrics: AppMetrics,
|
||||
}
|
||||
|
||||
impl PokedexService {
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
pokemon: PokeApiClient,
|
||||
translations: TranslationClient,
|
||||
metrics: AppMetrics,
|
||||
) -> Self {
|
||||
Self {
|
||||
pokemon,
|
||||
translations,
|
||||
metrics,
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self), fields(pokemon.name = %name))]
|
||||
pub async fn get_basic(&self, name: &str) -> Result<PokemonInfo, AppError> {
|
||||
self.fetch_pokemon(name).await
|
||||
}
|
||||
|
||||
#[instrument(skip(self), fields(pokemon.name = %name))]
|
||||
pub async fn get_translated(&self, name: &str) -> Result<PokemonInfo, AppError> {
|
||||
let mut pokemon = self.fetch_pokemon(name).await?;
|
||||
let translation_kind = translation_kind_for(&pokemon);
|
||||
|
||||
let translation_start = Instant::now();
|
||||
let translation = self
|
||||
.translations
|
||||
.translate(translation_kind, &pokemon.description)
|
||||
.await;
|
||||
self.metrics.record_upstream_request(
|
||||
"funtranslations",
|
||||
translation_kind.endpoint(),
|
||||
translation.is_ok(),
|
||||
translation_start.elapsed(),
|
||||
);
|
||||
|
||||
match translation {
|
||||
Ok(translated) => pokemon.description = translated,
|
||||
Err(error) => debug!(%error, "using original description after translation failure"),
|
||||
}
|
||||
|
||||
Ok(pokemon)
|
||||
}
|
||||
|
||||
async fn fetch_pokemon(&self, name: &str) -> Result<PokemonInfo, AppError> {
|
||||
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(),
|
||||
);
|
||||
pokemon
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn translation_kind_for(pokemon: &PokemonInfo) -> TranslationKind {
|
||||
if pokemon.needs_yoda_translation() {
|
||||
TranslationKind::Yoda
|
||||
} else {
|
||||
TranslationKind::Shakespeare
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{PokedexService, translation_kind_for};
|
||||
use crate::{
|
||||
domain::PokemonInfo, pokemon::PokeApiClient, telemetry::Telemetry,
|
||||
translation::TranslationClient, translation::TranslationKind,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
|
||||
|
||||
#[test]
|
||||
fn chooses_yoda_for_legendary_pokemon() {
|
||||
let pokemon = pokemon_info("rare", true, "Created by science.");
|
||||
|
||||
assert_eq!(translation_kind_for(&pokemon), TranslationKind::Yoda);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chooses_yoda_for_cave_pokemon() {
|
||||
let pokemon = pokemon_info("cave", false, "Sleeps upside down.");
|
||||
|
||||
assert_eq!(translation_kind_for(&pokemon), TranslationKind::Yoda);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chooses_shakespeare_for_regular_pokemon() {
|
||||
let pokemon = pokemon_info("forest", false, "Electric cheeks.");
|
||||
|
||||
assert_eq!(translation_kind_for(&pokemon), TranslationKind::Shakespeare);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn translates_legendary_pokemon_with_yoda() {
|
||||
let pokeapi = MockServer::start().await;
|
||||
let translations = MockServer::start().await;
|
||||
mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").await;
|
||||
Mock::given(matchers::method("POST"))
|
||||
.and(matchers::path("/translate/yoda.json"))
|
||||
.and(matchers::body_string_contains("Created+by+science"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"contents": { "translated": "Created by science, it was." }
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&translations)
|
||||
.await;
|
||||
let service = service_for(&pokeapi, &translations);
|
||||
|
||||
let pokemon = service
|
||||
.get_translated("mewtwo")
|
||||
.await
|
||||
.expect("translated pokemon should be returned");
|
||||
|
||||
assert_eq!(pokemon.description, "Created by science, it was.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn translates_regular_pokemon_with_shakespeare() {
|
||||
let pokeapi = MockServer::start().await;
|
||||
let translations = MockServer::start().await;
|
||||
mount_species(&pokeapi, "pikachu", false, "forest", "Electric cheeks.").await;
|
||||
Mock::given(matchers::method("POST"))
|
||||
.and(matchers::path("/translate/shakespeare.json"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"contents": { "translated": "Electric cheeks, good sir." }
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&translations)
|
||||
.await;
|
||||
let service = service_for(&pokeapi, &translations);
|
||||
|
||||
let pokemon = service
|
||||
.get_translated("pikachu")
|
||||
.await
|
||||
.expect("translated pokemon should be returned");
|
||||
|
||||
assert_eq!(pokemon.description, "Electric cheeks, good sir.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn falls_back_to_original_description_when_translation_fails() {
|
||||
let pokeapi = MockServer::start().await;
|
||||
let translations = MockServer::start().await;
|
||||
mount_species(&pokeapi, "zubat", false, "cave", "It has no eyes.").await;
|
||||
Mock::given(matchers::method("POST"))
|
||||
.and(matchers::path("/translate/yoda.json"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.expect(1)
|
||||
.mount(&translations)
|
||||
.await;
|
||||
let service = service_for(&pokeapi, &translations);
|
||||
|
||||
let pokemon = service
|
||||
.get_translated("zubat")
|
||||
.await
|
||||
.expect("translation failure should not fail the endpoint");
|
||||
|
||||
assert_eq!(pokemon.description, "It has no eyes.");
|
||||
}
|
||||
|
||||
async fn mount_species(
|
||||
server: &MockServer,
|
||||
name: &str,
|
||||
is_legendary: bool,
|
||||
habitat: &str,
|
||||
description: &str,
|
||||
) {
|
||||
Mock::given(matchers::method("GET"))
|
||||
.and(matchers::path(format!("/pokemon-species/{name}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"name": name,
|
||||
"is_legendary": is_legendary,
|
||||
"habitat": { "name": habitat },
|
||||
"flavor_text_entries": [
|
||||
{ "flavor_text": description, "language": { "name": "en" } }
|
||||
]
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn pokemon_info(habitat: &str, is_legendary: bool, description: &str) -> PokemonInfo {
|
||||
PokemonInfo {
|
||||
name: "testmon".to_owned(),
|
||||
description: description.to_owned(),
|
||||
habitat: habitat.to_owned(),
|
||||
is_legendary,
|
||||
}
|
||||
}
|
||||
|
||||
fn service_for(pokeapi: &MockServer, translations: &MockServer) -> PokedexService {
|
||||
PokedexService::new(
|
||||
PokeApiClient::new(pokeapi.uri(), Duration::from_secs(1)).expect("client should build"),
|
||||
TranslationClient::new(translations.uri(), Duration::from_secs(1))
|
||||
.expect("client should build"),
|
||||
Telemetry::new("test-service").metrics(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,27 +11,6 @@ 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 {
|
||||
@@ -89,10 +68,10 @@ impl AppMetrics {
|
||||
&self,
|
||||
service: &'static str,
|
||||
operation: &'static str,
|
||||
outcome: UpstreamRequestOutcome,
|
||||
success: bool,
|
||||
duration: Duration,
|
||||
) {
|
||||
let status = outcome.label();
|
||||
let status = if success { "ok" } else { "error" };
|
||||
let attributes = [
|
||||
KeyValue::new("service", service),
|
||||
KeyValue::new("operation", operation),
|
||||
@@ -101,7 +80,7 @@ impl AppMetrics {
|
||||
self.upstream_requests.add(1, &attributes);
|
||||
self.upstream_request_duration
|
||||
.record(duration.as_secs_f64(), &attributes);
|
||||
if outcome.is_error() {
|
||||
if !success {
|
||||
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, UpstreamRequestOutcome};
|
||||
pub use metrics::AppMetrics;
|
||||
pub use tracing::init_tracing;
|
||||
|
||||
pub struct Telemetry {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{clients::body, domain::TranslationKind, error::AppError};
|
||||
use crate::error::AppError;
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
use tracing::instrument;
|
||||
@@ -9,6 +9,22 @@ 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()
|
||||
@@ -24,11 +40,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/{}", self.base_url, endpoint_for(kind));
|
||||
let url = format!("{}/translate/{}.json", self.base_url, kind.endpoint());
|
||||
let response = self
|
||||
.http
|
||||
.post(url)
|
||||
.json(&TranslationRequest { text })
|
||||
.form(&[("text", text)])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_reqwest_error(&error))?;
|
||||
@@ -40,22 +56,14 @@ impl TranslationClient {
|
||||
)));
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
response
|
||||
.json::<TranslationResponse>()
|
||||
.await
|
||||
.map(|payload| payload.contents.translated)
|
||||
.map_err(|error| map_reqwest_error(&error))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct TranslationRequest<'a> {
|
||||
text: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TranslationResponse {
|
||||
contents: TranslationContents,
|
||||
@@ -66,13 +74,6 @@ 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()
|
||||
}
|
||||
@@ -87,23 +88,22 @@ fn map_reqwest_error(error: &reqwest::Error) -> AppError {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{TranslationClient, endpoint_for};
|
||||
use crate::domain::TranslationKind;
|
||||
use super::{TranslationClient, TranslationKind};
|
||||
use std::time::Duration;
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
|
||||
|
||||
#[test]
|
||||
fn translation_kind_resolves_funtranslations_endpoint() {
|
||||
assert_eq!(endpoint_for(TranslationKind::Yoda), "yoda");
|
||||
assert_eq!(endpoint_for(TranslationKind::Shakespeare), "shakespeare");
|
||||
assert_eq!(TranslationKind::Yoda.endpoint(), "yoda");
|
||||
assert_eq!(TranslationKind::Shakespeare.endpoint(), "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"))
|
||||
.and(matchers::body_string_contains("Created"))
|
||||
.and(matchers::path("/translate/yoda.json"))
|
||||
.and(matchers::body_string_contains("text=Created"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"contents": { "translated": "Created, it was." }
|
||||
})))
|
||||
@@ -120,31 +120,11 @@ 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"))
|
||||
.and(matchers::path("/translate/shakespeare.json"))
|
||||
.respond_with(ResponseTemplate::new(429))
|
||||
.mount(&server)
|
||||
.await;
|
||||
+49
-243
@@ -3,22 +3,18 @@ use axum::{
|
||||
http::{Request, StatusCode, header::HeaderName},
|
||||
};
|
||||
use pokedex_api::{
|
||||
application::PokedexService,
|
||||
clients::{funtranslations::TranslationClient, pokeapi::PokeApiClient},
|
||||
http::{AppState, RateLimiter, create_app},
|
||||
http::{AppState, create_app},
|
||||
pokemon::PokeApiClient,
|
||||
rate_limit::RateLimiter,
|
||||
service::PokedexService,
|
||||
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;
|
||||
@@ -41,7 +37,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_fixture(&pokeapi, "mewtwo", POKEAPI_MEWTWO_SPECIES).await;
|
||||
mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").await;
|
||||
let app = create_app(state_for(&pokeapi, &translations));
|
||||
|
||||
let response = app
|
||||
@@ -54,7 +50,7 @@ async fn pokemon_endpoint_returns_basic_pokemon_information() {
|
||||
json_body(response).await,
|
||||
serde_json::json!({
|
||||
"name": "mewtwo",
|
||||
"description": "It was created by a scientist after years of horrific gene splicing and DNA engineering experiments.",
|
||||
"description": "Created by science.",
|
||||
"habitat": "rare",
|
||||
"isLegendary": true
|
||||
})
|
||||
@@ -73,27 +69,22 @@ 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!(
|
||||
body["message"],
|
||||
json_body(response).await["error"],
|
||||
"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_fixture(&pokeapi, "pikachu", POKEAPI_PIKACHU_SPECIES).await;
|
||||
mount_species(&pokeapi, "pikachu", false, "forest", "Electric cheeks.").await;
|
||||
Mock::given(matchers::method("POST"))
|
||||
.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)
|
||||
.and(matchers::path("/translate/shakespeare.json"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"contents": { "translated": "Electric cheeks, prithee." }
|
||||
})))
|
||||
.mount(&translations)
|
||||
.await;
|
||||
let app = create_app(state_for(&pokeapi, &translations));
|
||||
@@ -105,50 +96,8 @@ async fn translated_endpoint_returns_translated_description() {
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
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
|
||||
})
|
||||
json_body(response).await["description"],
|
||||
"Electric cheeks, prithee."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,7 +107,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"))
|
||||
.and(matchers::path("/translate/yoda.json"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.mount(&translations)
|
||||
.await;
|
||||
@@ -170,47 +119,7 @@ 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,
|
||||
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
|
||||
})
|
||||
);
|
||||
assert_eq!(json_body(response).await["description"], "It has no eyes.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -247,10 +156,32 @@ async fn keeps_incoming_request_id_when_present() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_rejects_requests_over_the_configured_burst() {
|
||||
async fn metrics_endpoint_exposes_http_and_upstream_otel_metrics() {
|
||||
let pokeapi = MockServer::start().await;
|
||||
let translations = MockServer::start().await;
|
||||
mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").await;
|
||||
let app = create_app(state_for(&pokeapi, &translations));
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request("/pokemon/mewtwo"))
|
||||
.await
|
||||
.expect("request should be handled");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let response = app
|
||||
.oneshot(request("/metrics"))
|
||||
.await
|
||||
.expect("request should be handled");
|
||||
let body = text_body(response).await;
|
||||
|
||||
assert!(body.contains("http_server_requests"));
|
||||
assert!(body.contains("upstream_client_requests"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_rejects_requests_over_the_configured_burst() {
|
||||
let pokeapi = MockServer::start().await;
|
||||
let translations = MockServer::start().await;
|
||||
let telemetry = Telemetry::new("test-service");
|
||||
let app = create_app(AppState::new(
|
||||
PokedexService::new(
|
||||
@@ -265,57 +196,17 @@ async fn rate_limit_rejects_requests_over_the_configured_burst() {
|
||||
|
||||
let first = app
|
||||
.clone()
|
||||
.oneshot(request("/pokemon/mewtwo"))
|
||||
.oneshot(request("/health"))
|
||||
.await
|
||||
.expect("request should be handled");
|
||||
let second = app
|
||||
.oneshot(request("/pokemon/mewtwo"))
|
||||
.oneshot(request("/health"))
|
||||
.await
|
||||
.expect("request should be handled");
|
||||
|
||||
assert_eq!(first.status(), StatusCode::OK);
|
||||
assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
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);
|
||||
assert_eq!(json_body(second).await["error"], "rate limit exceeded");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -330,93 +221,12 @@ async fn pokemon_endpoint_maps_missing_pokemon_to_404() {
|
||||
let app = create_app(state_for(&pokeapi, &translations));
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request_with_request_id("/pokemon/nope", "known-request-id"))
|
||||
.oneshot(request("/pokemon/nope"))
|
||||
.await
|
||||
.expect("request should be handled");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::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");
|
||||
}
|
||||
|
||||
#[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;
|
||||
assert_eq!(json_body(response).await["error"], "pokemon not found");
|
||||
}
|
||||
|
||||
async fn mount_species(
|
||||
@@ -461,19 +271,15 @@ 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")
|
||||
}
|
||||
|
||||
async fn text_body(response: axum::response::Response) -> String {
|
||||
String::from_utf8(body_bytes(response).await.to_vec()).expect("body should be valid UTF-8")
|
||||
}
|
||||
|
||||
async fn body_bytes(response: axum::response::Response) -> axum::body::Bytes {
|
||||
to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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.");
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"success": { "total": 1 },
|
||||
"contents": {
|
||||
"translated": "Electric cheeks, prithee.",
|
||||
"text": "Electric cheeks.",
|
||||
"translation": "shakespeare"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"success": { "total": 1 },
|
||||
"contents": {
|
||||
"translated": "Created by science, it was.",
|
||||
"text": "Created by science.",
|
||||
"translation": "yoda"
|
||||
}
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"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
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"name": "pikachu",
|
||||
"is_legendary": false,
|
||||
"habitat": { "name": "forest" },
|
||||
"flavor_text_entries": [
|
||||
{
|
||||
"flavor_text": "Electric cheeks.",
|
||||
"language": { "name": "en" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use pokedex_api::telemetry::Telemetry;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn records_http_and_upstream_metrics_in_prometheus_text_format() {
|
||||
let telemetry = Telemetry::new("test-service");
|
||||
let metrics = telemetry.metrics();
|
||||
|
||||
metrics.record_http_request("GET", "/pokemon/{name}", 200, Duration::from_millis(10));
|
||||
metrics.record_upstream_request("pokeapi", "pokemon_species", true, Duration::from_millis(5));
|
||||
let rendered = telemetry
|
||||
.render_prometheus()
|
||||
.expect("metrics should render as text");
|
||||
|
||||
assert!(rendered.contains("http_server_requests"));
|
||||
assert!(rendered.contains("upstream_client_requests"));
|
||||
assert!(rendered.contains("service_name=\"test-service\""));
|
||||
}
|
||||
Reference in New Issue
Block a user