From cf11e20f90642ef3ad6c9b19fbfc397efb30bf22 Mon Sep 17 00:00:00 2001 From: Alberto Moretti Date: Sat, 13 Jun 2026 11:29:23 +0200 Subject: [PATCH] feat: document api contract --- README.md | 14 ++ docs/architecture.md | 33 ++++ openapi.yaml | 170 ++++++++++++++++++ src/application/pokedex.rs | 2 + src/error.rs | 91 ++++++++-- src/http/handlers.rs | 34 +++- src/http/middleware.rs | 8 +- tests/app.rs | 74 +++++--- .../funtranslations_shakespeare_pikachu.json | 8 + .../fixtures/funtranslations_yoda_mewtwo.json | 8 + tests/fixtures/pokeapi_mewtwo_species.json | 11 ++ tests/fixtures/pokeapi_pikachu_species.json | 11 ++ 12 files changed, 417 insertions(+), 47 deletions(-) create mode 100644 docs/architecture.md create mode 100644 openapi.yaml create mode 100644 tests/fixtures/funtranslations_shakespeare_pikachu.json create mode 100644 tests/fixtures/funtranslations_yoda_mewtwo.json create mode 100644 tests/fixtures/pokeapi_mewtwo_species.json create mode 100644 tests/fixtures/pokeapi_pikachu_species.json diff --git a/README.md b/README.md index 616a4d1..793030e 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a960e1a --- /dev/null +++ b/docs/architecture.md @@ -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. diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..d06d54f --- /dev/null +++ b/openapi.yaml @@ -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. diff --git a/src/application/pokedex.rs b/src/application/pokedex.rs index 4664a36..9239762 100644 --- a/src/application/pokedex.rs +++ b/src/application/pokedex.rs @@ -66,6 +66,8 @@ impl PokedexService { } async fn fetch_pokemon(&self, name: &str) -> Result { + // 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(_))) { diff --git a/src/error.rs b/src/error.rs index a25a0ed..f7c99f0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,4 +1,8 @@ -use axum::{Json, http::StatusCode, response::IntoResponse}; +use axum::{ + Json, + http::{HeaderMap, StatusCode}, + response::IntoResponse, +}; use serde::Serialize; #[derive(Debug, thiserror::Error)] @@ -29,20 +33,20 @@ impl AppError { Self::Internal => StatusCode::INTERNAL_SERVER_ERROR, } } -} -impl IntoResponse for AppError { - fn into_response(self) -> axum::response::Response { - let status = self.status_code(); - let body = ErrorBody { - error: self.public_message(), - }; - (status, Json(body)).into_response() + #[must_use] + pub const fn code(&self) -> &'static str { + match self { + Self::BadRequest(_) => "bad_request", + Self::NotFound => "pokemon_not_found", + Self::InvalidUpstreamData(_) => "upstream_invalid_data", + Self::Upstream(_) | Self::Timeout => "upstream_unavailable", + Self::Internal => "internal_error", + } } -} -impl AppError { - fn public_message(&self) -> String { + #[must_use] + pub fn public_message(&self) -> String { match self { Self::BadRequest(message) => message.clone(), Self::NotFound => "pokemon not found".to_owned(), @@ -53,7 +57,64 @@ impl AppError { } } -#[derive(Serialize)] -struct ErrorBody { - error: String, +impl IntoResponse for AppError { + fn into_response(self) -> axum::response::Response { + ApiError::new(self, None).into_response() + } +} + +pub struct ApiError { + source: AppError, + request_id: Option, +} + +impl ApiError { + #[must_use] + pub const fn new(source: AppError, request_id: Option) -> 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, +} + +impl ErrorBody { + #[must_use] + pub fn new(code: &'static str, message: impl Into, request_id: Option) -> Self { + Self { + code, + message: message.into(), + request_id, + } + } +} + +#[must_use] +pub fn request_id_from_headers(headers: &HeaderMap) -> Option { + headers + .get("x-request-id") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) } diff --git a/src/http/handlers.rs b/src/http/handlers.rs index 4607f0e..ea62239 100644 --- a/src/http/handlers.rs +++ b/src/http/handlers.rs @@ -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 { Json(HealthResponse { status: "ok" }) } -pub(super) async fn metrics(State(state): State) -> Result { - let body = state.telemetry.render_prometheus()?; +pub(super) async fn metrics( + State(state): State, + headers: HeaderMap, +) -> Result { + 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, Path(name): Path, -) -> Result, AppError> { - state.service.get_basic(&name).await.map(Json) + headers: HeaderMap, +) -> Result, 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, Path(name): Path, -) -> Result, AppError> { - state.service.get_translated(&name).await.map(Json) + headers: HeaderMap, +) -> Result, ApiError> { + state + .service + .get_translated(&name) + .await + .map(Json) + .map_err(|error| ApiError::from_headers(error, &headers)) } #[derive(Serialize)] diff --git a/src/http/middleware.rs b/src/http/middleware.rs index d49e0eb..1452126 100644 --- a/src/http/middleware.rs +++ b/src/http/middleware.rs @@ -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() } diff --git a/tests/app.rs b/tests/app.rs index d1ed899..0fe84d0 100644 --- a/tests/app.rs +++ b/tests/app.rs @@ -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 { .expect("request should build") } +fn request_with_request_id(path: &str, request_id: &str) -> Request { + 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") diff --git a/tests/fixtures/funtranslations_shakespeare_pikachu.json b/tests/fixtures/funtranslations_shakespeare_pikachu.json new file mode 100644 index 0000000..8ae79a7 --- /dev/null +++ b/tests/fixtures/funtranslations_shakespeare_pikachu.json @@ -0,0 +1,8 @@ +{ + "success": { "total": 1 }, + "contents": { + "translated": "Electric cheeks, prithee.", + "text": "Electric cheeks.", + "translation": "shakespeare" + } +} diff --git a/tests/fixtures/funtranslations_yoda_mewtwo.json b/tests/fixtures/funtranslations_yoda_mewtwo.json new file mode 100644 index 0000000..2a53f33 --- /dev/null +++ b/tests/fixtures/funtranslations_yoda_mewtwo.json @@ -0,0 +1,8 @@ +{ + "success": { "total": 1 }, + "contents": { + "translated": "Created by science, it was.", + "text": "Created by science.", + "translation": "yoda" + } +} diff --git a/tests/fixtures/pokeapi_mewtwo_species.json b/tests/fixtures/pokeapi_mewtwo_species.json new file mode 100644 index 0000000..b8c790e --- /dev/null +++ b/tests/fixtures/pokeapi_mewtwo_species.json @@ -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" } + } + ] +} diff --git a/tests/fixtures/pokeapi_pikachu_species.json b/tests/fixtures/pokeapi_pikachu_species.json new file mode 100644 index 0000000..a62352b --- /dev/null +++ b/tests/fixtures/pokeapi_pikachu_species.json @@ -0,0 +1,11 @@ +{ + "name": "pikachu", + "is_legendary": false, + "habitat": { "name": "forest" }, + "flavor_text_entries": [ + { + "flavor_text": "Electric cheeks.", + "language": { "name": "en" } + } + ] +}