Compare commits

..
14 Commits
Author SHA1 Message Date
hiimjako 0f195de9e5 refactor: split telemetry module 2026-06-15 12:26:26 +02:00
hiimjako da910a88c7 refactor: split http layer 2026-06-15 12:26:26 +02:00
hiimjako a6dd497e8a test: move app tests to integration suite 2026-06-15 12:26:26 +02:00
hiimjako 653accc511 refactor: move entrypoint to bin 2026-06-15 12:26:26 +02:00
hiimjako 78ff3473ee fix: address review findings 2026-06-15 12:26:26 +02:00
hiimjako 66496ec476 docs: add usage guide 2026-06-15 12:26:26 +02:00
hiimjako c92714d13b ci: build docker image 2026-06-15 12:26:26 +02:00
hiimjako cde25f6dbb chore: add multi-arch docker build 2026-06-15 12:26:26 +02:00
hiimjako c64b738117 feat: add request rate limiting 2026-06-13 06:14:47 +02:00
hiimjako e7cf7729ee feat: add telemetry instrumentation 2026-06-13 06:12:51 +02:00
hiimjako 0a1c6ca7ab feat: expose pokemon api 2026-06-13 06:09:15 +02:00
hiimjako 5acf241033 feat: add translation service 2026-06-13 06:08:09 +02:00
hiimjako 3eb57cd327 feat: add pokeapi client 2026-06-13 06:07:02 +02:00
hiimjako b84de88585 chore: bootstrap rust service 2026-06-13 06:05:45 +02:00
30 changed files with 408 additions and 1130 deletions
+1 -14
View File
@@ -19,30 +19,17 @@ jobs:
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - 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 - name: Set up QEMU
if: ${{ steps.docker-platforms.outputs.platforms != 'linux/amd64' }}
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4 uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 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 - name: Build multi-arch image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with: with:
context: . context: .
platforms: ${{ steps.docker-platforms.outputs.platforms }} platforms: linux/amd64,linux/arm64
push: false push: false
build-args: | build-args: |
GIT_SHA=${{ github.sha }} GIT_SHA=${{ github.sha }}
-30
View File
@@ -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
View File
@@ -74,11 +74,11 @@ COPY --from=builder /usr/local/bin/pokedex-api /usr/local/bin/pokedex-api
ARG GIT_SHA=unknown ARG GIT_SHA=unknown
ENV GIT_SHA=$GIT_SHA \ 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 USER app
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ 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"] CMD ["pokedex-api"]
-63
View File
@@ -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) .
+20 -64
View File
@@ -4,21 +4,15 @@ REST API for the TrueLayer Software Engineering Challenge. It returns basic Pok
## Requirements ## Requirements
Install these tools first: You can run it either with Docker or with a local Rust toolchain.
- Rust, via `rustup`, for local development and tests.
- `make`, for the documented command shortcuts.
- Docker, for the containerized run path.
You can run it either with Docker or with a local Rust toolchain. Common commands are available through `make`; run `make help` for the full list.
### Option A: Docker ### Option A: Docker
Install Docker Desktop or an equivalent Docker runtime, then run: Install Docker Desktop or an equivalent Docker runtime, then run:
```bash ```bash
make docker-build docker build -t pokedex-api .
make docker-run docker run --rm -p 5000:5000 pokedex-api
``` ```
### Option B: local Rust ### Option B: local Rust
@@ -34,18 +28,18 @@ rustup default stable
Then run the service: Then run the service:
```bash ```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 ## Endpoints
```bash ```bash
make health curl http://localhost:5000/health
make pokemon curl http://localhost:5000/pokemon/mewtwo
make translated curl http://localhost:5000/pokemon/translated/mewtwo
make metrics curl http://localhost:5000/metrics
``` ```
Example response: Example response:
@@ -59,48 +53,13 @@ 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 ## Configuration
| Variable | Default | Description | | Variable | Default | Description |
| --------------------------- | ------------------------------------------- | -------------------------- | | --- | --- | --- |
| `BIND_ADDR` | `0.0.0.0:8000` | HTTP bind address | | `BIND_ADDR` | `0.0.0.0:5000` | HTTP bind address |
| `POKEAPI_BASE_URL` | `https://pokeapi.co/api/v2` | PokéAPI base URL | | `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 | | `FUN_TRANSLATIONS_BASE_URL` | `https://funtranslations.mercxry.me` | FunTranslations base URL |
| `REQUEST_TIMEOUT_SECONDS` | `5` | Upstream HTTP timeout | | `REQUEST_TIMEOUT_SECONDS` | `5` | Upstream HTTP timeout |
| `RATE_LIMIT_PER_SECOND` | `20` | Global token refill rate | | `RATE_LIMIT_PER_SECOND` | `20` | Global token refill rate |
| `RATE_LIMIT_BURST` | `40` | Global burst capacity | | `RATE_LIMIT_BURST` | `40` | Global burst capacity |
@@ -121,27 +80,24 @@ The service includes:
## Development checks ## Development checks
```bash ```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. 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 ## Docker multi-arch build
```bash ```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. The Dockerfile uses `cargo-chef` and BuildKit cache mounts for dependency and target directory caching.
## Production notes ## 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
View File
@@ -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.
-3
View File
@@ -1,3 +0,0 @@
mod pokedex;
pub use pokedex::{PokedexService, translation_kind_for};
-115
View File
@@ -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
}
}
-51
View File
@@ -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())
}
}
-4
View File
@@ -1,4 +0,0 @@
mod body;
pub mod funtranslations;
pub mod pokeapi;
+4 -4
View File
@@ -17,7 +17,7 @@ impl AppConfig {
Self { Self {
bind_addr: default_bind_addr(), bind_addr: default_bind_addr(),
pokeapi_base_url: "https://pokeapi.co/api/v2".to_owned(), 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), request_timeout: Duration::from_secs(5),
rate_limit_per_second: 20.0, rate_limit_per_second: 20.0,
rate_limit_burst: 40, rate_limit_burst: 40,
@@ -66,11 +66,11 @@ fn env_or_string(name: &'static str, default: &str) -> String {
} }
fn default_bind_addr() -> SocketAddr { 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> { 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(()) Ok(())
} else { } else {
Err(ConfigError::InvalidRateLimit) Err(ConfigError::InvalidRateLimit)
@@ -101,7 +101,7 @@ mod tests {
fn default_config_is_valid_for_local_development() { fn default_config_is_valid_for_local_development() {
let config = AppConfig::default_local(); 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_eq!(config.pokeapi_base_url, "https://pokeapi.co/api/v2");
assert!((config.rate_limit_per_second - 20.0).abs() < f64::EPSILON); assert!((config.rate_limit_per_second - 20.0).abs() < f64::EPSILON);
} }
-6
View File
@@ -15,9 +15,3 @@ impl PokemonInfo {
self.is_legendary || self.habitat.eq_ignore_ascii_case("cave") self.is_legendary || self.habitat.eq_ignore_ascii_case("cave")
} }
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TranslationKind {
Shakespeare,
Yoda,
}
+13 -74
View File
@@ -1,8 +1,4 @@
use axum::{ use axum::{Json, http::StatusCode, response::IntoResponse};
Json,
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use serde::Serialize; use serde::Serialize;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -33,20 +29,20 @@ impl AppError {
Self::Internal => StatusCode::INTERNAL_SERVER_ERROR, Self::Internal => StatusCode::INTERNAL_SERVER_ERROR,
} }
} }
}
#[must_use] impl IntoResponse for AppError {
pub const fn code(&self) -> &'static str { fn into_response(self) -> axum::response::Response {
match self { let status = self.status_code();
Self::BadRequest(_) => "bad_request", let body = ErrorBody {
Self::NotFound => "pokemon_not_found", error: self.public_message(),
Self::InvalidUpstreamData(_) => "upstream_invalid_data", };
Self::Upstream(_) | Self::Timeout => "upstream_unavailable", (status, Json(body)).into_response()
Self::Internal => "internal_error",
} }
} }
#[must_use] impl AppError {
pub fn public_message(&self) -> String { fn public_message(&self) -> String {
match self { match self {
Self::BadRequest(message) => message.clone(), Self::BadRequest(message) => message.clone(),
Self::NotFound => "pokemon not found".to_owned(), 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)] #[derive(Serialize)]
pub struct ErrorBody { struct ErrorBody {
code: &'static str, error: String,
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)
} }
+8 -26
View File
@@ -1,9 +1,9 @@
use super::AppState; use super::AppState;
use crate::{domain::PokemonInfo, error::ApiError}; use crate::{domain::PokemonInfo, error::AppError};
use axum::{ use axum::{
Json, Json,
extract::{Path, State}, extract::{Path, State},
http::{HeaderMap, header}, http::header,
response::IntoResponse, response::IntoResponse,
}; };
use serde::Serialize; use serde::Serialize;
@@ -12,41 +12,23 @@ pub(super) async fn health() -> Json<HealthResponse> {
Json(HealthResponse { status: "ok" }) Json(HealthResponse { status: "ok" })
} }
pub(super) async fn metrics( pub(super) async fn metrics(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
State(state): State<AppState>, let body = state.telemetry.render_prometheus()?;
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)) Ok(([(header::CONTENT_TYPE, "text/plain; version=0.0.4")], body))
} }
pub(super) async fn get_pokemon( pub(super) async fn get_pokemon(
State(state): State<AppState>, State(state): State<AppState>,
Path(name): Path<String>, Path(name): Path<String>,
headers: HeaderMap, ) -> Result<Json<PokemonInfo>, AppError> {
) -> Result<Json<PokemonInfo>, ApiError> { state.service.get_basic(&name).await.map(Json)
state
.service
.get_basic(&name)
.await
.map(Json)
.map_err(|error| ApiError::from_headers(error, &headers))
} }
pub(super) async fn get_translated_pokemon( pub(super) async fn get_translated_pokemon(
State(state): State<AppState>, State(state): State<AppState>,
Path(name): Path<String>, Path(name): Path<String>,
headers: HeaderMap, ) -> Result<Json<PokemonInfo>, AppError> {
) -> Result<Json<PokemonInfo>, ApiError> { state.service.get_translated(&name).await.map(Json)
state
.service
.get_translated(&name)
.await
.map(Json)
.map_err(|error| ApiError::from_headers(error, &headers))
} }
#[derive(Serialize)] #[derive(Serialize)]
+2 -9
View File
@@ -1,5 +1,4 @@
use super::AppState; use super::AppState;
use crate::error::{ErrorBody, request_id_from_headers};
use axum::{ use axum::{
Json, Json,
extract::{MatchedPath, Request, State}, extract::{MatchedPath, Request, State},
@@ -14,18 +13,12 @@ pub(super) async fn enforce_rate_limit(
req: Request, req: Request,
next: middleware::Next, next: middleware::Next,
) -> Response { ) -> Response {
let path = req.uri().path(); if state.rate_limiter.try_acquire() {
if matches!(path, "/health" | "/metrics") || state.rate_limiter.try_acquire() {
next.run(req).await next.run(req).await
} else { } else {
let request_id = request_id_from_headers(req.headers());
( (
StatusCode::TOO_MANY_REQUESTS, StatusCode::TOO_MANY_REQUESTS,
Json(ErrorBody::new( Json(serde_json::json!({ "error": "rate limit exceeded" })),
"rate_limit_exceeded",
"rate limit exceeded",
request_id,
)),
) )
.into_response() .into_response()
} }
+3 -9
View File
@@ -1,13 +1,9 @@
mod handlers; mod handlers;
mod middleware; mod middleware;
mod rate_limit;
use crate::{ use crate::{
application::PokedexService, config::AppConfig, error::AppError, pokemon::PokeApiClient, rate_limit::RateLimiter,
clients::{funtranslations::TranslationClient, pokeapi::PokeApiClient}, service::PokedexService, telemetry::Telemetry, translation::TranslationClient,
config::AppConfig,
error::AppError,
telemetry::Telemetry,
}; };
use axum::{Router, extract::Request, middleware as axum_middleware, routing::get}; use axum::{Router, extract::Request, middleware as axum_middleware, routing::get};
use std::sync::Arc; use std::sync::Arc;
@@ -18,8 +14,6 @@ use tower_http::{
trace::{DefaultOnResponse, TraceLayer}, trace::{DefaultOnResponse, TraceLayer},
}; };
pub use rate_limit::RateLimiter;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub(super) service: Arc<PokedexService>, pub(super) service: Arc<PokedexService>,
@@ -66,7 +60,7 @@ pub fn create_app(state: AppState) -> Router {
tracing::info_span!( tracing::info_span!(
"http.request", "http.request",
method = %request.method(), method = %request.method(),
path = %request.uri().path(), uri = %request.uri(),
request_id = %request_id, request_id = %request_id,
) )
}) })
+4 -2
View File
@@ -2,13 +2,15 @@
#![deny(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)] #![deny(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::missing_errors_doc, clippy::multiple_crate_versions)] #![allow(clippy::missing_errors_doc, clippy::multiple_crate_versions)]
pub mod application;
pub mod clients;
pub mod config; pub mod config;
pub mod domain; pub mod domain;
pub mod error; pub mod error;
pub mod http; pub mod http;
pub mod pokemon;
pub mod rate_limit;
pub mod service;
pub mod telemetry; pub mod telemetry;
pub mod translation;
#[must_use] #[must_use]
pub const fn crate_name() -> &'static str { pub const fn crate_name() -> &'static str {
+7 -52
View File
@@ -1,4 +1,4 @@
use crate::{clients::body, domain::PokemonInfo, error::AppError}; use crate::{domain::PokemonInfo, error::AppError};
use serde::Deserialize; use serde::Deserialize;
use std::time::Duration; use std::time::Duration;
use tracing::instrument; use tracing::instrument;
@@ -25,11 +25,7 @@ impl PokeApiClient {
#[instrument(skip(self), fields(pokemon.name = %name))] #[instrument(skip(self), fields(pokemon.name = %name))]
pub async fn get_pokemon_info(&self, name: &str) -> Result<PokemonInfo, AppError> { pub async fn get_pokemon_info(&self, name: &str) -> Result<PokemonInfo, AppError> {
validate_pokemon_name(name)?; validate_pokemon_name(name)?;
let url = format!( let url = format!("{}/pokemon-species/{}", self.base_url, name);
"{}/pokemon-species/{}",
self.base_url,
name.to_ascii_lowercase()
);
let response = self let response = self
.http .http
.get(url) .get(url)
@@ -48,9 +44,12 @@ impl PokeApiClient {
))); )));
} }
body::read_json::<PokemonSpeciesResponse>(response, "PokéAPI") let species = response
.json::<PokemonSpeciesResponse>()
.await .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); 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] #[tokio::test]
async fn uses_unknown_habitat_when_pokeapi_has_no_habitat() { async fn uses_unknown_habitat_when_pokeapi_has_no_habitat() {
let server = MockServer::start().await; let server = MockServer::start().await;
@@ -300,24 +273,6 @@ mod tests {
assert!(matches!(error, AppError::InvalidUpstreamData(_))); 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 { fn client_for(server: &MockServer) -> PokeApiClient {
PokeApiClient::new(server.uri(), Duration::from_secs(1)).expect("client should build") PokeApiClient::new(server.uri(), Duration::from_secs(1)).expect("client should build")
} }
+12 -11
View File
@@ -1,8 +1,5 @@
use std::{sync::Mutex, time::Instant}; 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)] #[derive(Debug)]
pub struct RateLimiter { pub struct RateLimiter {
bucket: Mutex<TokenBucket>, bucket: Mutex<TokenBucket>,
@@ -10,7 +7,7 @@ pub struct RateLimiter {
impl RateLimiter { impl RateLimiter {
pub fn new(refill_per_second: f64, burst: u32) -> Result<Self, RateLimitError> { 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); return Err(RateLimitError::InvalidConfiguration);
} }
@@ -70,13 +67,7 @@ impl TokenBucket {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::RateLimiter; 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] #[test]
fn allows_requests_up_to_the_burst_capacity() { fn allows_requests_up_to_the_burst_capacity() {
@@ -86,4 +77,14 @@ mod tests {
assert!(limiter.try_acquire()); assert!(limiter.try_acquire());
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
View File
@@ -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(),
)
}
}
+3 -24
View File
@@ -11,27 +11,6 @@ pub struct AppMetrics {
upstream_request_duration: opentelemetry::metrics::Histogram<f64>, 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 { impl AppMetrics {
#[must_use] #[must_use]
pub fn new(meter: &Meter) -> Self { pub fn new(meter: &Meter) -> Self {
@@ -89,10 +68,10 @@ impl AppMetrics {
&self, &self,
service: &'static str, service: &'static str,
operation: &'static str, operation: &'static str,
outcome: UpstreamRequestOutcome, success: bool,
duration: Duration, duration: Duration,
) { ) {
let status = outcome.label(); let status = if success { "ok" } else { "error" };
let attributes = [ let attributes = [
KeyValue::new("service", service), KeyValue::new("service", service),
KeyValue::new("operation", operation), KeyValue::new("operation", operation),
@@ -101,7 +80,7 @@ impl AppMetrics {
self.upstream_requests.add(1, &attributes); self.upstream_requests.add(1, &attributes);
self.upstream_request_duration self.upstream_request_duration
.record(duration.as_secs_f64(), &attributes); .record(duration.as_secs_f64(), &attributes);
if outcome.is_error() { if !success {
self.upstream_errors.add(1, &attributes); self.upstream_errors.add(1, &attributes);
} }
} }
+1 -1
View File
@@ -6,7 +6,7 @@ use opentelemetry::metrics::MeterProvider;
use opentelemetry_prometheus_text_exporter::PrometheusExporter; use opentelemetry_prometheus_text_exporter::PrometheusExporter;
use opentelemetry_sdk::{Resource, metrics::SdkMeterProvider}; use opentelemetry_sdk::{Resource, metrics::SdkMeterProvider};
pub use metrics::{AppMetrics, UpstreamRequestOutcome}; pub use metrics::AppMetrics;
pub use tracing::init_tracing; pub use tracing::init_tracing;
pub struct Telemetry { pub struct Telemetry {
@@ -1,4 +1,4 @@
use crate::{clients::body, domain::TranslationKind, error::AppError}; use crate::error::AppError;
use serde::Deserialize; use serde::Deserialize;
use std::time::Duration; use std::time::Duration;
use tracing::instrument; use tracing::instrument;
@@ -9,6 +9,22 @@ pub struct TranslationClient {
http: reqwest::Client, 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 { impl TranslationClient {
pub fn new(base_url: impl Into<String>, timeout: Duration) -> Result<Self, AppError> { pub fn new(base_url: impl Into<String>, timeout: Duration) -> Result<Self, AppError> {
let http = reqwest::Client::builder() let http = reqwest::Client::builder()
@@ -24,11 +40,11 @@ impl TranslationClient {
#[instrument(skip(self, text), fields(translation.kind = ?kind))] #[instrument(skip(self, text), fields(translation.kind = ?kind))]
pub async fn translate(&self, kind: TranslationKind, text: &str) -> Result<String, AppError> { 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 let response = self
.http .http
.post(url) .post(url)
.json(&TranslationRequest { text }) .form(&[("text", text)])
.send() .send()
.await .await
.map_err(|error| map_reqwest_error(&error))?; .map_err(|error| map_reqwest_error(&error))?;
@@ -40,21 +56,13 @@ impl TranslationClient {
))); )));
} }
let payload = body::read_json::<TranslationResponse>(response, "FunTranslations").await?; response
if payload.contents.translated.trim().is_empty() { .json::<TranslationResponse>()
Err(AppError::InvalidUpstreamData( .await
"FunTranslations returned an empty translation".to_owned(), .map(|payload| payload.contents.translated)
)) .map_err(|error| map_reqwest_error(&error))
} else {
Ok(payload.contents.translated)
} }
} }
}
#[derive(serde::Serialize)]
struct TranslationRequest<'a> {
text: &'a str,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct TranslationResponse { struct TranslationResponse {
@@ -66,13 +74,6 @@ struct TranslationContents {
translated: String, 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 { fn trim_trailing_slash(value: &str) -> String {
value.trim_end_matches('/').to_owned() value.trim_end_matches('/').to_owned()
} }
@@ -87,23 +88,22 @@ fn map_reqwest_error(error: &reqwest::Error) -> AppError {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{TranslationClient, endpoint_for}; use super::{TranslationClient, TranslationKind};
use crate::domain::TranslationKind;
use std::time::Duration; use std::time::Duration;
use wiremock::{Mock, MockServer, ResponseTemplate, matchers}; use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
#[test] #[test]
fn translation_kind_resolves_funtranslations_endpoint() { fn translation_kind_resolves_funtranslations_endpoint() {
assert_eq!(endpoint_for(TranslationKind::Yoda), "yoda"); assert_eq!(TranslationKind::Yoda.endpoint(), "yoda");
assert_eq!(endpoint_for(TranslationKind::Shakespeare), "shakespeare"); assert_eq!(TranslationKind::Shakespeare.endpoint(), "shakespeare");
} }
#[tokio::test] #[tokio::test]
async fn posts_text_to_the_requested_translation_endpoint() { async fn posts_text_to_the_requested_translation_endpoint() {
let server = MockServer::start().await; let server = MockServer::start().await;
Mock::given(matchers::method("POST")) Mock::given(matchers::method("POST"))
.and(matchers::path("/translate/yoda")) .and(matchers::path("/translate/yoda.json"))
.and(matchers::body_string_contains("Created")) .and(matchers::body_string_contains("text=Created"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"contents": { "translated": "Created, it was." } "contents": { "translated": "Created, it was." }
}))) })))
@@ -120,31 +120,11 @@ mod tests {
assert_eq!(translated, "Created, it was."); 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] #[tokio::test]
async fn maps_translation_http_failures_to_upstream_errors() { async fn maps_translation_http_failures_to_upstream_errors() {
let server = MockServer::start().await; let server = MockServer::start().await;
Mock::given(matchers::method("POST")) Mock::given(matchers::method("POST"))
.and(matchers::path("/translate/shakespeare")) .and(matchers::path("/translate/shakespeare.json"))
.respond_with(ResponseTemplate::new(429)) .respond_with(ResponseTemplate::new(429))
.mount(&server) .mount(&server)
.await; .await;
+49 -243
View File
@@ -3,22 +3,18 @@ use axum::{
http::{Request, StatusCode, header::HeaderName}, http::{Request, StatusCode, header::HeaderName},
}; };
use pokedex_api::{ use pokedex_api::{
application::PokedexService, http::{AppState, create_app},
clients::{funtranslations::TranslationClient, pokeapi::PokeApiClient}, pokemon::PokeApiClient,
http::{AppState, RateLimiter, create_app}, rate_limit::RateLimiter,
service::PokedexService,
telemetry::Telemetry, telemetry::Telemetry,
translation::TranslationClient,
}; };
use serde_json::Value; use serde_json::Value;
use std::time::Duration; use std::time::Duration;
use tower::ServiceExt; use tower::ServiceExt;
use wiremock::{Mock, MockServer, ResponseTemplate, matchers}; 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] #[tokio::test]
async fn health_endpoint_reports_ok() { async fn health_endpoint_reports_ok() {
let pokeapi = MockServer::start().await; let pokeapi = MockServer::start().await;
@@ -41,7 +37,7 @@ async fn health_endpoint_reports_ok() {
async fn pokemon_endpoint_returns_basic_pokemon_information() { async fn pokemon_endpoint_returns_basic_pokemon_information() {
let pokeapi = MockServer::start().await; let pokeapi = MockServer::start().await;
let translations = 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 app = create_app(state_for(&pokeapi, &translations));
let response = app let response = app
@@ -54,7 +50,7 @@ async fn pokemon_endpoint_returns_basic_pokemon_information() {
json_body(response).await, json_body(response).await,
serde_json::json!({ serde_json::json!({
"name": "mewtwo", "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", "habitat": "rare",
"isLegendary": true "isLegendary": true
}) })
@@ -73,27 +69,22 @@ async fn pokemon_endpoint_rejects_invalid_names_before_calling_upstream() {
.expect("request should be handled"); .expect("request should be handled");
assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = json_body(response).await;
assert_eq!(body["code"], "bad_request");
assert_eq!( assert_eq!(
body["message"], json_body(response).await["error"],
"pokemon name must contain only ASCII letters, digits, or hyphens" "pokemon name must contain only ASCII letters, digits, or hyphens"
); );
assert!(body["requestId"].is_string());
} }
#[tokio::test] #[tokio::test]
async fn translated_endpoint_returns_translated_description() { async fn translated_endpoint_returns_translated_description() {
let pokeapi = MockServer::start().await; let pokeapi = MockServer::start().await;
let translations = 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")) Mock::given(matchers::method("POST"))
.and(matchers::path("/translate/shakespeare")) .and(matchers::path("/translate/shakespeare.json"))
.and(matchers::body_string_contains("Electric cheeks")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
.respond_with( "contents": { "translated": "Electric cheeks, prithee." }
ResponseTemplate::new(200).set_body_string(FUNTRANSLATIONS_SHAKESPEARE_PIKACHU), })))
)
.expect(1)
.mount(&translations) .mount(&translations)
.await; .await;
let app = create_app(state_for(&pokeapi, &translations)); 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!(response.status(), StatusCode::OK);
assert_eq!( assert_eq!(
json_body(response).await, json_body(response).await["description"],
serde_json::json!({ "Electric cheeks, prithee."
"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
})
); );
} }
@@ -158,7 +107,7 @@ async fn translated_endpoint_falls_back_to_original_description_when_translation
let translations = MockServer::start().await; let translations = MockServer::start().await;
mount_species(&pokeapi, "zubat", false, "cave", "It has no eyes.").await; mount_species(&pokeapi, "zubat", false, "cave", "It has no eyes.").await;
Mock::given(matchers::method("POST")) Mock::given(matchers::method("POST"))
.and(matchers::path("/translate/yoda")) .and(matchers::path("/translate/yoda.json"))
.respond_with(ResponseTemplate::new(500)) .respond_with(ResponseTemplate::new(500))
.mount(&translations) .mount(&translations)
.await; .await;
@@ -170,47 +119,7 @@ async fn translated_endpoint_falls_back_to_original_description_when_translation
.expect("request should be handled"); .expect("request should be handled");
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
assert_eq!( assert_eq!(json_body(response).await["description"], "It has no eyes.");
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] #[tokio::test]
@@ -247,10 +156,32 @@ async fn keeps_incoming_request_id_when_present() {
} }
#[tokio::test] #[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 pokeapi = MockServer::start().await;
let translations = MockServer::start().await; let translations = MockServer::start().await;
mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").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 telemetry = Telemetry::new("test-service");
let app = create_app(AppState::new( let app = create_app(AppState::new(
PokedexService::new( PokedexService::new(
@@ -265,57 +196,17 @@ async fn rate_limit_rejects_requests_over_the_configured_burst() {
let first = app let first = app
.clone() .clone()
.oneshot(request("/pokemon/mewtwo")) .oneshot(request("/health"))
.await .await
.expect("request should be handled"); .expect("request should be handled");
let second = app let second = app
.oneshot(request("/pokemon/mewtwo")) .oneshot(request("/health"))
.await .await
.expect("request should be handled"); .expect("request should be handled");
assert_eq!(first.status(), StatusCode::OK); assert_eq!(first.status(), StatusCode::OK);
assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS); assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS);
let body = json_body(second).await; assert_eq!(json_body(second).await["error"], "rate limit exceeded");
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] #[tokio::test]
@@ -330,93 +221,12 @@ async fn pokemon_endpoint_maps_missing_pokemon_to_404() {
let app = create_app(state_for(&pokeapi, &translations)); let app = create_app(state_for(&pokeapi, &translations));
let response = app let response = app
.clone() .oneshot(request("/pokemon/nope"))
.oneshot(request_with_request_id("/pokemon/nope", "known-request-id"))
.await .await
.expect("request should be handled"); .expect("request should be handled");
assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(response.headers()["x-request-id"], "known-request-id"); assert_eq!(json_body(response).await["error"], "pokemon not found");
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;
} }
async fn mount_species( async fn mount_species(
@@ -461,19 +271,15 @@ fn request(path: &str) -> Request<Body> {
.expect("request should build") .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 { async fn json_body(response: axum::response::Response) -> Value {
let bytes = body_bytes(response).await; let bytes = body_bytes(response).await;
serde_json::from_slice(&bytes).expect("body should be valid JSON") 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 { async fn body_bytes(response: axum::response::Response) -> axum::body::Bytes {
to_bytes(response.into_body(), 1024 * 1024) to_bytes(response.into_body(), 1024 * 1024)
.await .await
-56
View File
@@ -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"
}
}
-8
View File
@@ -1,8 +0,0 @@
{
"success": { "total": 1 },
"contents": {
"translated": "Created by science, it was.",
"text": "Created by science.",
"translation": "yoda"
}
}
-11
View File
@@ -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
View File
@@ -1,11 +0,0 @@
{
"name": "pikachu",
"is_legendary": false,
"habitat": { "name": "forest" },
"flavor_text_entries": [
{
"flavor_text": "Electric cheeks.",
"language": { "name": "en" }
}
]
}
+18
View File
@@ -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\""));
}