feat: document api contract

This commit is contained in:
2026-06-13 11:29:23 +02:00
parent b910e77460
commit cf11e20f90
12 changed files with 417 additions and 47 deletions

View File

@@ -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(_))) {

View File

@@ -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)
}

View File

@@ -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)]

View File

@@ -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()
}