axum으로 HTTP 서비스 만들기 문서의 구현 코드다.
//! axum 0.8 서비스 하나: 라우터, 추출기(Path·Query·Json·State), Arc 공유 상태,
//! IntoResponse 에러 타입(내부 에러는 로그로만), TraceLayer·TimeoutLayer, TraceLayer가 남기는 로그,
//! oneshot 테스트, 진짜 소켓 요청과 안전한 종료.
//! 의존성: axum = "0.8", tokio = { version = "1", features = ["full"] }, tower = { version = "0.5", features = ["util"] },
//! tower-http = { version = "0.7", features = ["trace", "timeout"] }, serde = { version = "1", features = ["derive"] },
//! serde_json = "1", thiserror = "2", anyhow = "1", tracing = "0.1",
//! tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
//! 실행: python3 "_시스템/Rust 계획/rs_check.py" "Rust/3.개념집/45_axum-http_impl.rs"
//! 진짜 서버로 띄우려면 main의 주소를 "0.0.0.0:3000"으로 바꾸고, 종료 신호를 tokio::signal::ctrl_c()로 바꾼다.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use anyhow::Context;
use axum::body::{Body, to_bytes};
use axum::extract::{Path, Query, State};
use axum::http::{Request, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::RwLock;
use tower::ServiceExt;
use tower_http::timeout::TimeoutLayer;
use tower_http::trace::TraceLayer;
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
struct Item {
id: u64,
name: String,
}
#[derive(Deserialize)]
struct NewItem {
name: String,
}
#[derive(Deserialize)]
struct ListParams {
limit: Option<usize>,
}
/// 모든 요청이 함께 보는 상태. Router가 요청마다 clone하므로 Arc 하나만 복사된다.
#[derive(Default)]
struct AppState {
items: RwLock<HashMap<u64, Item>>,
next_id: AtomicU64,
}
type Shared = Arc<AppState>;
#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("item {0} not found")]
NotFound(u64),
#[error("name must not be empty")]
EmptyName,
/// 사용자 잘못이 아닌 실패. 자세한 원인은 로그에만 남기고 응답에는 싣지 않는다.
#[error(transparent)]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, msg) = match &self {
AppError::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
AppError::EmptyName => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
AppError::Internal(e) => {
tracing::error!(error = format!("{e:#}"), "internal error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
};
(status, Json(serde_json::json!({ "error": msg }))).into_response()
}
}
async fn health() -> &'static str {
"ok"
}
async fn create_item(
State(state): State<Shared>,
Json(input): Json<NewItem>, // 본문을 읽는 추출기는 마지막 인자
) -> Result<(StatusCode, Json<Item>), AppError> {
if input.name.trim().is_empty() {
return Err(AppError::EmptyName);
}
let id = state.next_id.fetch_add(1, Ordering::Relaxed) + 1;
let item = Item {
id,
name: input.name,
};
state.items.write().await.insert(id, item.clone());
Ok((StatusCode::CREATED, Json(item)))
}
async fn get_item(
State(state): State<Shared>,
Path(id): Path<u64>,
) -> Result<Json<Item>, AppError> {
let items = state.items.read().await;
items
.get(&id)
.cloned()
.map(Json)
.ok_or(AppError::NotFound(id))
}
async fn list_items(
State(state): State<Shared>,
Query(params): Query<ListParams>,
) -> Json<Vec<Item>> {
let items = state.items.read().await;
let mut all: Vec<Item> = items.values().cloned().collect();
all.sort_by_key(|i| i.id);
all.truncate(params.limit.unwrap_or(usize::MAX));
Json(all)
}
/// 항목을 파일로 내보낸다. 일부러 없는 폴더에 써서 내부 에러를 낸다.
async fn export_item(State(state): State<Shared>, Path(id): Path<u64>) -> Result<String, AppError> {
let name = {
let items = state.items.read().await;
items.get(&id).ok_or(AppError::NotFound(id))?.name.clone()
};
let path = format!("/no-such-dir-vault/{id}.txt");
tokio::fs::write(&path, name)
.await
.with_context(|| format!("writing {path}"))?; // anyhow::Error -> AppError::Internal
Ok(path)
}
async fn slow() -> &'static str {
tokio::time::sleep(Duration::from_secs(5)).await;
"too late"
}
fn app(state: Shared) -> Router {
Router::new()
.route("/health", get(health))
.route("/items", get(list_items).post(create_item))
.route("/items/{id}", get(get_item))
.route("/items/{id}/export", get(export_item))
.route("/slow", get(slow))
.layer(TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
Duration::from_millis(100),
))
.layer(TraceLayer::new_for_http())
.with_state(state)
}
async fn call(app: &Router, req: Request<Body>) -> (StatusCode, String) {
let res = app.clone().oneshot(req).await.expect("Router never fails");
let status = res.status();
let bytes = to_bytes(res.into_body(), 1 << 20).await.expect("body");
(status, String::from_utf8(bytes.to_vec()).expect("utf8"))
}
fn get_req(uri: &str) -> Request<Body> {
Request::get(uri).body(Body::empty()).expect("request")
}
fn post_json(uri: &str, body: &str) -> Request<Body> {
Request::post(uri)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_owned()))
.expect("request")
}
/// oneshot으로 라우터를 시험하고 (조건, 설명) 목록을 돌려준다.
async fn router_checks() -> Vec<(bool, String)> {
let app = app(Shared::default());
let mut out = Vec::new();
let r = call(&app, get_req("/health")).await;
out.push((
r == (StatusCode::OK, "ok".into()),
format!("GET /health -> {r:?}"),
));
let r = call(&app, post_json("/items", r#"{"name":"pen"}"#)).await;
out.push((
r == (StatusCode::CREATED, r#"{"id":1,"name":"pen"}"#.into()),
format!("POST /items -> {r:?}"),
));
call(&app, post_json("/items", r#"{"name":"cup"}"#)).await;
let r = call(&app, get_req("/items/2")).await;
out.push((
r == (StatusCode::OK, r#"{"id":2,"name":"cup"}"#.into()),
format!("GET /items/2 -> {r:?}"),
));
let r = call(&app, get_req("/items/9")).await;
out.push((
r == (
StatusCode::NOT_FOUND,
r#"{"error":"item 9 not found"}"#.into(),
),
format!("GET /items/9 -> {r:?}"),
));
let r = call(&app, get_req("/items?limit=1")).await;
out.push((
r == (StatusCode::OK, r#"[{"id":1,"name":"pen"}]"#.into()),
format!("GET /items?limit=1 -> {r:?}"),
));
let r = call(&app, post_json("/items", r#"{"name":" "}"#)).await;
out.push((
r.0 == StatusCode::UNPROCESSABLE_ENTITY,
format!("빈 이름 -> {r:?}"),
));
// 추출기 거부: 핸들러는 불리지 않고 axum이 바로 응답한다.
let r = call(&app, get_req("/items/abc")).await;
out.push((
r.0 == StatusCode::BAD_REQUEST,
format!("Path 파싱 실패 -> {}", r.0),
));
let r = call(&app, post_json("/items", r#"{"name":"#)).await;
out.push((
r.0 == StatusCode::BAD_REQUEST,
format!("JSON 문법 오류 -> {}", r.0),
));
let r = call(&app, post_json("/items", r#"{"title":"x"}"#)).await;
out.push((
r.0 == StatusCode::UNPROCESSABLE_ENTITY,
format!("필드 누락 -> {}", r.0),
));
let no_ct = Request::post("/items")
.body(Body::from(r#"{"name":"x"}"#))
.expect("request");
let r = call(&app, no_ct).await;
out.push((
r.0 == StatusCode::UNSUPPORTED_MEDIA_TYPE,
format!("Content-Type 없음 -> {}", r.0),
));
let r = call(
&app,
Request::delete("/items/1")
.body(Body::empty())
.expect("request"),
)
.await;
out.push((
r.0 == StatusCode::METHOD_NOT_ALLOWED,
format!("DELETE /items/1 -> {}", r.0),
));
let r = call(&app, get_req("/nope")).await;
out.push((
r.0 == StatusCode::NOT_FOUND,
format!("없는 경로 -> {}", r.0),
));
let r = call(&app, get_req("/items/1/export")).await;
out.push((
r == (
StatusCode::INTERNAL_SERVER_ERROR,
r#"{"error":"internal error"}"#.into(),
),
format!("내부 에러 -> {r:?} (경로·OS 오류는 응답에 없다)"),
));
let r = call(&app, get_req("/slow")).await;
out.push((
r.0 == StatusCode::REQUEST_TIMEOUT,
format!("GET /slow (100ms 제한) -> {}", r.0),
));
out
}
/// 로그를 메모리에 모으는 writer.
#[derive(Clone, Default)]
struct LogBuf(Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for LogBuf {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
self.0.lock().expect("lock").extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
/// TraceLayer와 내부 에러가 남기는 로그를 JSON으로 붙잡아 본다.
/// oneshot은 지금 태스크 안에서 돌므로, 이 스레드의 기본 구독자(set_default)가 그대로 받는다.
async fn logging_checks() -> Vec<(bool, String)> {
let buf = LogBuf::default();
let w = buf.clone();
let sub = tracing_subscriber::fmt()
.json()
.with_writer(move || w.clone())
.with_env_filter(tracing_subscriber::EnvFilter::new("debug"))
.finish();
let guard = tracing::subscriber::set_default(sub);
let app = app(Shared::default());
call(&app, post_json("/items", r#"{"name":"pen"}"#)).await;
call(&app, get_req("/items/1/export")).await;
drop(guard);
let text = String::from_utf8(buf.0.lock().expect("lock").clone()).expect("utf8");
let lines: Vec<serde_json::Value> = text
.lines()
.map(|l| serde_json::from_str(l).expect("JSON 한 줄"))
.collect();
let export: Vec<&serde_json::Value> = lines
.iter()
.filter(|l| l["span"]["uri"] == "/items/1/export")
.collect();
for l in &export {
println!(
" {} {} | {} | span={}",
l["level"].as_str().unwrap_or(""),
l["target"].as_str().unwrap_or(""),
l["fields"],
l["span"]
);
}
let msgs: Vec<&str> = export
.iter()
.map(|l| l["fields"]["message"].as_str().unwrap_or(""))
.collect();
let internal = export
.iter()
.find(|l| l["fields"]["message"] == "internal error");
vec![
(
lines.iter().all(|l| l["span"]["name"] == "request"),
"모든 줄이 TraceLayer의 request span 안에서 찍혔다".into(),
),
(
msgs.first() == Some(&"started processing request")
&& msgs.contains(&"finished processing request"),
format!("내보내기 요청의 이벤트 순서: {msgs:?}"),
),
(
internal.is_some_and(|l| {
l["level"] == "ERROR"
&& l["fields"]["error"]
.as_str()
.is_some_and(|e| e.starts_with("writing /no-such-dir-vault/1.txt: "))
}),
"내부 에러의 원인 사슬은 ERROR 로그에만 남는다".into(),
),
]
}
/// 0.7 문법 `/:id`는 0.8에서 라우트를 등록할 때 panic한다.
fn old_syntax_panics() -> (bool, String) {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let res = std::panic::catch_unwind(|| {
let _r: Router = Router::new().route("/items/:id", get(health));
});
std::panic::set_hook(prev);
match res {
Err(e) => {
let msg = e
.downcast_ref::<String>()
.cloned()
.or_else(|| e.downcast_ref::<&str>().map(|s| s.to_string()))
.unwrap_or_default();
(true, msg)
}
Ok(()) => (false, "panic 없음".into()),
}
}
/// 127.0.0.1:0에 띄우고, TcpStream으로 HTTP 요청을 직접 보낸 뒤, 종료 신호를 보내 끝낸다.
async fn real_socket_check() -> (bool, String) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let server = tokio::spawn(async move {
axum::serve(listener, app(Shared::default()))
.with_graceful_shutdown(async {
let _ = rx.await;
})
.await
});
let mut stream = tokio::net::TcpStream::connect(addr).await.expect("connect");
stream
.write_all(b"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.expect("write");
let mut raw = String::new();
stream.read_to_string(&mut raw).await.expect("read");
tx.send(()).expect("shutdown signal");
let joined = tokio::time::timeout(Duration::from_secs(5), server).await;
let stopped = matches!(joined, Ok(Ok(Ok(()))));
let first_line = raw.lines().next().unwrap_or("").to_string();
let ok = first_line == "HTTP/1.1 200 OK" && raw.ends_with("ok") && stopped;
(
ok,
format!(
"{first_line} / 본문 끝 {:?} / 서버 종료 {stopped}",
raw.rsplit("\r\n").next()
),
)
}
#[tokio::main]
async fn main() {
let mut all_ok = true;
for (ok, msg) in router_checks().await {
println!("[{}] {msg}", if ok { "ok" } else { "FAIL" });
all_ok &= ok;
}
for (ok, msg) in logging_checks().await {
println!("[{}] {msg}", if ok { "ok" } else { "FAIL" });
all_ok &= ok;
}
let (ok, msg) = old_syntax_panics();
println!(
"[{}] `/items/:id` 등록 -> panic: {msg}",
if ok { "ok" } else { "FAIL" }
);
all_ok &= ok && msg.contains("{capture}");
let (ok, msg) = real_socket_check().await;
println!("[{}] 진짜 소켓: {msg}", if ok { "ok" } else { "FAIL" });
all_ok &= ok;
assert!(all_ok);
println!("ALL CHECKS PASSED");
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn create_then_get() {
let app = app(Shared::default());
let (s, _) = call(&app, post_json("/items", r#"{"name":"pen"}"#)).await;
assert_eq!(s, StatusCode::CREATED);
let (s, body) = call(&app, get_req("/items/1")).await;
assert_eq!(s, StatusCode::OK);
let item: Item = serde_json::from_str(&body).unwrap();
assert_eq!(item.name, "pen");
}
#[tokio::test]
async fn all_router_checks_pass() {
for (ok, msg) in router_checks().await {
assert!(ok, "{msg}");
}
}
}