fix: cover api edge cases
This commit is contained in:
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -25,6 +25,8 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||||
|
|
||||||
|
# Production hardening: add dependency and image scanning before publishing,
|
||||||
|
# for example cargo-audit plus a container scanner such as Sysdig or Trivy.
|
||||||
- name: Build multi-arch image
|
- name: Build multi-arch image
|
||||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ Example response:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Translation rules
|
||||||
|
|
||||||
|
`/pokemon/translated/{name}` applies Yoda when the Pokémon is legendary or its habitat is `cave`; otherwise it applies Shakespeare. If translation fails or returns an empty result, the API falls back to the standard PokéAPI description.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@@ -100,4 +104,4 @@ The Dockerfile uses `cargo-chef` and BuildKit cache mounts for dependency and ta
|
|||||||
|
|
||||||
## Production notes
|
## Production notes
|
||||||
|
|
||||||
For a production API I would add per-client or per-token rate limiting instead of one global bucket, retries with bounded exponential backoff, circuit breakers for upstream failures, response caching for PokéAPI data, stronger health checks that distinguish readiness from liveness, dashboards and alerts from the OTEL metrics, and a dedicated OTEL collector pipeline. I would also separate public API traffic from `/metrics`, define SLOs, and add contract tests against recorded upstream fixtures.
|
For a production API I would add per-client or per-token rate limiting instead of one global bucket, retries with bounded exponential backoff, circuit breakers for upstream failures, response caching for PokéAPI data, stronger health checks that distinguish readiness from liveness, dashboards and alerts from the OTEL metrics, and a dedicated OTEL collector pipeline. I would also keep `/metrics` private, add dependency/container scanning, define SLOs, and add contract tests against recorded upstream fixtures.
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ use crate::{
|
|||||||
clients::{funtranslations::TranslationClient, pokeapi::PokeApiClient},
|
clients::{funtranslations::TranslationClient, pokeapi::PokeApiClient},
|
||||||
domain::{PokemonInfo, TranslationKind},
|
domain::{PokemonInfo, TranslationKind},
|
||||||
error::AppError,
|
error::AppError,
|
||||||
telemetry::AppMetrics,
|
telemetry::{AppMetrics, UpstreamRequestOutcome},
|
||||||
};
|
};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tracing::{debug, instrument};
|
use tracing::{debug, instrument};
|
||||||
|
|
||||||
|
// The application service depends on concrete clients to keep this challenge
|
||||||
|
// concise. In a larger enterprise codebase these would become trait-based ports
|
||||||
|
// so adapters could be swapped without touching business logic.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct PokedexService {
|
pub struct PokedexService {
|
||||||
pokemon: PokeApiClient,
|
pokemon: PokeApiClient,
|
||||||
@@ -46,7 +49,11 @@ impl PokedexService {
|
|||||||
self.metrics.record_upstream_request(
|
self.metrics.record_upstream_request(
|
||||||
"funtranslations",
|
"funtranslations",
|
||||||
translation_operation(translation_kind),
|
translation_operation(translation_kind),
|
||||||
translation.is_ok(),
|
if translation.is_ok() {
|
||||||
|
UpstreamRequestOutcome::Success
|
||||||
|
} else {
|
||||||
|
UpstreamRequestOutcome::Error
|
||||||
|
},
|
||||||
translation_start.elapsed(),
|
translation_start.elapsed(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -61,16 +68,34 @@ impl PokedexService {
|
|||||||
async fn fetch_pokemon(&self, name: &str) -> Result<PokemonInfo, AppError> {
|
async fn fetch_pokemon(&self, name: &str) -> Result<PokemonInfo, AppError> {
|
||||||
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(_))) {
|
||||||
self.metrics.record_upstream_request(
|
self.metrics.record_upstream_request(
|
||||||
"pokeapi",
|
"pokeapi",
|
||||||
"pokemon_species",
|
"pokemon_species",
|
||||||
pokemon.is_ok(),
|
upstream_outcome_for_pokemon(&pokemon),
|
||||||
start.elapsed(),
|
start.elapsed(),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
pokemon
|
pokemon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fn upstream_outcome_for_pokemon(
|
||||||
|
result: &Result<PokemonInfo, AppError>,
|
||||||
|
) -> UpstreamRequestOutcome {
|
||||||
|
match result {
|
||||||
|
Ok(_) => UpstreamRequestOutcome::Success,
|
||||||
|
Err(AppError::NotFound) => UpstreamRequestOutcome::NotFound,
|
||||||
|
Err(
|
||||||
|
AppError::BadRequest(_)
|
||||||
|
| AppError::InvalidUpstreamData(_)
|
||||||
|
| AppError::Upstream(_)
|
||||||
|
| AppError::Timeout
|
||||||
|
| AppError::Internal,
|
||||||
|
) => UpstreamRequestOutcome::Error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fn translation_operation(kind: TranslationKind) -> &'static str {
|
const fn translation_operation(kind: TranslationKind) -> &'static str {
|
||||||
match kind {
|
match kind {
|
||||||
TranslationKind::Shakespeare => "shakespeare",
|
TranslationKind::Shakespeare => "shakespeare",
|
||||||
|
|||||||
51
src/clients/body.rs
Normal file
51
src/clients/body.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
use crate::error::AppError;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
|
||||||
|
const MAX_UPSTREAM_BODY_BYTES: usize = 1024 * 1024;
|
||||||
|
|
||||||
|
pub async fn read_json<T>(
|
||||||
|
mut response: reqwest::Response,
|
||||||
|
service: &'static str,
|
||||||
|
) -> Result<T, AppError>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
if response
|
||||||
|
.content_length()
|
||||||
|
.is_some_and(|length| length > MAX_UPSTREAM_BODY_BYTES as u64)
|
||||||
|
{
|
||||||
|
return Err(AppError::Upstream(format!(
|
||||||
|
"{service} response body exceeded {MAX_UPSTREAM_BODY_BYTES} bytes"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut body = Vec::new();
|
||||||
|
while let Some(chunk) = response
|
||||||
|
.chunk()
|
||||||
|
.await
|
||||||
|
.map_err(|error| map_reqwest_error(&error))?
|
||||||
|
{
|
||||||
|
let next_len = body
|
||||||
|
.len()
|
||||||
|
.checked_add(chunk.len())
|
||||||
|
.ok_or(AppError::Internal)?;
|
||||||
|
if next_len > MAX_UPSTREAM_BODY_BYTES {
|
||||||
|
return Err(AppError::Upstream(format!(
|
||||||
|
"{service} response body exceeded {MAX_UPSTREAM_BODY_BYTES} bytes"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
body.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::from_slice(&body).map_err(|error| {
|
||||||
|
AppError::InvalidUpstreamData(format!("{service} returned invalid JSON: {error}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_reqwest_error(error: &reqwest::Error) -> AppError {
|
||||||
|
if error.is_timeout() {
|
||||||
|
AppError::Timeout
|
||||||
|
} else {
|
||||||
|
AppError::Upstream(error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::{domain::TranslationKind, error::AppError};
|
use crate::{clients::body, domain::TranslationKind, error::AppError};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
@@ -40,11 +40,14 @@ impl TranslationClient {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
response
|
let payload = body::read_json::<TranslationResponse>(response, "FunTranslations").await?;
|
||||||
.json::<TranslationResponse>()
|
if payload.contents.translated.trim().is_empty() {
|
||||||
.await
|
Err(AppError::InvalidUpstreamData(
|
||||||
.map(|payload| payload.contents.translated)
|
"FunTranslations returned an empty translation".to_owned(),
|
||||||
.map_err(|error| map_reqwest_error(&error))
|
))
|
||||||
|
} else {
|
||||||
|
Ok(payload.contents.translated)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +115,26 @@ mod tests {
|
|||||||
assert_eq!(translated, "Created, it was.");
|
assert_eq!(translated, "Created, it was.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_empty_translations_so_callers_can_fallback() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(matchers::method("POST"))
|
||||||
|
.and(matchers::path("/translate/yoda.json"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||||
|
"contents": { "translated": " " }
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
let client = client_for(&server);
|
||||||
|
|
||||||
|
let error = client
|
||||||
|
.translate(TranslationKind::Yoda, "hello")
|
||||||
|
.await
|
||||||
|
.expect_err("empty translation should fail");
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("empty translation"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn maps_translation_http_failures_to_upstream_errors() {
|
async fn maps_translation_http_failures_to_upstream_errors() {
|
||||||
let server = MockServer::start().await;
|
let server = MockServer::start().await;
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
|
mod body;
|
||||||
|
|
||||||
pub mod funtranslations;
|
pub mod funtranslations;
|
||||||
pub mod pokeapi;
|
pub mod pokeapi;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::{domain::PokemonInfo, error::AppError};
|
use crate::{clients::body, domain::PokemonInfo, error::AppError};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
@@ -44,12 +44,9 @@ impl PokeApiClient {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let species = response
|
body::read_json::<PokemonSpeciesResponse>(response, "PokéAPI")
|
||||||
.json::<PokemonSpeciesResponse>()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|error| map_reqwest_error(&error))?;
|
.and_then(TryInto::try_into)
|
||||||
|
|
||||||
species.try_into()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,6 +270,24 @@ mod tests {
|
|||||||
assert!(matches!(error, AppError::InvalidUpstreamData(_)));
|
assert!(matches!(error, AppError::InvalidUpstreamData(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_oversized_upstream_bodies() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(matchers::method("GET"))
|
||||||
|
.and(matchers::path("/pokemon-species/snorlax"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_string("x".repeat(1025 * 1024)))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
let client = client_for(&server);
|
||||||
|
|
||||||
|
let error = client
|
||||||
|
.get_pokemon_info("snorlax")
|
||||||
|
.await
|
||||||
|
.expect_err("oversized body should fail");
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("exceeded"));
|
||||||
|
}
|
||||||
|
|
||||||
fn client_for(server: &MockServer) -> PokeApiClient {
|
fn client_for(server: &MockServer) -> PokeApiClient {
|
||||||
PokeApiClient::new(server.uri(), Duration::from_secs(1)).expect("client should build")
|
PokeApiClient::new(server.uri(), Duration::from_secs(1)).expect("client should build")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ fn default_bind_addr() -> SocketAddr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fn validate_rate_limit(per_second: f64, burst: u32) -> Result<(), ConfigError> {
|
const fn validate_rate_limit(per_second: f64, burst: u32) -> Result<(), ConfigError> {
|
||||||
if per_second.is_sign_positive() && burst > 0 {
|
if per_second.is_finite() && per_second > 0.0 && burst > 0 {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(ConfigError::InvalidRateLimit)
|
Err(ConfigError::InvalidRateLimit)
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ pub(super) async fn enforce_rate_limit(
|
|||||||
req: Request,
|
req: Request,
|
||||||
next: middleware::Next,
|
next: middleware::Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
if state.rate_limiter.try_acquire() {
|
let path = req.uri().path();
|
||||||
|
if matches!(path, "/health" | "/metrics") || state.rate_limiter.try_acquire() {
|
||||||
next.run(req).await
|
next.run(req).await
|
||||||
} else {
|
} else {
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ pub fn create_app(state: AppState) -> Router {
|
|||||||
tracing::info_span!(
|
tracing::info_span!(
|
||||||
"http.request",
|
"http.request",
|
||||||
method = %request.method(),
|
method = %request.method(),
|
||||||
uri = %request.uri(),
|
path = %request.uri().path(),
|
||||||
request_id = %request_id,
|
request_id = %request_id,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
use std::{sync::Mutex, time::Instant};
|
use std::{sync::Mutex, time::Instant};
|
||||||
|
|
||||||
|
// This challenge uses a simple in-memory global limiter. In production,
|
||||||
|
// prefer API gateway rate limiting so limits are shared across instances and
|
||||||
|
// identity-aware policies can be keyed by API key, user, or client IP.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct RateLimiter {
|
pub struct RateLimiter {
|
||||||
bucket: Mutex<TokenBucket>,
|
bucket: Mutex<TokenBucket>,
|
||||||
@@ -7,7 +10,7 @@ pub struct RateLimiter {
|
|||||||
|
|
||||||
impl RateLimiter {
|
impl RateLimiter {
|
||||||
pub fn new(refill_per_second: f64, burst: u32) -> Result<Self, RateLimitError> {
|
pub fn new(refill_per_second: f64, burst: u32) -> Result<Self, RateLimitError> {
|
||||||
if !refill_per_second.is_sign_positive() || burst == 0 {
|
if !refill_per_second.is_finite() || refill_per_second <= 0.0 || burst == 0 {
|
||||||
return Err(RateLimitError::InvalidConfiguration);
|
return Err(RateLimitError::InvalidConfiguration);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +72,13 @@ mod tests {
|
|||||||
use super::RateLimiter;
|
use super::RateLimiter;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_positive_or_non_finite_configuration() {
|
||||||
|
assert!(RateLimiter::new(0.0, 1).is_err());
|
||||||
|
assert!(RateLimiter::new(f64::INFINITY, 1).is_err());
|
||||||
|
assert!(RateLimiter::new(1.0, 0).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn allows_requests_up_to_the_burst_capacity() {
|
fn allows_requests_up_to_the_burst_capacity() {
|
||||||
let limiter = RateLimiter::new(100.0, 2).expect("rate limiter should build");
|
let limiter = RateLimiter::new(100.0, 2).expect("rate limiter should build");
|
||||||
@@ -84,6 +94,8 @@ mod tests {
|
|||||||
|
|
||||||
assert!(limiter.try_acquire());
|
assert!(limiter.try_acquire());
|
||||||
assert!(!limiter.try_acquire());
|
assert!(!limiter.try_acquire());
|
||||||
|
// A production-grade limiter would inject a clock; a short sleep keeps
|
||||||
|
// this challenge implementation simple without coupling tests to internals.
|
||||||
std::thread::sleep(Duration::from_millis(20));
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
assert!(limiter.try_acquire());
|
assert!(limiter.try_acquire());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,27 @@ pub struct AppMetrics {
|
|||||||
upstream_request_duration: opentelemetry::metrics::Histogram<f64>,
|
upstream_request_duration: opentelemetry::metrics::Histogram<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum UpstreamRequestOutcome {
|
||||||
|
Success,
|
||||||
|
NotFound,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpstreamRequestOutcome {
|
||||||
|
const fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Success => "ok",
|
||||||
|
Self::NotFound => "not_found",
|
||||||
|
Self::Error => "error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn is_error(self) -> bool {
|
||||||
|
matches!(self, Self::Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl AppMetrics {
|
impl AppMetrics {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(meter: &Meter) -> Self {
|
pub fn new(meter: &Meter) -> Self {
|
||||||
@@ -68,10 +89,10 @@ impl AppMetrics {
|
|||||||
&self,
|
&self,
|
||||||
service: &'static str,
|
service: &'static str,
|
||||||
operation: &'static str,
|
operation: &'static str,
|
||||||
success: bool,
|
outcome: UpstreamRequestOutcome,
|
||||||
duration: Duration,
|
duration: Duration,
|
||||||
) {
|
) {
|
||||||
let status = if success { "ok" } else { "error" };
|
let status = outcome.label();
|
||||||
let attributes = [
|
let attributes = [
|
||||||
KeyValue::new("service", service),
|
KeyValue::new("service", service),
|
||||||
KeyValue::new("operation", operation),
|
KeyValue::new("operation", operation),
|
||||||
@@ -80,7 +101,7 @@ impl AppMetrics {
|
|||||||
self.upstream_requests.add(1, &attributes);
|
self.upstream_requests.add(1, &attributes);
|
||||||
self.upstream_request_duration
|
self.upstream_request_duration
|
||||||
.record(duration.as_secs_f64(), &attributes);
|
.record(duration.as_secs_f64(), &attributes);
|
||||||
if !success {
|
if outcome.is_error() {
|
||||||
self.upstream_errors.add(1, &attributes);
|
self.upstream_errors.add(1, &attributes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use opentelemetry::metrics::MeterProvider;
|
|||||||
use opentelemetry_prometheus_text_exporter::PrometheusExporter;
|
use opentelemetry_prometheus_text_exporter::PrometheusExporter;
|
||||||
use opentelemetry_sdk::{Resource, metrics::SdkMeterProvider};
|
use opentelemetry_sdk::{Resource, metrics::SdkMeterProvider};
|
||||||
|
|
||||||
pub use metrics::AppMetrics;
|
pub use metrics::{AppMetrics, UpstreamRequestOutcome};
|
||||||
pub use tracing::init_tracing;
|
pub use tracing::init_tracing;
|
||||||
|
|
||||||
pub struct Telemetry {
|
pub struct Telemetry {
|
||||||
|
|||||||
213
tests/app.rs
213
tests/app.rs
@@ -80,9 +80,11 @@ async fn translated_endpoint_returns_translated_description() {
|
|||||||
mount_species(&pokeapi, "pikachu", false, "forest", "Electric cheeks.").await;
|
mount_species(&pokeapi, "pikachu", false, "forest", "Electric cheeks.").await;
|
||||||
Mock::given(matchers::method("POST"))
|
Mock::given(matchers::method("POST"))
|
||||||
.and(matchers::path("/translate/shakespeare.json"))
|
.and(matchers::path("/translate/shakespeare.json"))
|
||||||
|
.and(matchers::body_string_contains("Electric+cheeks"))
|
||||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||||
"contents": { "translated": "Electric cheeks, prithee." }
|
"contents": { "translated": "Electric cheeks, prithee." }
|
||||||
})))
|
})))
|
||||||
|
.expect(1)
|
||||||
.mount(&translations)
|
.mount(&translations)
|
||||||
.await;
|
.await;
|
||||||
let app = create_app(state_for(&pokeapi, &translations));
|
let app = create_app(state_for(&pokeapi, &translations));
|
||||||
@@ -94,8 +96,52 @@ async fn translated_endpoint_returns_translated_description() {
|
|||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
json_body(response).await["description"],
|
json_body(response).await,
|
||||||
"Electric cheeks, prithee."
|
serde_json::json!({
|
||||||
|
"name": "pikachu",
|
||||||
|
"description": "Electric cheeks, prithee.",
|
||||||
|
"habitat": "forest",
|
||||||
|
"isLegendary": false
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn translated_endpoint_uses_yoda_for_legendary_pokemon() {
|
||||||
|
let pokeapi = MockServer::start().await;
|
||||||
|
let translations = MockServer::start().await;
|
||||||
|
mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").await;
|
||||||
|
Mock::given(matchers::method("POST"))
|
||||||
|
.and(matchers::path("/translate/yoda.json"))
|
||||||
|
.and(matchers::body_string_contains("Created+by+science"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||||
|
"contents": { "translated": "Created by science, it was." }
|
||||||
|
})))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&translations)
|
||||||
|
.await;
|
||||||
|
Mock::given(matchers::method("POST"))
|
||||||
|
.and(matchers::path("/translate/shakespeare.json"))
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.expect(0)
|
||||||
|
.mount(&translations)
|
||||||
|
.await;
|
||||||
|
let app = create_app(state_for(&pokeapi, &translations));
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(request("/pokemon/translated/mewtwo"))
|
||||||
|
.await
|
||||||
|
.expect("request should be handled");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
json_body(response).await,
|
||||||
|
serde_json::json!({
|
||||||
|
"name": "mewtwo",
|
||||||
|
"description": "Created by science, it was.",
|
||||||
|
"habitat": "rare",
|
||||||
|
"isLegendary": true
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +163,47 @@ async fn translated_endpoint_falls_back_to_original_description_when_translation
|
|||||||
.expect("request should be handled");
|
.expect("request should be handled");
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
assert_eq!(json_body(response).await["description"], "It has no eyes.");
|
assert_eq!(
|
||||||
|
json_body(response).await,
|
||||||
|
serde_json::json!({
|
||||||
|
"name": "zubat",
|
||||||
|
"description": "It has no eyes.",
|
||||||
|
"habitat": "cave",
|
||||||
|
"isLegendary": false
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn translated_endpoint_falls_back_when_translation_is_empty() {
|
||||||
|
let pokeapi = MockServer::start().await;
|
||||||
|
let translations = MockServer::start().await;
|
||||||
|
mount_species(&pokeapi, "zubat", false, "cave", "It has no eyes.").await;
|
||||||
|
Mock::given(matchers::method("POST"))
|
||||||
|
.and(matchers::path("/translate/yoda.json"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||||
|
"contents": { "translated": " " }
|
||||||
|
})))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&translations)
|
||||||
|
.await;
|
||||||
|
let app = create_app(state_for(&pokeapi, &translations));
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(request("/pokemon/translated/zubat"))
|
||||||
|
.await
|
||||||
|
.expect("request should be handled");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
json_body(response).await,
|
||||||
|
serde_json::json!({
|
||||||
|
"name": "zubat",
|
||||||
|
"description": "It has no eyes.",
|
||||||
|
"habitat": "cave",
|
||||||
|
"isLegendary": false
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -180,6 +266,7 @@ async fn metrics_endpoint_exposes_http_and_upstream_otel_metrics() {
|
|||||||
async fn rate_limit_rejects_requests_over_the_configured_burst() {
|
async fn rate_limit_rejects_requests_over_the_configured_burst() {
|
||||||
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;
|
||||||
let telemetry = Telemetry::new("test-service");
|
let telemetry = Telemetry::new("test-service");
|
||||||
let app = create_app(AppState::new(
|
let app = create_app(AppState::new(
|
||||||
PokedexService::new(
|
PokedexService::new(
|
||||||
@@ -194,11 +281,11 @@ async fn rate_limit_rejects_requests_over_the_configured_burst() {
|
|||||||
|
|
||||||
let first = app
|
let first = app
|
||||||
.clone()
|
.clone()
|
||||||
.oneshot(request("/health"))
|
.oneshot(request("/pokemon/mewtwo"))
|
||||||
.await
|
.await
|
||||||
.expect("request should be handled");
|
.expect("request should be handled");
|
||||||
let second = app
|
let second = app
|
||||||
.oneshot(request("/health"))
|
.oneshot(request("/pokemon/mewtwo"))
|
||||||
.await
|
.await
|
||||||
.expect("request should be handled");
|
.expect("request should be handled");
|
||||||
|
|
||||||
@@ -207,6 +294,43 @@ async fn rate_limit_rejects_requests_over_the_configured_burst() {
|
|||||||
assert_eq!(json_body(second).await["error"], "rate limit exceeded");
|
assert_eq!(json_body(second).await["error"], "rate limit exceeded");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn health_and_metrics_are_not_rate_limited() {
|
||||||
|
let pokeapi = MockServer::start().await;
|
||||||
|
let translations = MockServer::start().await;
|
||||||
|
mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").await;
|
||||||
|
let telemetry = Telemetry::new("test-service");
|
||||||
|
let app = create_app(AppState::new(
|
||||||
|
PokedexService::new(
|
||||||
|
PokeApiClient::new(pokeapi.uri(), Duration::from_secs(1)).expect("client should build"),
|
||||||
|
TranslationClient::new(translations.uri(), Duration::from_secs(1))
|
||||||
|
.expect("client should build"),
|
||||||
|
telemetry.metrics(),
|
||||||
|
),
|
||||||
|
telemetry,
|
||||||
|
RateLimiter::new(0.01, 1).expect("rate limiter should build"),
|
||||||
|
));
|
||||||
|
|
||||||
|
let pokemon = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(request("/pokemon/mewtwo"))
|
||||||
|
.await
|
||||||
|
.expect("request should be handled");
|
||||||
|
let health = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(request("/health"))
|
||||||
|
.await
|
||||||
|
.expect("request should be handled");
|
||||||
|
let metrics = app
|
||||||
|
.oneshot(request("/metrics"))
|
||||||
|
.await
|
||||||
|
.expect("request should be handled");
|
||||||
|
|
||||||
|
assert_eq!(pokemon.status(), StatusCode::OK);
|
||||||
|
assert_eq!(health.status(), StatusCode::OK);
|
||||||
|
assert_eq!(metrics.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pokemon_endpoint_maps_missing_pokemon_to_404() {
|
async fn pokemon_endpoint_maps_missing_pokemon_to_404() {
|
||||||
let pokeapi = MockServer::start().await;
|
let pokeapi = MockServer::start().await;
|
||||||
@@ -219,12 +343,91 @@ async fn pokemon_endpoint_maps_missing_pokemon_to_404() {
|
|||||||
let app = create_app(state_for(&pokeapi, &translations));
|
let app = create_app(state_for(&pokeapi, &translations));
|
||||||
|
|
||||||
let response = app
|
let response = app
|
||||||
|
.clone()
|
||||||
.oneshot(request("/pokemon/nope"))
|
.oneshot(request("/pokemon/nope"))
|
||||||
.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!(json_body(response).await["error"], "pokemon not found");
|
||||||
|
|
||||||
|
let metrics = app
|
||||||
|
.oneshot(request("/metrics"))
|
||||||
|
.await
|
||||||
|
.expect("request should be handled");
|
||||||
|
let body = text_body(metrics).await;
|
||||||
|
assert!(body.contains("status=\"not_found\""));
|
||||||
|
assert!(!body.contains("upstream_client_errors"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pokemon_endpoint_maps_pokeapi_500_to_502() {
|
||||||
|
let pokeapi = MockServer::start().await;
|
||||||
|
let translations = MockServer::start().await;
|
||||||
|
Mock::given(matchers::method("GET"))
|
||||||
|
.and(matchers::path("/pokemon-species/mewtwo"))
|
||||||
|
.respond_with(ResponseTemplate::new(500))
|
||||||
|
.mount(&pokeapi)
|
||||||
|
.await;
|
||||||
|
let app = create_app(state_for(&pokeapi, &translations));
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(request("/pokemon/mewtwo"))
|
||||||
|
.await
|
||||||
|
.expect("request should be handled");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||||
|
assert_eq!(
|
||||||
|
json_body(response).await["error"],
|
||||||
|
"upstream service failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pokemon_endpoint_maps_invalid_pokeapi_json_to_502() {
|
||||||
|
let pokeapi = MockServer::start().await;
|
||||||
|
let translations = MockServer::start().await;
|
||||||
|
Mock::given(matchers::method("GET"))
|
||||||
|
.and(matchers::path("/pokemon-species/mewtwo"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_string("not-json"))
|
||||||
|
.mount(&pokeapi)
|
||||||
|
.await;
|
||||||
|
let app = create_app(state_for(&pokeapi, &translations));
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(request("/pokemon/mewtwo"))
|
||||||
|
.await
|
||||||
|
.expect("request should be handled");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||||
|
assert_eq!(
|
||||||
|
json_body(response).await["error"],
|
||||||
|
"upstream service returned invalid data"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn translated_endpoint_does_not_call_funtranslations_when_pokeapi_fails() {
|
||||||
|
let pokeapi = MockServer::start().await;
|
||||||
|
let translations = MockServer::start().await;
|
||||||
|
Mock::given(matchers::method("GET"))
|
||||||
|
.and(matchers::path("/pokemon-species/mewtwo"))
|
||||||
|
.respond_with(ResponseTemplate::new(500))
|
||||||
|
.mount(&pokeapi)
|
||||||
|
.await;
|
||||||
|
Mock::given(matchers::method("POST"))
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.expect(0)
|
||||||
|
.mount(&translations)
|
||||||
|
.await;
|
||||||
|
let app = create_app(state_for(&pokeapi, &translations));
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(request("/pokemon/translated/mewtwo"))
|
||||||
|
.await
|
||||||
|
.expect("request should be handled");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn mount_species(
|
async fn mount_species(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use pokedex_api::telemetry::Telemetry;
|
use pokedex_api::telemetry::{Telemetry, UpstreamRequestOutcome};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -7,7 +7,12 @@ fn records_http_and_upstream_metrics_in_prometheus_text_format() {
|
|||||||
let metrics = telemetry.metrics();
|
let metrics = telemetry.metrics();
|
||||||
|
|
||||||
metrics.record_http_request("GET", "/pokemon/{name}", 200, Duration::from_millis(10));
|
metrics.record_http_request("GET", "/pokemon/{name}", 200, Duration::from_millis(10));
|
||||||
metrics.record_upstream_request("pokeapi", "pokemon_species", true, Duration::from_millis(5));
|
metrics.record_upstream_request(
|
||||||
|
"pokeapi",
|
||||||
|
"pokemon_species",
|
||||||
|
UpstreamRequestOutcome::Success,
|
||||||
|
Duration::from_millis(5),
|
||||||
|
);
|
||||||
let rendered = telemetry
|
let rendered = telemetry
|
||||||
.render_prometheus()
|
.render_prometheus()
|
||||||
.expect("metrics should render as text");
|
.expect("metrics should render as text");
|
||||||
|
|||||||
Reference in New Issue
Block a user