feat: document api contract

This commit is contained in:
2026-06-13 11:29:23 +02:00
parent b910e77460
commit cf11e20f90
12 changed files with 417 additions and 47 deletions

View File

@@ -68,6 +68,20 @@ Example response:
`/pokemon/translated/{name}` applies Yoda when the Pokémon is legendary or its habitat is `cave`; otherwise it applies Shakespeare. If translation fails or returns an empty result, the API falls back to the standard PokéAPI description.
## Architecture
```mermaid
flowchart LR
CLI[CLI helper] --> HTTP[HTTP layer]
HTTP --> APP[Application service]
APP --> DOMAIN[Domain]
APP --> CLIENTS[External clients]
HTTP --> OTEL[Telemetry]
APP --> OTEL
```
See `docs/architecture.md` for the design notes.
## Configuration
| Variable | Default | Description |

33
docs/architecture.md Normal file
View File

@@ -0,0 +1,33 @@
# Architecture
This is a small Rust service, so the structure is intentionally a lightweight hexagonal/onion layout rather than a framework-heavy one.
```mermaid
flowchart LR
CLI[CLI helper] --> HTTP
HTTP[HTTP layer: axum routes, handlers, middleware] --> Application
Application[Application service: use cases and translation rules] --> Domain
Application --> Clients
Clients[External clients: PokéAPI and FunTranslations]
HTTP --> Telemetry
Application --> Telemetry
```
## Layers
- `domain`: core data and business concepts (`PokemonInfo`, `TranslationKind`).
- `application`: orchestration and business rules, including Yoda vs Shakespeare selection and fallback behavior.
- `clients`: adapters for external providers (`PokéAPI`, `FunTranslations`).
- `http`: API transport concerns, routing, handlers, request IDs, metrics middleware, and rate limiting.
- `telemetry`: OpenTelemetry metrics and tracing setup.
- `bin`: executable entrypoints for the API and the local CLI helper.
## Deliberate trade-offs
The application service currently depends on concrete clients instead of trait-based ports. For this challenge that keeps the code easier to read and avoids abstractions that do not buy much yet. In a larger enterprise codebase, those clients would likely become traits owned by the application layer, with HTTP clients as adapters, so providers could be swapped or mocked without depending on concrete implementations.
PokéAPI species data changes rarely, so production deployments would add a TTL cache for successful species responses. That would reduce upstream calls, improve latency, and make transient provider failures less visible to users.
Rate limiting is intentionally simple and in-memory. In production, this should usually live in an API gateway or shared rate-limiting service so limits are consistent across instances and can be keyed by API key, user, or client IP.
External contract tests are separated from the normal deterministic test suite. Unit and integration tests use mocks; the nightly workflow calls real providers to detect contract drift early.

170
openapi.yaml Normal file
View File

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

View File

