feat: document api contract
This commit is contained in:
14
README.md
14
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.
|
`/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
|
## Configuration
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|
|||||||
33
docs/architecture.md
Normal file
33
docs/architecture.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
This is a small Rust service, so the structure is intentionally a lightweight hexagonal/onion layout rather than a framework-heavy one.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
CLI[CLI helper] --> HTTP
|
||||||
|
HTTP[HTTP layer: axum routes, handlers, middleware] --> Application
|
||||||
|
Application[Application service: use cases and translation rules] --> Domain
|
||||||
|
Application --> Clients
|
||||||
|
Clients[External clients: PokéAPI and FunTranslations]
|
||||||
|
HTTP --> Telemetry
|
||||||
|
Application --> Telemetry
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layers
|
||||||
|
|
||||||
|
- `domain`: core data and business concepts (`PokemonInfo`, `TranslationKind`).
|
||||||
|
- `application`: orchestration and business rules, including Yoda vs Shakespeare selection and fallback behavior.
|
||||||
|
- `clients`: adapters for external providers (`PokéAPI`, `FunTranslations`).
|
||||||
|
- `http`: API transport concerns, routing, handlers, request IDs, metrics middleware, and rate limiting.
|
||||||
|
- `telemetry`: OpenTelemetry metrics and tracing setup.
|
||||||
|
- `bin`: executable entrypoints for the API and the local CLI helper.
|
||||||
|
|
||||||
|
## Deliberate trade-offs
|
||||||
|
|
||||||
|
The application service currently depends on concrete clients instead of trait-based ports. For this challenge that keeps the code easier to read and avoids abstractions that do not buy much yet. In a larger enterprise codebase, those clients would likely become traits owned by the application layer, with HTTP clients as adapters, so providers could be swapped or mocked without depending on concrete implementations.
|
||||||
|
|
||||||
|
PokéAPI species data changes rarely, so production deployments would add a 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
170
openapi.yaml
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Pokedex API
|
||||||
|
version: 0.1.0
|
||||||
|
description: REST API for the TrueLayer Pokémon challenge.
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:5000
|
||||||
|
paths:
|
||||||
|
/health:
|
||||||
|
get:
|
||||||
|
summary: Health check
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Service is alive
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/HealthResponse"
|
||||||
|
/pokemon/{name}:
|
||||||
|
get:
|
||||||
|
summary: Return basic Pokémon information
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/PokemonName"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Pokémon information
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PokemonInfo"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/PokemonNotFound"
|
||||||
|
"502":
|
||||||
|
$ref: "#/components/responses/UpstreamFailure"
|
||||||
|
"429":
|
||||||
|
$ref: "#/components/responses/RateLimited"
|
||||||
|
/pokemon/translated/{name}:
|
||||||
|
get:
|
||||||
|
summary: Return Pokémon information with a fun translated description
|
||||||
|
description: Uses Yoda for legendary or cave Pokémon, Shakespeare otherwise. Falls back to the standard description when translation fails.
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/PokemonName"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Pokémon information with translated or fallback description
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PokemonInfo"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/PokemonNotFound"
|
||||||
|
"502":
|
||||||
|
$ref: "#/components/responses/UpstreamFailure"
|
||||||
|
"429":
|
||||||
|
$ref: "#/components/responses/RateLimited"
|
||||||
|
/metrics:
|
||||||
|
get:
|
||||||
|
summary: OpenTelemetry metrics in Prometheus text format
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Prometheus text exposition
|
||||||
|
content:
|
||||||
|
text/plain:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
components:
|
||||||
|
parameters:
|
||||||
|
PokemonName:
|
||||||
|
name: name
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: Pokémon identifier. Only ASCII letters, digits, and hyphens are accepted.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
pattern: "^[A-Za-z0-9-]+$"
|
||||||
|
example: mewtwo
|
||||||
|
responses:
|
||||||
|
BadRequest:
|
||||||
|
description: Invalid request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
example:
|
||||||
|
code: bad_request
|
||||||
|
message: pokemon name must contain only ASCII letters, digits, or hyphens
|
||||||
|
requestId: 7b2d6f7b-78ef-4cc3-85fd-5f09145fbd18
|
||||||
|
PokemonNotFound:
|
||||||
|
description: Pokémon was not found by PokéAPI
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
example:
|
||||||
|
code: pokemon_not_found
|
||||||
|
message: pokemon not found
|
||||||
|
requestId: 7b2d6f7b-78ef-4cc3-85fd-5f09145fbd18
|
||||||
|
UpstreamFailure:
|
||||||
|
description: An upstream provider failed or returned invalid data
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
examples:
|
||||||
|
unavailable:
|
||||||
|
value:
|
||||||
|
code: upstream_unavailable
|
||||||
|
message: upstream service failed
|
||||||
|
requestId: 7b2d6f7b-78ef-4cc3-85fd-5f09145fbd18
|
||||||
|
invalidData:
|
||||||
|
value:
|
||||||
|
code: upstream_invalid_data
|
||||||
|
message: upstream service returned invalid data
|
||||||
|
requestId: 7b2d6f7b-78ef-4cc3-85fd-5f09145fbd18
|
||||||
|
RateLimited:
|
||||||
|
description: Request was rate limited
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
example:
|
||||||
|
code: rate_limit_exceeded
|
||||||
|
message: rate limit exceeded
|
||||||
|
requestId: 7b2d6f7b-78ef-4cc3-85fd-5f09145fbd18
|
||||||
|
schemas:
|
||||||
|
HealthResponse:
|
||||||
|
type: object
|
||||||
|
required: [status]
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
const: ok
|
||||||
|
PokemonInfo:
|
||||||
|
type: object
|
||||||
|
required: [name, description, habitat, isLegendary]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
example: mewtwo
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
example: It was created by a scientist after years of horrific gene splicing and DNA engineering experiments.
|
||||||
|
habitat:
|
||||||
|
type: string
|
||||||
|
example: rare
|
||||||
|
isLegendary:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
ErrorResponse:
|
||||||
|
type: object
|
||||||
|
required: [code, message]
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- bad_request
|
||||||
|
- pokemon_not_found
|
||||||
|
- upstream_invalid_data
|
||||||
|
- upstream_unavailable
|
||||||
|
- rate_limit_exceeded
|
||||||
|
- internal_error
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
requestId:
|
||||||
|
type: string
|
||||||
|
description: Request correlation id from x-request-id.
|
||||||
@@ -66,6 +66,8 @@ impl PokedexService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_pokemon(&self, name: &str) -> Result<PokemonInfo, AppError> {
|
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 start = Instant::now();
|
||||||
let pokemon = self.pokemon.get_pokemon_info(name).await;
|
let pokemon = self.pokemon.get_pokemon_info(name).await;
|
||||||
if !matches!(pokemon, Err(AppError::BadRequest(_))) {
|
if !matches!(pokemon, Err(AppError::BadRequest(_))) {
|
||||||
|
|||||||
89
src/error.rs
89
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;
|
use serde::Serialize;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -29,20 +33,20 @@ impl AppError {
|
|||||||
Self::Internal => StatusCode::INTERNAL_SERVER_ERROR,
|
Self::Internal => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoResponse for AppError {
|
#[must_use]
|
||||||
fn into_response(self) -> axum::response::Response {
|
pub const fn code(&self) -> &'static str {
|
||||||
let status = self.status_code();
|
match self {
|
||||||
let body = ErrorBody {
|
Self::BadRequest(_) => "bad_request",
|
||||||
error: self.public_message(),
|
Self::NotFound => "pokemon_not_found",
|
||||||
};
|
Self::InvalidUpstreamData(_) => "upstream_invalid_data",
|
||||||
(status, Json(body)).into_response()
|
Self::Upstream(_) | Self::Timeout => "upstream_unavailable",
|
||||||
|
Self::Internal => "internal_error",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppError {
|
#[must_use]
|
||||||
fn public_message(&self) -> String {
|
pub 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(),
|
||||||
@@ -53,7 +57,64 @@ impl AppError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
impl IntoResponse for AppError {
|
||||||
struct ErrorBody {
|
fn into_response(self) -> axum::response::Response {
|
||||||
error: String,
|
ApiError::new(self, None).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ApiError {
|
||||||
|
source: AppError,
|
||||||
|
request_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiError {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(source: AppError, request_id: Option<String>) -> Self {
|
||||||
|
Self { source, request_id }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_headers(source: AppError, headers: &HeaderMap) -> Self {
|
||||||
|
Self::new(source, request_id_from_headers(headers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
let status = self.source.status_code();
|
||||||
|
let body = ErrorBody::new(
|
||||||
|
self.source.code(),
|
||||||
|
self.source.public_message(),
|
||||||
|
self.request_id,
|
||||||
|
);
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ErrorBody {
|
||||||
|
code: &'static str,
|
||||||
|
message: String,
|
||||||
|
#[serde(rename = "requestId", skip_serializing_if = "Option::is_none")]
|
||||||
|
request_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ErrorBody {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(code: &'static str, message: impl Into<String>, request_id: Option<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
code,
|
||||||
|
message: message.into(),
|
||||||
|
request_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn request_id_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||||
|
headers
|
||||||
|
.get("x-request-id")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(str::to_owned)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use super::AppState;
|
use super::AppState;
|
||||||
use crate::{domain::PokemonInfo, error::AppError};
|
use crate::{domain::PokemonInfo, error::ApiError};
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::header,
|
http::{HeaderMap, header},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -12,23 +12,41 @@ pub(super) async fn health() -> Json<HealthResponse> {
|
|||||||
Json(HealthResponse { status: "ok" })
|
Json(HealthResponse { status: "ok" })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn metrics(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
|
pub(super) async fn metrics(
|
||||||
let body = state.telemetry.render_prometheus()?;
|
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))
|
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>,
|
||||||
) -> Result<Json<PokemonInfo>, AppError> {
|
headers: HeaderMap,
|
||||||
state.service.get_basic(&name).await.map(Json)
|
) -> 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(
|
pub(super) async fn get_translated_pokemon(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
) -> Result<Json<PokemonInfo>, AppError> {
|
headers: HeaderMap,
|
||||||
state.service.get_translated(&name).await.map(Json)
|
) -> Result<Json<PokemonInfo>, ApiError> {
|
||||||
|
state
|
||||||
|
.service
|
||||||
|
.get_translated(&name)
|
||||||
|
.await
|
||||||
|
.map(Json)
|
||||||
|
.map_err(|error| ApiError::from_headers(error, &headers))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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},
|
||||||
@@ -17,9 +18,14 @@ pub(super) async fn enforce_rate_limit(
|
|||||||
if matches!(path, "/health" | "/metrics") || 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(serde_json::json!({ "error": "rate limit exceeded" })),
|
Json(ErrorBody::new(
|
||||||
|
"rate_limit_exceeded",
|
||||||
|
"rate limit exceeded",
|
||||||
|
request_id,
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
74
tests/app.rs
74
tests/app.rs
@@ -13,6 +13,12 @@ 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;
|
||||||
@@ -35,7 +41,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(&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 app = create_app(state_for(&pokeapi, &translations));
|
||||||
|
|
||||||
let response = app
|
let response = app
|
||||||
@@ -48,7 +54,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": "Created by science.",
|
"description": "It was created by a scientist after years of horrific gene splicing and DNA engineering experiments.",
|
||||||
"habitat": "rare",
|
"habitat": "rare",
|
||||||
"isLegendary": true
|
"isLegendary": true
|
||||||
})
|
})
|
||||||
@@ -67,23 +73,26 @@ 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!(
|
||||||
json_body(response).await["error"],
|
body["message"],
|
||||||
"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(&pokeapi, "pikachu", false, "forest", "Electric cheeks.").await;
|
mount_species_fixture(&pokeapi, "pikachu", POKEAPI_PIKACHU_SPECIES).await;
|
||||||
Mock::given(matchers::method("POST"))
|
Mock::given(matchers::method("POST"))
|
||||||
.and(matchers::path("/translate/shakespeare"))
|
.and(matchers::path("/translate/shakespeare"))
|
||||||
.and(matchers::body_string_contains("Electric cheeks"))
|
.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)
|
.expect(1)
|
||||||
.mount(&translations)
|
.mount(&translations)
|
||||||
.await;
|
.await;
|
||||||
@@ -110,13 +119,11 @@ async fn translated_endpoint_returns_translated_description() {
|
|||||||
async fn translated_endpoint_uses_yoda_for_legendary_pokemon() {
|
async fn translated_endpoint_uses_yoda_for_legendary_pokemon() {
|
||||||
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_fixture(&pokeapi, "mewtwo", POKEAPI_MEWTWO_SPECIES).await;
|
||||||
Mock::given(matchers::method("POST"))
|
Mock::given(matchers::method("POST"))
|
||||||
.and(matchers::path("/translate/yoda"))
|
.and(matchers::path("/translate/yoda"))
|
||||||
.and(matchers::body_string_contains("Created by science"))
|
.and(matchers::body_string_contains("horrific gene splicing"))
|
||||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
.respond_with(ResponseTemplate::new(200).set_body_string(FUNTRANSLATIONS_YODA_MEWTWO))
|
||||||
"contents": { "translated": "Created by science, it was." }
|
|
||||||
})))
|
|
||||||
.expect(1)
|
.expect(1)
|
||||||
.mount(&translations)
|
.mount(&translations)
|
||||||
.await;
|
.await;
|
||||||
@@ -291,7 +298,10 @@ async fn rate_limit_rejects_requests_over_the_configured_burst() {
|
|||||||
|
|
||||||
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);
|
||||||
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]
|
#[tokio::test]
|
||||||
@@ -344,12 +354,16 @@ async fn pokemon_endpoint_maps_missing_pokemon_to_404() {
|
|||||||
|
|
||||||
let response = app
|
let response = app
|
||||||
.clone()
|
.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!(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
|
let metrics = app
|
||||||
.oneshot(request("/metrics"))
|
.oneshot(request("/metrics"))
|
||||||
@@ -377,10 +391,9 @@ async fn pokemon_endpoint_maps_pokeapi_500_to_502() {
|
|||||||
.expect("request should be handled");
|
.expect("request should be handled");
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||||
assert_eq!(
|
let body = json_body(response).await;
|
||||||
json_body(response).await["error"],
|
assert_eq!(body["code"], "upstream_unavailable");
|
||||||
"upstream service failed"
|
assert_eq!(body["message"], "upstream service failed");
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -400,10 +413,9 @@ async fn pokemon_endpoint_maps_invalid_pokeapi_json_to_502() {
|
|||||||
.expect("request should be handled");
|
.expect("request should be handled");
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||||
assert_eq!(
|
let body = json_body(response).await;
|
||||||
json_body(response).await["error"],
|
assert_eq!(body["code"], "upstream_invalid_data");
|
||||||
"upstream service returned invalid data"
|
assert_eq!(body["message"], "upstream service returned invalid data");
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -430,6 +442,14 @@ async fn translated_endpoint_does_not_call_funtranslations_when_pokeapi_fails()
|
|||||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
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(
|
||||||
server: &MockServer,
|
server: &MockServer,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -472,6 +492,14 @@ 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")
|
||||||
|
|||||||
8
tests/fixtures/funtranslations_shakespeare_pikachu.json
vendored
Normal file
8
tests/fixtures/funtranslations_shakespeare_pikachu.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"success": { "total": 1 },
|
||||||
|
"contents": {
|
||||||
|
"translated": "Electric cheeks, prithee.",
|
||||||
|
"text": "Electric cheeks.",
|
||||||
|
"translation": "shakespeare"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
tests/fixtures/funtranslations_yoda_mewtwo.json
vendored
Normal file
8
tests/fixtures/funtranslations_yoda_mewtwo.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"success": { "total": 1 },
|
||||||
|
"contents": {
|
||||||
|
"translated": "Created by science, it was.",
|
||||||
|
"text": "Created by science.",
|
||||||
|
"translation": "yoda"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
tests/fixtures/pokeapi_mewtwo_species.json
vendored
Normal file
11
tests/fixtures/pokeapi_mewtwo_species.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "mewtwo",
|
||||||
|
"is_legendary": true,
|
||||||
|
"habitat": { "name": "rare" },
|
||||||
|
"flavor_text_entries": [
|
||||||
|
{
|
||||||
|
"flavor_text": "It was created by a scientist after years of horrific gene splicing and DNA engineering experiments.",
|
||||||
|
"language": { "name": "en" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
11
tests/fixtures/pokeapi_pikachu_species.json
vendored
Normal file
11
tests/fixtures/pokeapi_pikachu_species.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "pikachu",
|
||||||
|
"is_legendary": false,
|
||||||
|
"habitat": { "name": "forest" },
|
||||||
|
"flavor_text_entries": [
|
||||||
|
{
|
||||||
|
"flavor_text": "Electric cheeks.",
|
||||||
|
"language": { "name": "en" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user