Danh sách bài viết

Bài 12: Hello World Với Axum

Bài 12 của series Rust RESTful API — bài code thực hành đầu tiên trong codebase Shop API: enhance file crates/shop-api/src/main.rs từ skeleton B10 với 3 route (/, /health, /version), handler return impl IntoResponse linh hoạt, graceful shutdown qua tokio::signal::ctrl_c + Unix SIGTERM, tracing log mỗi lần server listen/stop; bài cũng mổ flow cốt lõi Router → TcpListener → axum::serve, signature handler axum (6 IntoResponse built-in), pattern multi-route trong Router builder, lý do graceful shutdown quan trọng cho rolling deploy K8s, và cách test endpoint bằng curl + HTTPie kèm verify Ctrl+C drain in-flight request — đây là pattern shutdown chuẩn áp dụng cho mọi binary trong workspace Shop API (shop-api lúc này, shop-worker ở G21).

12/06/2026
10 phút đọc
2 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Hiểu rõ flow 4 bước của axum server: Routertower::ServiceTcpListeneraxum::serve.
  • Biết handler signature chuẩn axum async fn() -> impl IntoResponse và 6 type built-in implement IntoResponse.
  • Biết tạo multi-route trong cùng Router qua builder pattern .route() + chain method.
  • Hiểu graceful shutdown: vì sao cần, tokio::signal::ctrl_c bắt SIGINT, signal::unix::SignalKind::terminate bắt SIGTERM, tokio::select! chờ cả hai.
  • Test endpoint bằng curl + HTTPie (tool đã lock ở B9) và verify Ctrl+C drain in-flight request.
  • Có Shop API enhanced với 3 route (/, /health, /version) + graceful shutdown + log mỗi lần listen/stop, build incremental trên skeleton B10.
2

State Hiện Tại Của Shop API (Recap B10)

B10 đã init workspace Shop API với 2 crate (shop-api binary + shop-common lib). File crates/shop-api/src/main.rs hiện chạy được trên port 3000 với 2 route tối giản: GET / trả chuỗi "shop-api v0.1.0"GET /health trả "ok". Module shop_common::config::AppConfig đã load env, shop_common::error::AppError 11 variants đã sẵn (sẽ dùng từ B16), shop_common::telemetry::init_tracing stub đã setup tracing subscriber. Workspace.dependencies đã lock axum = "0.8", tokio = "1" (features macros, rt-multi-thread, signal), serde_json = "1", tracing = "0.1" — bài này không cần thêm dependency mới.

Cây file liên quan trong workspace:

shop/
├── Cargo.toml                        # workspace.dependencies đã lock
├── crates/
│   ├── shop-api/
│   │   ├── Cargo.toml
│   │   └── src/
│   │       └── main.rs               # ← bài này enhance
│   └── shop-common/
│       └── src/
│           ├── lib.rs
│           ├── config.rs             # AppConfig (sẵn từ B10)
│           ├── error.rs              # AppError (sẵn từ B10)
│           └── telemetry.rs          # init_tracing (sẵn từ B10)

Bài này chỉ chạm vào một file duy nhất crates/shop-api/src/main.rs — extend từ 2 route lên 3 route, thêm graceful shutdown, thêm tracing log mỗi vòng đời server. Module shop-common giữ nguyên không sửa.

3

Flow Cốt Lõi: Router → Service → TcpListener → serve

Một axum server tối giản trải qua đúng 4 bước, không hơn không kém:

  1. Build RouterRouter::new().route("/path", get(handler)) tạo type axum::Router. Bản chất Router là một tower::Service<Request> — nhận http::Request<Body>, dispatch tới handler khớp path, trả http::Response<Body>.
  2. Bind TcpListenertokio::net::TcpListener::bind(addr).await? mở socket lắng nghe TCP port. Type này là async wrapper của std::net::TcpListener chuẩn POSIX, lấy ownership socket khỏi runtime.
  3. Start server loopaxum::serve(listener, app).await? drive accept loop: nhận connection mới → spawn task tokio xử lý → gọi Service::call của router → write response về socket. Function này trả Future never-ending cho đến khi shutdown signal đến.
  4. Graceful shutdown — gắn .with_graceful_shutdown(future) trước .await. Khi future ready (Ctrl+C, SIGTERM), axum stop accept connection mới, đợi in-flight request hoàn tất, rồi mới trả về.

Diagram flow:

