feat: document api contract
This commit is contained in:
@@ -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(_))) {
|
||||
|
||||
91
src/error.rs
91
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user