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.
|
||||
|
||||
## 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
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> {
|
||||
// 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(_))) {
|
||||
|
||||
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;
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
74
tests/app.rs
74
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<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")
|
||||
|
||||
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