서비스 기초 연습 문서의 문제 4 풀이 코드다.
//! 서비스 기초 연습 p4: 메모 API. PUT·GET·DELETE /notes/{key}, 키 검사(400), 없는 키(404),
//! x-api-key 헤더 검사(401), 에러 타입 하나로 JSON 응답. oneshot으로 시험한다.
//! 의존성: axum = "0.8", tokio = { version = "1", features = ["full"] }, tower = { version = "0.5", features = ["util"] },
//! serde = { version = "1", features = ["derive"] }, serde_json = "1"
//! 실행: python3 "_시스템/Rust 계획/rs_check.py" "Rust/4.연습문제/45_service-basics-practice_p4.rs"
use std::collections::HashMap;
use std::sync::Arc;
use axum::body::{Body, to_bytes};
use axum::extract::{Path, State};
use axum::http::{HeaderMap, Request, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::put;
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tower::ServiceExt;
struct AppState {
api_key: String,
notes: RwLock<HashMap<String, String>>,
}
#[derive(Deserialize, Serialize)]
struct Note {
text: String,
}
enum ApiError {
Unauthorized,
BadKey(String),
NotFound(String),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, msg) = match self {
ApiError::Unauthorized => (
StatusCode::UNAUTHORIZED,
"missing or wrong x-api-key".to_string(),
),
ApiError::BadKey(k) => (
StatusCode::BAD_REQUEST,
format!("bad key {k:?}: use 1-32 letters or digits"),
),
ApiError::NotFound(k) => (StatusCode::NOT_FOUND, format!("note {k:?} not found")),
};
(status, Json(serde_json::json!({ "error": msg }))).into_response()
}
}
// 하위목표 1: 공통 검사 두 가지를 함수로 뺀다.
fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> {
match headers.get("x-api-key") {
Some(v) if v.as_bytes() == state.api_key.as_bytes() => Ok(()),
_ => Err(ApiError::Unauthorized),
}
}
fn valid_key(key: &str) -> Result<(), ApiError> {
let ok = (1..=32).contains(&key.len()) && key.chars().all(|c| c.is_ascii_alphanumeric());
if ok {
Ok(())
} else {
Err(ApiError::BadKey(key.to_string()))
}
}
// 하위목표 2: 핸들러. State·HeaderMap·Path를 먼저, 본문 Json은 마지막에.
async fn put_note(
State(s): State<Arc<AppState>>,
headers: HeaderMap,
Path(key): Path<String>,
Json(note): Json<Note>,
) -> Result<StatusCode, ApiError> {
authorize(&s, &headers)?;
valid_key(&key)?;
let created = s.notes.write().await.insert(key, note.text).is_none();
Ok(if created {
StatusCode::CREATED
} else {
StatusCode::OK
})
}
async fn get_note(
State(s): State<Arc<AppState>>,
Path(key): Path<String>,
) -> Result<Json<Note>, ApiError> {
valid_key(&key)?;
let notes = s.notes.read().await;
let text = notes.get(&key).cloned().ok_or(ApiError::NotFound(key))?;
Ok(Json(Note { text }))
}
async fn delete_note(
State(s): State<Arc<AppState>>,
headers: HeaderMap,
Path(key): Path<String>,
) -> Result<StatusCode, ApiError> {
authorize(&s, &headers)?;
valid_key(&key)?;
match s.notes.write().await.remove(&key) {
Some(_) => Ok(StatusCode::NO_CONTENT),
None => Err(ApiError::NotFound(key)),
}
}
// 하위목표 3: 라우터 조립
fn app(api_key: &str) -> Router {
let state = Arc::new(AppState {
api_key: api_key.to_string(),
notes: RwLock::default(),
});
Router::new()
.route(
"/notes/{key}",
put(put_note).get(get_note).delete(delete_note),
)
.with_state(state)
}
async fn send(
app: &Router,
method: &str,
uri: &str,
key: Option<&str>,
body: Option<&str>,
) -> (u16, String) {
let mut b = Request::builder().method(method).uri(uri);
if let Some(k) = key {
b = b.header("x-api-key", k);
}
let req = match body {
Some(s) => b
.header("content-type", "application/json")
.body(Body::from(s.to_string())),
None => b.body(Body::empty()),
}
.expect("request");
let res = app.clone().oneshot(req).await.expect("infallible");
let status = res.status().as_u16();
let bytes = to_bytes(res.into_body(), 1 << 16).await.expect("body");
(status, String::from_utf8_lossy(&bytes).into_owned())
}
async fn scenario() -> Vec<(u16, String)> {
let app = app("k-123");
let note = Some(r#"{"text":"buy milk"}"#);
vec![
send(&app, "PUT", "/notes/todo", None, note).await,
send(&app, "PUT", "/notes/todo", Some("k-123"), note).await,
send(&app, "PUT", "/notes/todo", Some("k-123"), note).await,
send(&app, "GET", "/notes/todo", None, None).await,
send(&app, "GET", "/notes/bad-key", None, None).await,
send(&app, "DELETE", "/notes/todo", Some("k-123"), None).await,
send(&app, "GET", "/notes/todo", None, None).await,
]
}
#[tokio::main]
async fn main() {
let got = scenario().await;
for g in &got {
println!("{g:?}");
}
let codes: Vec<u16> = got.iter().map(|g| g.0).collect();
assert_eq!(codes, [401, 201, 200, 200, 400, 204, 404]);
assert_eq!(got[3].1, r#"{"text":"buy milk"}"#);
assert_eq!(got[6].1, r#"{"error":"note \"todo\" not found"}"#);
println!("ALL CHECKS PASSED");
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn wrong_key_is_unauthorized_even_for_delete() {
let app = app("k-123");
assert_eq!(
send(&app, "DELETE", "/notes/x", Some("nope"), None).await.0,
401
);
}
#[tokio::test]
async fn key_longer_than_32_is_rejected() {
let app = app("k");
let long = format!("/notes/{}", "a".repeat(33));
assert_eq!(send(&app, "GET", &long, None, None).await.0, 400);
}
}