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
|
||||
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
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
||||
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
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -100,4 +104,4 @@ The Dockerfile uses `cargo-chef` and BuildKit cache mounts for dependency and ta
|
||||
|
||||
## 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},
|
||||
domain::{PokemonInfo, TranslationKind},
|
||||
error::AppError,
|
||||
telemetry::AppMetrics,
|
||||
telemetry::{AppMetrics, UpstreamRequestOutcome},
|
||||
};
|
||||
use std::time::Instant;
|
||||
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)]
|
||||
pub struct PokedexService {
|
||||
pokemon: PokeApiClient,
|
||||
@@ -46,7 +49,11 @@ impl PokedexService {
|
||||
self.metrics.record_upstream_request(
|
||||
"funtranslations",
|
||||
translation_operation(translation_kind),
|
||||
translation.is_ok(),
|
||||
if translation.is_ok() {
|
||||
UpstreamRequestOutcome::Success
|
||||
} else {
|
||||
UpstreamRequestOutcome::Error
|
||||
},
|
||||
translation_start.elapsed(),
|
||||
);
|
||||
|
||||
@@ -61,16 +68,34 @@ impl PokedexService {
|
||||
async fn fetch_pokemon(&self, name: &str) -> Result<PokemonInfo, AppError> {
|
||||
let start = Instant::now();
|
||||
let pokemon = self.pokemon.get_pokemon_info(name).await;
|
||||
self.metrics.record_upstream_request(
|
||||
"pokeapi",
|
||||
"pokemon_species",
|
||||
pokemon.is_ok(),
|
||||
start.elapsed(),
|
||||
);
|
||||
if !matches!(pokemon, Err(AppError::BadRequest(_))) {
|
||||
self.metrics.record_upstream_request(
|
||||
"pokeapi",
|
||||
"pokemon_species",
|
||||
upstream_outcome_for_pokemon(&pokemon),
|
||||
start.elapsed(),
|
||||
);
|
||||
}
|
||||
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 {
|
||||
match kind {
|
||||
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 std::time::Duration;
|
||||
use tracing::instrument;
|
||||
@@ -40,11 +40,14 @@ impl TranslationClient {
|
||||
)));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<TranslationResponse>()
|
||||
.await
|
||||
.map(|payload| payload.contents.translated)
|
||||
.map_err(|error| map_reqwest_error(&error))
|
||||
let payload = body::read_json::<TranslationResponse>(response, "FunTranslations").await?;
|
||||
if payload.contents.translated.trim().is_empty() {
|
||||
Err(AppError::InvalidUpstreamData(
|
||||
"FunTranslations returned an empty translation".to_owned(),
|
||||
))
|
||||
} else {
|
||||
Ok(payload.contents.translated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +115,26 @@ mod tests {
|
||||
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]
|
||||
async fn maps_translation_http_failures_to_upstream_errors() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
mod body;
|
||||
|
||||
pub mod funtranslations;
|
||||
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 std::time::Duration;
|
||||
use tracing::instrument;
|
||||
@@ -44,12 +44,9 @@ impl PokeApiClient {
|
||||
)));
|
||||
}
|
||||
|
||||
let species = response
|
||||
.json::<PokemonSpeciesResponse>()
|
||||
body::read_json::<PokemonSpeciesResponse>(response, "PokéAPI")
|
||||
.await
|
||||
.map_err(|error| map_reqwest_error(&error))?;
|
||||
|
||||
species.try_into()
|
||||
.and_then(TryInto::try_into)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,6 +270,24 @@ mod tests {
|
||||
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 {
|
||||
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> {
|
||||
if per_second.is_sign_positive() && burst > 0 {
|
||||
if per_second.is_finite() && per_second > 0.0 && burst > 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConfigError::InvalidRateLimit)
|
||||
|
||||
@@ -13,7 +13,8 @@ pub(super) async fn enforce_rate_limit(
|
||||
req: Request,
|
||||
next: middleware::Next,
|
||||
) -> 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
|
||||
} else {
|
||||
(
|
||||
|
||||
@@ -66,7 +66,7 @@ pub fn create_app(state: AppState) -> Router {
|
||||
tracing::info_span!(
|
||||
"http.request",
|
||||
method = %request.method(),
|
||||
uri = %request.uri(),
|
||||
path = %request.uri().path(),
|
||||
request_id = %request_id,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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)]
|
||||
pub struct RateLimiter {
|
||||
bucket: Mutex<TokenBucket>,
|
||||
@@ -7,7 +10,7 @@ pub struct RateLimiter {
|
||||
|
||||
impl RateLimiter {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -69,6 +72,13 @@ mod tests {
|
||||
use super::RateLimiter;
|
||||
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]
|
||||
fn allows_requests_up_to_the_burst_capacity() {
|
||||
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());
|
||||
// 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));
|
||||
assert!(limiter.try_acquire());
|
||||
}
|
||||
|
||||
@@ -11,6 +11,27 @@ pub struct AppMetrics {
|
||||
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 {
|
||||
#[must_use]
|
||||
pub fn new(meter: &Meter) -> Self {
|
||||
@@ -68,10 +89,10 @@ impl AppMetrics {
|
||||
&self,
|
||||
service: &'static str,
|
||||
operation: &'static str,
|
||||
success: bool,
|
||||
outcome: UpstreamRequestOutcome,
|
||||
duration: Duration,
|
||||
) {
|
||||
let status = if success { "ok" } else { "error" };
|
||||
let status = outcome.label();
|
||||
let attributes = [
|
||||
KeyValue::new("service", service),
|
||||
KeyValue::new("operation", operation),
|
||||
@@ -80,7 +101,7 @@ impl AppMetrics {
|
||||
self.upstream_requests.add(1, &attributes);
|
||||
self.upstream_request_duration
|
||||
.record(duration.as_secs_f64(), &attributes);
|
||||
if !success {
|
||||
if outcome.is_error() {
|
||||
self.upstream_errors.add(1, &attributes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use opentelemetry::metrics::MeterProvider;
|
||||
use opentelemetry_prometheus_text_exporter::PrometheusExporter;
|
||||
use opentelemetry_sdk::{Resource, metrics::SdkMeterProvider};
|
||||
|
||||
pub use metrics::AppMetrics;
|
||||
pub use metrics::{AppMetrics, UpstreamRequestOutcome};
|
||||
pub use tracing::init_tracing;
|
||||
|
||||
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;
|
||||
Mock::given(matchers::method("POST"))
|
||||
.and(matchers::path("/translate/shakespeare.json"))
|
||||
.and(matchers::body_string_contains("Electric+cheeks"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"contents": { "translated": "Electric cheeks, prithee." }
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&translations)
|
||||
.await;
|
||||
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!(
|
||||
json_body(response).await["description"],
|
||||
"Electric cheeks, prithee."
|
||||
json_body(response).await,
|
||||
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");
|
||||
|
||||
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]
|
||||
@@ -180,6 +266,7 @@ async fn metrics_endpoint_exposes_http_and_upstream_otel_metrics() {
|
||||
async fn rate_limit_rejects_requests_over_the_configured_burst() {
|
||||
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(
|
||||
@@ -194,11 +281,11 @@ async fn rate_limit_rejects_requests_over_the_configured_burst() {
|
||||
|
||||
let first = app
|
||||
.clone()
|
||||
.oneshot(request("/health"))
|
||||
.oneshot(request("/pokemon/mewtwo"))
|
||||
.await
|
||||
.expect("request should be handled");
|
||||
let second = app
|
||||
.oneshot(request("/health"))
|
||||
.oneshot(request("/pokemon/mewtwo"))
|
||||
.await
|
||||
.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");
|
||||
}
|
||||
|
||||
#[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]
|
||||
async fn pokemon_endpoint_maps_missing_pokemon_to_404() {
|
||||
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 response = app
|
||||
.clone()
|
||||
.oneshot(request("/pokemon/nope"))
|
||||
.await
|
||||
.expect("request should be handled");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::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(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use pokedex_api::telemetry::Telemetry;
|
||||
use pokedex_api::telemetry::{Telemetry, UpstreamRequestOutcome};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
@@ -7,7 +7,12 @@ fn records_http_and_upstream_metrics_in_prometheus_text_format() {
|
||||
let metrics = telemetry.metrics();
|
||||
|
||||
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
|
||||
.render_prometheus()
|
||||
.expect("metrics should render as text");
|
||||
|
||||
Reference in New Issue
Block a user