┌──────────────────────────────────────────────────────────┐
│   1. Router::new()                                       │
│        .route("/", get(root))                            │
│        .route("/health", get(health))    ← tower::Service│
│        .route("/version", get(version))                  │
└──────────────────────────────────────────────────────────┘
                          │
                          ▼
┌──────────────────────────────────────────────────────────┐
│   2. TcpListener::bind("0.0.0.0:3000").await?            │
│        (open socket POSIX, lắng nghe port)               │
└──────────────────────────────────────────────────────────┘
                          │
                          ▼
┌──────────────────────────────────────────────────────────┐
│   3. axum::serve(listener, app)                          │
│        .with_graceful_shutdown(shutdown_signal())        │
│        .await?                                           │
│        (accept loop + spawn task per request)            │
└──────────────────────────────────────────────────────────┘
                          │
                          ▼
┌──────────────────────────────────────────────────────────┐
│   4. shutdown_signal() future ready                      │
│        (Ctrl+C hoặc SIGTERM) → drain → exit              │
└──────────────────────────────────────────────────────────┘

So với viết server thuần bằng hyper raw (không qua axum), bạn đỡ ~100+ dòng boilerplate cho parse HTTP request, dispatch routing, build response, error handling. axum đóng gói toàn bộ thông qua bốn dòng API trên.

4

Handler Signature: async fn → impl IntoResponse

Handler axum là một async fn trả về type implement trait IntoResponse trong module axum::response. axum không yêu cầu handler trả thẳng Response<Body> — bất kỳ type nào convert được sang Response đều hợp lệ. Đây là sức mạnh ergonomic giúp handler đọc gần như function tổng quát Rust.

Sáu type built-in implement IntoResponse bạn sẽ dùng nhiều nhất:

  • String&'static str → response text/plain; charset=utf-8, status 200.
  • (StatusCode, body) tuple — override status code, body có thể là String hoặc bất kỳ type IntoResponse khác. Ví dụ (StatusCode::CREATED, "ok").
  • Html<T> wrap text → text/html; charset=utf-8. Dùng cho admin dashboard render template.
  • Json<T> wrap struct serialize → application/json; charset=utf-8. Sẽ là response chính của Shop API, deep dive ở B14-B15.
  • Response<Body> — raw response tự build qua Response::builder(). Dùng khi cần custom header phức tạp.
  • Result<T, E> với T: IntoResponseE: IntoResponse — error type cũng convert sang response. Đây là pattern Shop API sẽ dùng AppResult<T> từ B16 sau khi impl IntoResponse cho AppError.

Ví dụ 4 handler khác signature, cùng hợp lệ với axum:

use axum::{http::StatusCode, response::{Html, IntoResponse, Json}};
use serde_json::json;

// 1. Trả chuỗi tĩnh — text/plain
async fn ping() -> &'static str {
    "pong"
}

// 2. Tuple (StatusCode, body) — override status
async fn created() -> impl IntoResponse {
    (StatusCode::CREATED, "resource created")
}

// 3. Html — text/html
async fn dashboard() -> impl IntoResponse {
    Html("<h1>Admin</h1>")
}

// 4. Json — application/json
async fn version() -> impl IntoResponse {
    Json(json!({ "version": "0.1.0" }))
}

Ghi nhớ: AppError (11 variants từ B10 trong shop-common::error) sẽ được impl IntoResponse ở B16 — sau đó mọi handler Shop API return AppResult<Json<T>> sạch, axum tự convert error sang status code + body envelope { error, code, request_id } đã lock ở B3. Tạm thời bài này chỉ dùng String, (StatusCode, ...)Json<serde_json::Value> đủ minh họa.

5

Multi-Route Trong Router

Router dùng builder pattern — mọi method khai báo route đều trả về Router mới (consume ownership), cho phép chain method:

  • .route(path, method_router) — đăng ký một path với một MethodRouter. Function get(handler), post(handler), ... trong module axum::routing tạo MethodRouter single method.
  • .route(path, get(h1).post(h2)) — cùng path nhiều method. Chain method trên MethodRouter để gắn POST sau GET. Đây là pattern Shop API sẽ dùng cho /api/v1/products (GET list + POST create) ở B62.
  • .merge(other_router) — gộp hai Router cùng prefix. Use case: tách route module theo resource (file products.rs export fn router()) rồi merge vào root.
  • .nest(prefix, sub_router) — gắn sub-router dưới một prefix. .nest("/api/v1", api_v1_router()) sẽ làm mọi route trong sub-router có prefix /api/v1. Deep dive ở B24.