@@ -66,6 +66,8 @@ impl PokedexService {
}
async fn fetch_pokemon(&self, name: &str) -> Result<PokemonInfo, AppError> {
// PokéAPI species data changes rarely. In production, cache successful
// species responses with a TTL to reduce latency and upstream pressure.
let start = Instant::now();
let pokemon = self.pokemon.get_pokemon_info(name).await;
if !matches!(pokemon, Err(AppError::BadRequest(_))) {

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,12 @@ 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;
@@ -35,7 +41,7 @@ async fn health_endpoint_reports_ok() {
async fn pokemon_endpoint_returns_basic_pokemon_information() {
let pokeapi = MockServer::start().await;
let translations = MockServer::start().await;
mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").await;
mount_species_fixture(&pokeapi, "mewtwo", POKEAPI_MEWTWO_SPECIES).await;
let app = create_app(state_for(&pokeapi, &translations));
let response = app
@@ -48,7 +54,7 @@ async fn pokemon_endpoint_returns_basic_pokemon_information() {
json_body(response).await,
serde_json::json!({
"name": "mewtwo",
"description": "Created by science.",
"description": "It was created by a scientist after years of horrific gene splicing and DNA engineering experiments.",
"habitat": "rare",
"isLegendary": true
})
@@ -67,23 +73,26 @@ async fn pokemon_endpoint_rejects_invalid_names_before_calling_upstream() {
.expect("request should be handled");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = json_body(response).await;
assert_eq!(body["code"], "bad_request");
assert_eq!(
json_body(response).await["error"],
body["message"],
"pokemon name must contain only ASCII letters, digits, or hyphens"
);
assert!(body["requestId"].is_string());
}
#[tokio::test]
async fn translated_endpoint_returns_translated_description() {
let pokeapi = MockServer::start().await;
let translations = MockServer::start().await;
mount_species(&pokeapi, "pikachu", false, "forest", "Electric cheeks.").await;
mount_species_fixture(&pokeapi, "pikachu", POKEAPI_PIKACHU_SPECIES).await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/translate/shakespeare"))
.and(matchers::body_string_contains("Electric cheeks"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"contents": { "translated": "Electric cheeks, prithee." }
})))
.respond_with(
ResponseTemplate::new(200).set_body_string(FUNTRANSLATIONS_SHAKESPEARE_PIKACHU),
)
.expect(1)
.mount(&translations)
.await;
@@ -110,13 +119,11 @@ async fn translated_endpoint_returns_translated_description() {
async fn translated_endpoint_uses_yoda_for_legendary_pokemon() {
let pokeapi = MockServer::start().await;
let translations = MockServer::start().await;
mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").await;
mount_species_fixture(&pokeapi, "mewtwo", POKEAPI_MEWTWO_SPECIES).await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/translate/yoda"))
.and(matchers::body_string_contains("Created by science"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"contents": { "translated": "Created by science, it was." }
})))
.and(matchers::body_string_contains("horrific gene splicing"))
.respond_with(ResponseTemplate::new(200).set_body_string(FUNTRANSLATIONS_YODA_MEWTWO))
.expect(1)
.mount(&translations)
.await;
@@ -291,7 +298,10 @@ async fn rate_limit_rejects_requests_over_the_configured_burst() {
assert_eq!(first.status(), StatusCode::OK);
assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(json_body(second).await["error"], "rate limit exceeded");
let body = json_body(second).await;
assert_eq!(body["code"], "rate_limit_exceeded");
assert_eq!(body["message"], "rate limit exceeded");
assert!(body["requestId"].is_string());
}
#[tokio::test]
@@ -344,12 +354,16 @@ async fn pokemon_endpoint_maps_missing_pokemon_to_404() {
let response = app
.clone()
.oneshot(request("/pokemon/nope"))
.oneshot(request_with_request_id("/pokemon/nope", "known-request-id"))
.await
.expect("request should be handled");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(json_body(response).await["error"], "pokemon not found");
assert_eq!(response.headers()["x-request-id"], "known-request-id");
let error = json_body(response).await;
assert_eq!(error["code"], "pokemon_not_found");
assert_eq!(error["message"], "pokemon not found");
assert_eq!(error["requestId"], "known-request-id");
let metrics = app
.oneshot(request("/metrics"))
@@ -377,10 +391,9 @@ async fn pokemon_endpoint_maps_pokeapi_500_to_502() {
.expect("request should be handled");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
assert_eq!(
json_body(response).await["error"],
"upstream service failed"
);
let body = json_body(response).await;
assert_eq!(body["code"], "upstream_unavailable");
assert_eq!(body["message"], "upstream service failed");
}
#[tokio::test]
@@ -400,10 +413,9 @@ async fn pokemon_endpoint_maps_invalid_pokeapi_json_to_502() {
.expect("request should be handled");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
assert_eq!(
json_body(response).await["error"],
"upstream service returned invalid data"
);
let body = json_body(response).await;
assert_eq!(body["code"], "upstream_invalid_data");
assert_eq!(body["message"], "upstream service returned invalid data");
}
#[tokio::test]
@@ -430,6 +442,14 @@ async fn translated_endpoint_does_not_call_funtranslations_when_pokeapi_fails()
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(
server: &MockServer,
name: &str,
@@ -472,6 +492,14 @@ fn request(path: &str) -> Request<Body> {
.expect("request should build")
}
fn request_with_request_id(path: &str, request_id: &str) -> Request<Body> {
Request::builder()
.uri(path)
.header(HeaderName::from_static("x-request-id"), request_id)
.body(Body::empty())
.expect("request should build")
}
async fn json_body(response: axum::response::Response) -> Value {
let bytes = body_bytes(response).await;
serde_json::from_slice(&bytes).expect("body should be valid JSON")

View File

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

View File

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

View File

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

View File

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