Rust axum — 작은 웹 API 서버를 만든다
axum 은 tokio(입문 46편) 위에서 도는 Rust 웹 프레임워크다. Router 에 경로와 핸들러 함수를 묶고, Json·Path·State 같은 추출기로 요청에서 필요한 것을 꺼낸다. 메모 목록 API 를 만들어 127.0.0.1 에서 만들기 201·조회 200·없음 404·잘못된 JSON 422 응답을 확인한다.
01 경로 둘짜리 서버
[package]
name = "notes-api"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "=0.8.9"
tokio = { version = "=1.54.1", features = ["rt-multi-thread", "macros", "net", "io-util"] }
serde = { version = "=1.0.229", features = ["derive"] }use axum::{Json, Router, routing::get};
use serde::Serialize;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
#[derive(Serialize)]
struct Health {
status: &'static str,
version: &'static str,
}
async fn health() -> Json<Health> {
Json(Health { status: "ok", version: "0.1.0" })
}
async fn fetch(path: &str) -> String {
let mut stream = TcpStream::connect("127.0.0.1:7878").await.unwrap();
let request = format!("GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n");
stream.write_all(request.as_bytes()).await.unwrap();
let mut response = String::new();
stream.read_to_string(&mut response).await.unwrap();
let status = response.lines().next().unwrap_or("").to_string();
let body = response.split("\r\n\r\n").nth(1).unwrap_or("").to_string();
format!("{status} | {body}")
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(|| async { "notes api" }))
.route("/health", get(health));
let listener = TcpListener::bind("127.0.0.1:7878").await.unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
println!("{}", fetch("/").await);
println!("{}", fetch("/health").await);
println!("{}", fetch("/nope").await);
} Running `target/debug/notes-api`
HTTP/1.1 200 OK | notes api
HTTP/1.1 200 OK | {"status":"ok","version":"0.1.0"}
HTTP/1.1 404 Not Found | Router::new().route("/", get(…))는 "GET / 요청이 오면 이 함수를 부른다" 다. 핸들러는 async 함수나 클로저(입문 36편)다.- 핸들러가
&str을 돌려주면 글자 응답,Json(값)을 돌려주면 JSON 응답이 된다.Json안에는 serde 의Serialize(5편)를 갖춘 값이면 무엇이든 넣는다. - 정하지 않은 경로(
/nope)는 axum 이 알아서 404 로 답한다. axum::serve(listener, app)가 서버를 돌린다. 이 예제는 서버를tokio::spawn으로 뒤에 띄우고, 같은 프로그램 안에서fetch로 요청을 보내 응답 첫 줄과 본문을 찍었다(외부로 나가지 않게 127.0.0.1 만 쓴다).
02 메모 API — 만들기·목록·하나
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::{Json, Router, routing::get};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
#[derive(Clone, Serialize, Deserialize)]
struct Note {
id: usize,
title: String,
}
#[derive(Deserialize)]
struct NewNote {
title: String,
}
type Db = Arc<Mutex<Vec<Note>>>;
async fn list(State(db): State<Db>) -> Json<Vec<Note>> {
Json(db.lock().unwrap().clone())
}
async fn create(State(db): State<Db>, Json(input): Json<NewNote>) -> (StatusCode, Json<Note>) {
let mut notes = db.lock().unwrap();
let note = Note { id: notes.len() + 1, title: input.title };
notes.push(note.clone());
(StatusCode::CREATED, Json(note))
}
async fn one(State(db): State<Db>, Path(id): Path<usize>) -> Result<Json<Note>, StatusCode> {
let notes = db.lock().unwrap();
notes.iter().find(|n| n.id == id).cloned().map(Json).ok_or(StatusCode::NOT_FOUND)
}
pub fn app() -> Router {
let db: Db = Arc::new(Mutex::new(Vec::new()));
Router::new()
.route("/notes", get(list).post(create))
.route("/notes/{id}", get(one))
.with_state(db)
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:7878").await.unwrap();
tokio::spawn(async move { axum::serve(listener, app()).await.unwrap() });
let client = Client;
println!("{}", client.send("POST", "/notes", r#"{"title":"learn axum"}"#).await);
println!("{}", client.send("POST", "/notes", r#"{"title":"write tests"}"#).await);
println!("{}", client.send("GET", "/notes", "").await);
println!("{}", client.send("GET", "/notes/2", "").await);
println!("{}", client.send("GET", "/notes/9", "").await);
println!("{}", client.send("POST", "/notes", r#"{"name":"oops"}"#).await);
}
struct Client;
impl Client {
async fn send(&self, method: &str, path: &str, body: &str) -> String {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut s = tokio::net::TcpStream::connect("127.0.0.1:7878").await.unwrap();
let req = format!(
"{method} {path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
s.write_all(req.as_bytes()).await.unwrap();
let mut res = String::new();
s.read_to_string(&mut res).await.unwrap();
let status = res.lines().next().unwrap_or("").replace("HTTP/1.1 ", "");
let body = res.split("\r\n\r\n").nth(1).unwrap_or("");
format!("{method} {path} -> {status} {body}")
}
} Running `target/debug/notes-api`
POST /notes -> 201 Created {"id":1,"title":"learn axum"}
POST /notes -> 201 Created {"id":2,"title":"write tests"}
GET /notes -> 200 OK [{"id":1,"title":"learn axum"},{"id":2,"title":"write tests"}]
GET /notes/2 -> 200 OK {"id":2,"title":"write tests"}
GET /notes/9 -> 404 Not Found
POST /notes -> 422 Unprocessable Entity Failed to deserialize the JSON body into the target type: missing field `title` at line 1 column 15핸들러의 매개변수 타입이 곧 요청에서 꺼낼 것이다.
State(db): State<Db>—with_state(db)로 넣어 둔 공유 저장소를 꺼낸다. 요청은 여러 스레드에서 동시에 오니Arc<Mutex<…>>(입문 45편)로 감쌌다.Json(input): Json<NewNote>— 요청 본문을 JSON 으로 읽어NewNote로 만든다. 5편의 serde 가 일한다.Path(id): Path<usize>—/notes/{id}의{id}자리를 숫자로 꺼낸다.
돌려주는 값으로 응답이 정해진다.
(StatusCode::CREATED, Json(note))— 201 과 JSON 을 함께.Result<Json<Note>, StatusCode>—Ok면 200 과 JSON,Err(StatusCode::NOT_FOUND)면 404.ok_or는Option을Result로 바꾸는 메서드다.
마지막 줄을 보자. title 대신 name 을 보냈더니, 우리가 검사 코드를 한 줄도 쓰지 않았는데 axum 이 422(처리할 수 없는 내용)로 막고 이유(missing field title)를 답했다. 5편에서 본 serde 의 오류가 그대로 응답이 된 것이다. 핸들러는 올바른 NewNote 가 왔을 때만 불린다.
03 실습 — 지우기 경로 더하기
DELETE /notes/{id} 를 더해 보자.
async fn remove(State(db): State<Db>, Path(id): Path<usize>) -> StatusCode를 만든다. 있으면 지우고StatusCode::NO_CONTENT(204), 없으면NOT_FOUND..route("/notes/{id}", get(one).delete(remove))처럼 같은 경로에 메서드를 잇는다.- 같은 방법으로 요청을 보내 204 뒤에 목록에서 빠졌는지 본다.
실제 서비스에서는 Vec 대신 DB(sqlx 등)를 쓰고, 이 예제처럼 unwrap 하는 대신 에러를 응답으로 바꾼다. 그 에러 처리를 다음 편에서 다룬다.