Trong bài này, Shop API dùng 3 route đơn giản trong cùng builder chain:

fn build_router() -> Router {
    Router::new()
        .route("/", get(root))
        .route("/health", get(health))
        .route("/version", get(version))
}

Path /health sẽ được tách thành /healthz (liveness probe — server có sống không) và /readyz (readiness probe — DB + Redis có sẵn sàng không) ở Group 15. Path /version là build metadata endpoint mới, trả về tên crate + version + edition, dùng cho debug deploy ("phiên bản nào đang chạy ở môi trường này?").

6

Thực Hành: Enhance crates/shop-api/src/main.rs

Rewrite file crates/shop-api/src/main.rs với 3 route + graceful shutdown + tracing log:

// File: crates/shop-api/src/main.rs
use axum::{
    http::StatusCode,
    response::IntoResponse,
    routing::get,
    Json, Router,
};
use serde_json::json;
use shop_common::{config::AppConfig, telemetry};
use tokio::signal;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // 1. Tracing subscriber init trước mọi log
    telemetry::init_tracing(false);

    // 2. Load config từ env (.env + env::var)
    let config = AppConfig::from_env()?;

    // 3. Build axum Router với 3 route
    let app = build_router();

    // 4. Bind TCP listener
    let addr = format!("0.0.0.0:{}", config.port);
    let listener = tokio::net::TcpListener::bind(&addr).await?;
    tracing::info!(addr = %addr, "shop-api listening");

    // 5. Serve + graceful shutdown
    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await?;

    tracing::info!("shop-api stopped");
    Ok(())
}

fn build_router() -> Router {
    Router::new()
        .route("/", get(root))
        .route("/health", get(health))
        .route("/version", get(version))
}

// Root: text/plain identifier
async fn root() -> impl IntoResponse {
    (StatusCode::OK, "shop-api v0.1.0")
}

// Health: JSON status, sẽ split thành /healthz + /readyz ở G15
async fn health() -> impl IntoResponse {
    Json(json!({ "status": "ok" }))
}

// Version: build metadata cho debug deploy
async fn version() -> impl IntoResponse {
    Json(json!({
        "name": "shop-api",
        "version": env!("CARGO_PKG_VERSION"),
        "edition": "2024",
    }))
}

// Graceful shutdown: chờ Ctrl+C hoặc SIGTERM, cái nào đến trước
async fn shutdown_signal() {
    let ctrl_c = async {
        signal::ctrl_c()
            .await
            .expect("failed to install Ctrl+C handler");
    };

    #[cfg(unix)]
    let terminate = async {
        signal::unix::signal(signal::unix::SignalKind::terminate())
            .expect("failed to install signal handler")
            .recv()
            .await;
    };

    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();

    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    }

    tracing::info!("shutdown signal received, draining requests");
}

Phân tích từng phần:

  • Macro #[tokio::main] — khởi tạo multi-thread tokio runtime (feature rt-multi-thread đã bật trong workspace.dependencies B10). Function main trở thành async, runtime tự setup worker thread theo số CPU core máy.
  • Tracing init trước configtelemetry::init_tracing(false) setup subscriber pretty format cho dev (param json=false). Mọi tracing::info! sau đó sẽ in ra stdout. Production sẽ pass json=true để output JSON cho log aggregator parse.
  • Macro env!("CARGO_PKG_VERSION") — compile-time macro của Rust, expand thành chuỗi version trong Cargo.toml của crate hiện tại. Khi bạn bump version shop-api từ 0.1.0 lên 0.1.1 và rebuild, /version trả ngay version mới — không phải hard-code.
  • with_graceful_shutdown — method trên axum::serve() nhận một Future<Output = ()>. Khi future complete, axum stop accept connection mới, đợi mọi in-flight request hoàn tất (tới khi handler return), rồi resolve serve(...).await.
  • #[cfg(unix)] conditional compilation — chỉ Unix-like OS (Linux, macOS, BSD) mới có SIGTERM standard. Windows dùng cơ chế ConsoleControlEvent khác hoàn toàn — đoạn terminate trên Windows fallback std::future::pending::<()>() (future không bao giờ ready), nghĩa là chỉ Ctrl+C hoạt động.
  • tokio::select! — macro race future, branch nào ready trước thì execute branch đó, branch còn lại bị drop. Ở đây chờ cùng lúc Ctrl+C và SIGTERM, signal nào đến trước thì shutdown.

File Cargo.toml của shop-api không cần update — serde_json đã có sẵn từ B10 (đã lock serde_json = "1" trong workspace.dependencies + import qua .workspace = true trong crate Cargo.toml). Verify nhanh bằng cargo build -p shop-api không lỗi.

7

Test Với curl Và HTTPie

Chạy server từ workspace root:

cargo run -p shop-api

Output kỳ vọng trên terminal:

  Compiling shop-api v0.1.0 (/.../crates/shop-api)
    Finished `dev` profile in 3.21s
     Running `target/debug/shop-api`
2026-06-12T14:00:00.123Z  INFO shop_api: shop-api listening addr=0.0.0.0:3000

Mở terminal khác test bằng curl:

# Root: trả text/plain
curl http://localhost:3000/
# shop-api v0.1.0

# Health: trả JSON
curl http://localhost:3000/health
# {"status":"ok"}

# Version: trả JSON với metadata
curl -v http://localhost:3000/version
# > GET /version HTTP/1.1
# > Host: localhost:3000
# < HTTP/1.1 200 OK
# < content-type: application/json
# < content-length: 65
# {"edition":"2024","name":"shop-api","version":"0.1.0"}

Tương đương qua HTTPie (tool đã lock ở B9):

http :3000/
http :3000/health
http :3000/version

HTTPie auto detect application/json Content-Type và pretty-print body có color highlight. Đây là tool nên cài cho dev workflow local — output dễ đọc hơn curl rất nhiều.

Test graceful shutdown — quay về terminal chạy server, ấn Ctrl+C:

^C
2026-06-12T14:01:30.456Z  INFO shop_api: shutdown signal received, draining requests
2026-06-12T14:01:30.458Z  INFO shop_api: shop-api stopped

Hai dòng log xuất hiện ngay sau khi nhấn Ctrl+C: dòng 1 từ function shutdown_signal (signal đã bắt được), dòng 2 từ main sau khi axum::serve(...).await? resolve (server thực sự dừng). Nếu lúc đó có request đang xử lý, axum sẽ đợi handler hoàn tất rồi mới resolve — đó là essence của graceful shutdown.

8

Graceful Shutdown — Tại Sao Quan Trọng

Trong môi trường production, server không bao giờ chạy mãi mãi — nó sẽ bị restart, redeploy, hoặc kill bởi orchestrator. Pattern phổ biến nhất là rolling deploy (K8s Deployment, fly.io machine update, Docker Swarm rolling): orchestrator gửi SIGTERM cho process cũ → đợi grace period (mặc định K8s là 30 giây) → nếu process chưa thoát thì gửi SIGKILL force kill. Trong khoảng grace period đó, server phải dọn dẹp sạch sẽ trước khi exit.

Nếu thiếu graceful shutdown:

  • Request đang xử lý bị hủy đột ngột — connection TCP bị reset giữa chừng, client nhận lỗi 502 Bad Gateway hoặc connection reset by peer. Trong giờ peak traffic, một deploy có thể spike error rate vài phần trăm.
  • Database connection chưa close — connection pool còn giữ session, transaction đang mở bị abort phía DB, có thể để lại lock hoặc dirty state.
  • Background task chưa flush — log buffer, metric buffer, queue message in-flight bị mất.

Axum giải quyết qua method .with_graceful_shutdown(future) nhận một Future<Output = ()>. Khi future ready (signal đến), axum:

  1. Stop accept connection TCP mới (socket vẫn listen nhưng từ chối nhanh).
  2. Đợi mọi in-flight request hoàn tất (handler return Response và Response đã ghi xong xuống socket).
  3. Đóng connection còn lại sạch sẽ, resolve serve(...).await trả về Ok(()).

Hai signal cần bắt cho Shop API binary:

  • SIGINT — gửi khi dev nhấn Ctrl+C trong terminal. tokio::signal::ctrl_c() trả future ready khi signal đến. Cross-platform (Unix + Windows).
  • SIGTERM — gửi bởi orchestrator (K8s, Docker, systemd) khi cần stop process gracefully. Unix-only — bắt qua signal::unix::signal(SignalKind::terminate())?.recv().await. Trên Windows không có (Windows dùng ConsoleCtrlHandler khác cơ chế hoàn toàn).

Macro tokio::select! race hai future — signal nào đến trước thì shutdown. Pattern này áp dụng nguyên xi cho mọi binary trong workspace Shop API: shop-api (HTTP server, bài này), shop-worker (background job runner sẽ init ở G21), shop-cli (CLI tool ở G29 — CLI ít cần graceful shutdown hơn nhưng vẫn nên có).

Chi tiết deeper về deploy K8s SIGTERM lifecycle, preStop hook, terminationGracePeriodSeconds, và readinessProbe drain pattern sẽ mổ riêng ở B297 (Group 30 Docker & Deploy).

9

Suggested Commit

Commit message gợi ý cho repo Shop API:

git add crates/shop-api/src/main.rs
git commit -m "B12: enhance shop-api with multi-route, graceful shutdown, tracing log"

File thay đổi trong commit:

  • crates/shop-api/src/main.rs — rewrite enhanced: thêm route /version, đổi /health trả JSON thay text, gắn with_graceful_shutdown, tracing log lifecycle, helper function build_routershutdown_signal.

File giữ nguyên (không touch từ B10):

  • crates/shop-common/src/* — module config, error, headers, telemetry không đổi.
  • Cargo.toml workspace root + crate shop-api/shop-common — dependency lock từ B10 đủ dùng, không thêm gì.
  • .env.example, rust-toolchain.toml không đổi.

Sau commit, cargo build --workspace phải pass clean và cargo run -p shop-api phải start được server lắng localhost:3000 như section 7 demo.

10

Tổng Kết

  • Flow axum 4 bước: Router::new() build router (bản chất tower::Service) → TcpListener::bind mở socket → axum::serve drive accept loop → with_graceful_shutdown chờ shutdown signal.
  • Handler signature chuẩn axum: async fn name(extractors...) -> impl IntoResponse. 6 type built-in: String, (StatusCode, body), Html<T>, Json<T>, Response<Body>, Result<T, E> với cả hai vế là IntoResponse.
  • Router builder: .route(path, method_router) đăng ký, .merge(other) gộp, .nest(prefix, sub) prefix nested. Multi-method cùng path qua chain get(h1).post(h2) trên MethodRouter.
  • Graceful shutdown: with_graceful_shutdown(future) stop accept connection mới + drain in-flight + close clean khi future ready. Cần thiết cho rolling deploy K8s/Docker — tránh request bị hủy nửa chừng spike error rate.
  • Signal handle: tokio::signal::ctrl_c() bắt SIGINT cross-platform, signal::unix::SignalKind::terminate() bắt SIGTERM Unix-only (Windows fallback future::pending). tokio::select! race hai future.
  • Test workflow: cargo run -p shop-apicurl verbose để xem header + body, HTTPie ngắn gọn pretty-print color → Ctrl+C verify log "shutdown signal received" và "shop-api stopped" xuất hiện đúng.
  • Shop API state sau bài này: 3 route expose — GET / trả text identifier, GET /health trả JSON status, GET /version trả JSON build metadata (name + version từ env!("CARGO_PKG_VERSION") + edition). Skeleton B10 vẫn nguyên, chỉ enhance file main.rs.
  • Pattern shutdown chuẩn workspace: function shutdown_signal() này áp dụng nguyên xi cho mọi binary tương lai — shop-worker ở G21, shop-cli ở G29 đều cần handle SIGINT + SIGTERM tương tự. Deeper deploy K8s lifecycle ở B297.
11

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. Handler axum return impl IntoResponse nghĩa là gì? Liệt kê 4 type built-in implement IntoResponse và cho biết Content-Type mặc định của mỗi type.
  2. Phân biệt .route("/x", get(h1).post(h2)).merge(other_router). Khi nào dùng cái nào? Cho ví dụ cụ thể trong Shop API.
  3. Tại sao cần with_graceful_shutdown trong production? Hậu quả gì xảy ra nếu thiếu khi K8s rolling deploy?
  4. Đoạn code bắt SIGTERM bị wrap trong #[cfg(unix)]. Tại sao? Có cần handle SIGINT trên Windows không và cơ chế thế nào?
  5. Test endpoint /version với curl (verbose mode để xem header) và HTTPie. Viết command tương đương cho cả hai.
Đáp án
  1. Handler return impl IntoResponse nghĩa là return một type bất kỳ implement trait axum::response::IntoResponse — axum tự gọi .into_response() chuyển type đó thành Response<Body>. Compiler infer type cụ thể lúc build, không phải dynamic dispatch. 4 type built-in và Content-Type: (1) String / &'static strtext/plain; charset=utf-8; (2) (StatusCode, body) tuple → giữ Content-Type của body, override status code; (3) Html<T>text/html; charset=utf-8; (4) Json<T>application/json; charset=utf-8 (đã lock từ B5 JSON-only Shop API). Hai type bổ sung: Response<Body> giữ custom header phức tạp và Result<T, E> với cả hai vế IntoResponse — pattern Shop API dùng cho AppResult<T> từ B16.
  2. .route("/x", get(h1).post(h2)) — cùng path /x nhiều method khác nhau (GET gọi h1, POST gọi h2). Chain method trên MethodRouter. .merge(other_router) — gộp toàn bộ route của other_router vào router hiện tại; hai router thường định nghĩa các path khác nhau, merge để tổng hợp. Khi nào dùng: dùng get().post() chain khi cùng resource cùng path nhưng khác method (vd Shop API GET /api/v1/products list + POST /api/v1/products create — B62). Dùng .merge() khi tách route theo resource sang file riêng để code có cấu trúc — vd file products.rs export fn router() -> Router có cả GET + POST + PATCH cho products, root main.rs gọi .merge(products::router()) + .merge(orders::router()) + ... gom tất cả lại.
  3. Cần with_graceful_shutdown: production server bị restart liên tục (rolling deploy, scale down, redeploy after bug fix) — orchestrator gửi SIGTERM cho process cũ trước khi kill cứng. Trong khoảng grace period (K8s mặc định 30s), server phải dọn dẹp: stop accept connection mới, đợi in-flight request hoàn tất, close DB connection, flush log/metric buffer. Hậu quả thiếu: (a) request đang xử lý bị reset giữa chừng → client nhận 502 Bad Gateway hoặc connection reset by peer — peak traffic có thể spike error rate vài phần trăm mỗi lần deploy; (b) DB transaction đang mở bị abort, có thể để lock hoặc dirty state; (c) background task chưa flush, log/metric mất. K8s rolling deploy với 10 pod thay phiên restart sẽ tạo ra hiện tượng "fan-out error" rõ rệt trong monitoring nếu thiếu graceful shutdown — đây là lỗi production phổ biến nhất với binary Rust mới deploy.
  4. Đoạn bắt SIGTERM wrap #[cfg(unix)]SIGTERM là khái niệm POSIX — chỉ tồn tại trên Unix-like OS (Linux, macOS, FreeBSD, ...). Windows không có signal mechanism tương đương — Windows dùng ConsoleCtrlHandler với event như CTRL_C_EVENT, CTRL_CLOSE_EVENT, CTRL_SHUTDOWN_EVENT, cơ chế khác hoàn toàn. Code signal::unix::* không compile được trên Windows nên cần conditional compile. SIGINT trên Windows: tokio::signal::ctrl_c() cross-platform — trên Unix bắt SIGINT, trên Windows bắt CTRL_C_EVENT qua ConsoleCtrlHandler dưới hood. Cùng API, behavior tương đương — dev không phải viết riêng. Branch #[cfg(not(unix))] trong shutdown_signal fallback std::future::pending::<()>() — future không bao giờ ready, nên trên Windows chỉ Ctrl+C hoạt động (Windows production thường chạy qua Windows Service với cơ chế stop khác, không phải target chính của Shop API).
  5. curl verbose: curl -v http://localhost:3000/version — flag -v in chi tiết request line + request header (prefix >), response status + response header (prefix <), và body cuối cùng. Bạn sẽ thấy < HTTP/1.1 200 OK, < content-type: application/json, < content-length: ... rồi body JSON. HTTPie: http :3000/version — short form, HTTPie tự suffix localhost và prefix http:// khi thấy :port/path. HTTPie default in cả header response và body với color highlight, không cần flag verbose. Ngắn hơn rõ rệt, output thân thiện hơn cho dev workflow local — đã lock ở B9 là tool kèm curl trong README documentation Shop API.
12

Bài Tiếp Theo

— deep dive handler signature axum: argument của handler là extractor (Path, Query, Json, State, ...), return type implement IntoResponse với độ linh hoạt cao, và những wrong type signature gây compile error khó hiểu mà dev mới hay gặp. Bài này sẽ phân loại extractor theo FromRequest vs FromRequestParts, giải thích lý do Json<T> phải đặt cuối argument list, và pattern AppResult<Json<T>> chuẩn cho mọi handler Shop API sau khi AppError impl IntoResponse ở B16.