Danh sách bài viết

Bài 5: Content Negotiation: Accept, Content-Type, Vary

Bài 5 của series Rust RESTful API — đi sâu vào content negotiation theo RFC 9110: cú pháp Accept header với quality value (q=), thuật toán best match phía server, vendor MIME type cho API versioning (application/vnd.shop.v2+json), Vary header để cache layer phân biệt response theo client capability, mã 406 Not Acceptable, và lock policy cho Shop API — JSON-only cho mọi data endpoint, URL versioning /api/v1 thay vì vendor MIME, Vary: Accept-Encoding cho catalog public, Cache-Control: private thay Vary: Authorization cho user-scoped.

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

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

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

  • Hiểu content negotiation (đàm phán định dạng) là gì và xảy ra ở đâu trong vòng đời một request HTTP.
  • Nắm cú pháp Accept với quality value (q=) đánh trọng số preference.
  • Biết server match Accept theo thuật toán best match (specificity rồi tới q value).
  • Hiểu vendor-specific MIME type dạng application/vnd.<vendor>.<version>+json và vai trò trong API versioning qua header.
  • Biết Vary header và vì sao thiếu nó làm CDN/browser cache trả nhầm response.
  • Áp dụng vào Shop API: JSON-only mặc định, ETag + Vary cho caching, vendor MIME chỉ giữ để biết và Shop API chọn URL versioning /api/v1.
2

Content Negotiation Là Gì?

Một resource trong REST (vd sản phẩm có id 43) là khái niệm trừu tượng. Cùng resource có thể tồn tại nhiều representation khác nhau: bản JSON dùng cho app mobile, bản XML cho hệ thống B2B legacy, bản HTML cho browser duyệt trực tiếp, bản CSV cho export báo cáo. Content negotiation là cơ chế cho phép client báo cho server biết nó đọc được những representation nào và server chọn ra cái phù hợp nhất mà nó có thể tạo.

RFC 9110 mục 12 chuẩn hóa ba dimension (chiều) đàm phán, mỗi dimension dùng một cặp request/response header riêng:

Dimension          Request header          Response phản ánh
Media type         Accept                  Content-Type
Language           Accept-Language         Content-Language
Encoding           Accept-Encoding         Content-Encoding

Bài này tập trung vào dimension media type — chiều quan trọng nhất với REST API. Hai dimension còn lại (language cho đa ngôn ngữ, encoding cho nén gzip/br) sẽ xuất hiện lại ở B48 (Compression) và group i18n sau này.

Luồng chuẩn của content negotiation: client gửi request kèm Accept liệt kê các media type nó đọc được; server đọc danh sách, đối chiếu với khả năng của chính nó, chọn một type khớp nhất; server trả response kèm Content-Type đúng với representation đã chọn. Toàn bộ quá trình xảy ra mỗi request, không cần state trên server — phù hợp với ràng buộc stateless của REST.

3

Accept Header + Quality Value (q=)

Cú pháp tổng quát của Accept:

Accept: <media-type>[;q=<weight>], <media-type>[;q=<weight>], ...

Quality value là một số thực từ 0.0 tới 1.0, tối đa ba chữ số sau dấu chấm thập phân. Giá trị mặc định khi không ghi là 1.0. Ý nghĩa: q=1.0 là ưu tiên cao nhất, q=0.0 nghĩa là không chấp nhận (server thấy q=0 phải loại type đó khỏi danh sách match). Các giá trị giữa biểu thị mức độ chấp nhận tương đối.

GET /api/v1/products/43 HTTP/1.1
Host: shop.example.com
Accept: application/json;q=1.0, application/xml;q=0.5, */*;q=0.1
User-Agent: ShopMobile/2.1

Header này nói: "tôi thích JSON nhất, XML chấp nhận được nhưng không ưu tiên, còn lại type gì cũng nuốt được nhưng không thích". Server đọc xong sẽ ưu tiên trả JSON nếu hỗ trợ; nếu không có JSON nhưng có XML thì trả XML; nếu không có cả hai thì trả type bất kỳ nó có.

Cú pháp wildcard dùng dấu sao: */* chấp nhận mọi media type, application/* chấp nhận mọi subtype thuộc application, text/* chấp nhận mọi subtype thuộc text. Wildcard thường đi với q thấp để làm fallback — bạn ưu tiên type cụ thể, nhưng vẫn nhận đại nếu server không có.

Trong thực tế, browser tự gửi Accept rất dài và chi tiết (Chrome thường text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8); client API thường gửi tinh gọn (Accept: application/json). Khi gọi REST API qua tool như curl hoặc httpie mà không set Accept, mặc định là */* — server tự quyết định trả gì.

4

Server Match Accept — Best Match Algorithm

Server không chỉ chọn cái có q cao nhất. Thuật toán đối sánh theo RFC 9110 mục 12.5.1 ưu tiên specificity (mức cụ thể) trước, q value sau:

  • Type cụ thể thắng wildcard. application/json cụ thể hơn application/*, và application/* cụ thể hơn */*.
  • Nếu cùng mức specificity, q value cao thắng.
  • Nếu cùng cả specificity lẫn q, server tự do chọn theo thứ tự ưu tiên nội tại của nó.

Quy trình match đầy đủ phía server gồm bốn bước. Bước một, parse Accept thành danh sách (media-type, q-value). Bước hai, liệt kê những media type chính server có khả năng generate cho endpoint này (capability list). Bước ba, giao hai tập với nhau — chỉ giữ những type vừa có trong Accept vừa có trong capability. Bước bốn, từ tập giao chọn type có specificity cao nhất; nếu hòa, lấy q cao nhất; vẫn hòa thì server tự ưu tiên.

Accept của client:    application/xml;q=0.5, application/json;q=0.9, */*;q=0.1
Capability server:    [application/json, text/csv]
Giao:                 application/json (q=0.9), text/csv qua */* (q=0.1)
Best match:           application/json — cụ thể hơn và q cao hơn

Nếu giao là tập rỗng — nghĩa là không một type nào trong Accept khớp với capability — server trả 406 Not Acceptable (đã đề cập ở B3). Cẩn thận: trường hợp client có */* trong Accept thì giao gần như luôn khác rỗng (vì */* match mọi thứ), nên 406 rất hiếm khi Accept kèm wildcard.

Một quan sát thực tế: hầu hết REST API hiện đại bỏ qua phần lớn nội dung Accept và luôn trả JSON, vì gần 100% client REST gửi Accept: application/json hoặc */*. Đây là cách tiếp cận thực dụng — chỉ enforce nghiêm khi API có nhiều representation thật sự. Shop API thuộc nhóm pragmatic: chỉ support application/json cho mọi data endpoint, và 406 chỉ trả khi Accept rõ ràng loại JSON ra (vd Accept: application/xml không kèm wildcard).

5

Content-Type Trong Response Phải Match Representation

Sau khi server chọn được representation, response phải có Content-Type chính xác mô tả representation đó. Mặc định cho JSON là application/json; charset=utf-8. Phần charset=utf-8 theo RFC 8259 (JSON spec) là encoding bắt buộc cho JSON trao đổi qua mạng — JSON không cho phép encoding khác UTF-8, UTF-16, hay UTF-32, và UTF-8 là default tuyệt đối cho web API.

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 124
ETag: "v3-7a1b2c"

{"id": 43, "slug": "laptop-y", "name": "Laptop Y", "price": "1499.00", "currency": "USD"}

Khi response body khác JSON, server phải set Content-Type đúng:

  • File download → application/octet-stream kèm Content-Disposition: attachment; filename="invoice.pdf".
  • Ảnh → image/webp, image/png, image/jpeg.
  • CSV export → text/csv; charset=utf-8.
  • PDF → application/pdf.
  • SSE stream → text/event-stream (sẽ gặp lại ở B50).

Trong axum, extractor và response type Json<T> tự động set Content-Type: application/json ở cả request parsing lẫn response — bạn không phải tự set thủ công. Khi trả file qua ServeFile hoặc ServeDir, tower-http tự suy ra Content-Type từ extension qua crate mime_guess. Khi build response custom, bạn cần set thủ công qua ([("content-type", "text/csv; charset=utf-8")], body).into_response().

Lưu ý bảo mật: client tin tưởng Content-Type đến mức nào tùy môi trường. Browser cũ và một số tool có cơ chế MIME sniffing — đoán type bằng cách peek vài byte đầu body, dẫn tới khả năng diễn giải file HTML có injected script như HTML dù server nói text/plain. Header X-Content-Type-Options: nosniff tắt sniffing và buộc browser tôn trọng Content-Type server gửi (Shop API sẽ lock ở B159 — security headers).

6

API Versioning Qua Vendor MIME Type

RFC 6838 mô tả cấu trúc media type và dành riêng nhánh vendor tree cho organization tự đăng ký. Format: application/vnd.<vendor>[.<subtype>][.<version>]+<suffix>. Suffix +json báo cho parser biết body vẫn là JSON syntax, chỉ semantic là vendor-specific. Ví dụ application/vnd.shop.v2+json: vendor là shop, version là v2, body parse được như JSON bình thường.

Pattern này dùng cho API versioning qua header — client báo version mong muốn qua Accept thay vì qua URL:

GET /api/products/43 HTTP/1.1
Accept: application/vnd.shop.v2+json

HTTP/1.1 200 OK
Content-Type: application/vnd.shop.v2+json

{"id": 43, "slug": "laptop-y", "name": "Laptop Y", "pricing": {"amount": "1499.00", "currency": "USD"}}

Cùng URL /api/products/43, client gửi Accept: application/vnd.shop.v1+json thì server route tới handler v1 (price là number flat); gửi v2 thì tới handler v2 (price là object có currency). Đây là kỹ thuật GitHub API v3 từng dùng phổ biến, và một số API doanh nghiệp lớn vẫn áp dụng.

So sánh nhanh với URL versioning /api/v1 vs /api/v2:

Tiêu chí                  URL versioning           Vendor MIME versioning
URL                       /api/v1/products/43      /api/products/43
Chỉ định version qua      Path                     Accept header
Cache CDN                 Dễ (URL khác nhau)       Khó (cần Vary: Accept)
Postman/curl/browser      Set URL, xong            Phải set header riêng
Discovery, share link     Dán URL là đủ            Phải đính kèm header
Tooling support           Tuyệt đối                Hạn chế
Phổ biến industry         Cao                      Trung bình

Shop API chọn URL versioning /api/v1 (đã lock ở URL convention shop-state.md). Lý do: phù hợp ecosystem (Stripe, Twilio, Slack, AWS đều dùng URL versioning), debug dễ (nhìn URL biết version ngay), cache CDN không cần thêm Vary: Accept phức tạp, share link tự đầy đủ. Vendor MIME type vẫn được giới thiệu để bạn nhận ra khi gặp trong tài liệu của API khác, nhưng không implement trong series này. Chi tiết chiến lược versioning (URL, header, query) sẽ được mổ riêng ở B30.

7

Vary Header — Cache Đúng Theo Client Capability

Vấn đề nảy sinh ngay khi server có nhiều representation cho cùng một URL: cache layer (CDN, reverse proxy, browser cache) dùng URL làm khóa lưu, chỉ giữ một bản. Nếu request đầu đến từ client gửi Accept: application/json được lưu, request sau đến từ client gửi Accept: application/xml sẽ nhận nhầm bản JSON từ cache — sai hoàn toàn.

Vary là response header báo cache rằng: khóa cache phải mở rộng để bao gồm thêm các header này từ request. Có Vary: Accept tức cache phải lưu một bản riêng cho mỗi giá trị Accept khác nhau. Có Vary: Accept-Encoding tức bản gzip và bản raw được lưu tách. Có Vary: Accept-Language tức bản tiếng Việt và bản tiếng Anh tách.

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: public, max-age=300
ETag: "v3-7a1b2c"
Vary: Accept-Encoding

Khi cần nhiều dimension, ghép qua dấu phẩy: Vary: Accept, Accept-Encoding. Cache khi đó dùng tổ hợp (URL, Accept, Accept-Encoding) làm key. Mặt trái: càng nhiều dimension trong Vary, càng nhiều bản cache phải lưu, tỉ lệ hit giảm — cân nhắc kỹ trước khi thêm.

Một trường hợp hay bị lạm dụng: Vary: Authorization. Về mặt kỹ thuật, response phụ thuộc user (qua JWT trong Authorization) thì cache không được share giữa user — vì vậy nhiều tutorial dạy thêm Vary: Authorization. Nhưng cách sạch hơn là dùng Cache-Control: private: directive này nói thẳng với mọi shared cache (CDN, proxy) rằng không được cache, chỉ browser của user cuối được cache. Khi đó không cần Vary: Authorization nữa, vì shared cache đã loại response ra khỏi store ngay từ đầu.

Shop API lock policy Vary như sau (kết hợp với caching policy đã lock ở B4):

  • Catalog public (GET /api/v1/products, GET /api/v1/products/:slug) trả Cache-Control: public, max-age=300 + ETag + Vary: Accept-Encoding — để CDN cache đúng tách giữa bản gzip và bản raw.
  • User-scoped (GET /api/v1/me, GET /api/v1/cart) trả Cache-Control: private, no-cache — không thêm Vary: Authorizationprivate đã loại shared cache.
  • Sensitive (GET /api/v1/orders/:id/payment) trả Cache-Control: no-store — cấm cache mọi nơi, Vary không liên quan.
  • Không thêm Vary: Accept vì Shop API JSON-only — không có alternate representation để cache nhầm.
8

406 Not Acceptable Khi Không Match Nào

RFC 9110 mục 15.5.7 định nghĩa 406 Not Acceptable cho trường hợp server không thể tạo bất kỳ representation nào khớp với Accept của client. Body 406 nên giải thích lý do và liệt kê các media type server hỗ trợ, để client tự điều chỉnh:

GET /api/v1/products/43 HTTP/1.1
Accept: application/xml

HTTP/1.1 406 Not Acceptable
Content-Type: application/json; charset=utf-8

{
  "error": "Only application/json is supported by this endpoint.",
  "code": "NOT_ACCEPTABLE",
  "request_id": "req_8f3c2a",
  "supported": ["application/json"]
}

Lưu ý: response 406 vẫn nên trả về JSON kèm Content-Type: application/json theo chuẩn error envelope đã lock từ B3, dù client không yêu cầu — đây là pragmatic, bởi server không có representation nào khác để chọn, và client nhận body lỗi vẫn parse được nếu chịu khó.

Trong thực tế, nhiều REST API public bỏ qua Accept hoàn toàn và luôn trả JSON. Đây là quyết định hợp lý nếu (1) API document rõ là JSON-only và (2) client gửi Accept: application/xml chắc đã hiểu sai endpoint. Trade-off: bỏ 406 giúp giảm friction nhưng vi phạm contract HTTP (client gửi explicit type không match mà vẫn nhận body khác).

Shop API lock policy ở giữa hai cực — pragmatic nhưng vẫn tôn trọng signal rõ ràng:

  • Nếu Accept missing hoặc chứa */* hoặc chứa application/json (kể cả qua wildcard application/*) → server trả JSON với 200.
  • Nếu Accept liệt kê chỉ những type explicit non-JSON (vd Accept: application/xml hoặc Accept: text/html không kèm wildcard) → server trả 406 kèm body JSON giải thích và list supported.
  • Trường hợp 406 hiếm trong thực tế — hầu hết client REST gửi JSON hoặc */* — nhưng policy này tránh trả về JSON cho client mong đợi XML và parse fail âm thầm.
9

Tổng Kết

  • Content negotiation: client gửi Accept (kèm q value preference) → server match với capability → response set Content-Type tương ứng representation đã chọn.
  • Ba dimension đàm phán: media type (Accept), language (Accept-Language), encoding (Accept-Encoding) — REST API tập trung vào media type.
  • Quality value q= nằm trong khoảng 0.0 tới 1.0, tối đa ba chữ số sau dấu chấm; q=0 là loại trừ; default là 1.0.
  • Best match algorithm: specificity ưu tiên hơn q value — application/json thắng application/* thắng */* dù q có thế nào.
  • 406 Not Acceptable khi không có giao giữa Accept và capability server — hiếm khi client có */* trong danh sách.
  • Vendor MIME type application/vnd.<vendor>.<version>+json theo RFC 6838 dùng cho versioning qua header — URL giữ stable, version đàm phán qua Accept.
  • URL versioning /api/v1 phổ biến hơn vendor MIME (Stripe, Twilio, Slack đều dùng URL); Shop API chọn URL versioning, chi tiết ở B30.
  • Vary header bảo cache phải mở rộng key bao gồm các header request được liệt kê — Vary: Accept, Vary: Accept-Encoding, ghép qua dấu phẩy khi nhiều dimension.
  • Thay vì Vary: Authorization, dùng Cache-Control: private — sạch hơn, shared cache loại trực tiếp khỏi store.
  • Shop API lock: JSON-only mọi data endpoint, response set Content-Type: application/json; charset=utf-8; catalog public thêm Vary: Accept-Encoding; user-scoped dùng Cache-Control: private; URL versioning /api/v1 thay vì vendor MIME.
10

Bài Tập Củng Cố

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

  1. Client gửi Accept: application/xml;q=0.8, application/json;q=0.9 tới server chỉ hỗ trợ JSON. Server trả response với Content-Type gì và status code gì? Giải thích thuật toán match.
  2. Phân biệt application/jsonapplication/vnd.shop.v2+json. Khi nào dùng vendor MIME thay vì JSON thường?
  3. Server trả response phụ thuộc Acceptkhông set Vary: Accept trong response. Vấn đề gì xảy ra ở CDN và browser cache? Mô tả kịch bản cụ thể.
  4. So sánh URL versioning (/api/v1) và vendor MIME versioning (Accept: application/vnd.shop.v2+json). Shop API chọn cái nào và vì sao?
  5. 406 Not Acceptable xảy ra trong điều kiện nào? Shop API chọn policy pragmatic — trường hợp nào trả 406, trường hợp nào fallback JSON?
Đáp án
  1. Server trả Content-Type: application/json; charset=utf-8 với status 200 OK. Lý do: thuật toán match phía server (1) parse Accept thành [(application/xml, 0.8), (application/json, 0.9)]; (2) capability server là [application/json]; (3) giao là [application/json] với q=0.9; (4) chỉ một type còn lại nên là best match. Server không hỗ trợ XML nhưng client cũng chấp nhận JSON với q cao nhất nên không có lý do trả 406. Đây là trường hợp đơn giản — JSON match trực tiếp.
  2. application/json là MIME type chuẩn, body parse như JSON, không gắn semantic vendor cụ thể — dùng cho REST API thông dụng và là mặc định của Shop API. application/vnd.shop.v2+json theo RFC 6838 vendor tree: vendor là shop, version v2, suffix +json báo body vẫn parse được như JSON nhưng semantic là vendor-specific (cấu trúc field theo schema v2 của Shop). Dùng vendor MIME khi cần versioning qua header (URL giữ nguyên, version đàm phán qua Accept), hoặc khi API có nhiều biến thể schema cùng URL. Shop API không dùng vendor MIME, chọn URL versioning /api/v1 để dễ debug, dễ cache CDN, dễ share link.
  3. Vấn đề: cache (CDN, reverse proxy, browser) dùng URL làm khóa cache. Không có Vary, cache chỉ lưu một bản. Kịch bản cụ thể: request đầu từ client A gửi Accept: application/json nhận response JSON — cache lưu bản JSON. Request sau từ client B gửi Accept: application/xml tới cùng URL — cache hit, trả về bản JSON đã lưu mà không hỏi origin. Client B nhận JSON dù yêu cầu XML — parse fail. Giải pháp: response thêm Vary: Accept để cache hiểu phải lưu một bản riêng cho mỗi giá trị Accept. Mặt trái: cache hit rate giảm vì lưu nhiều bản; Shop API JSON-only nên không gặp vấn đề này.
  4. URL versioning đặt version trong path (/api/v1/products/43); vendor MIME đặt version trong Accept header (Accept: application/vnd.shop.v2+json) trên URL stable. Shop API chọn URL versioning với prefix /api/v1. Lý do: (1) Ecosystem support — Stripe, Twilio, Slack, AWS, GitHub v4 đều dùng URL versioning, Postman/curl/browser default tốt; (2) Debug và share — nhìn URL là biết version ngay, dán link là đủ; (3) Cache CDN không cần Vary: Accept phức tạp, URL khác nhau cache tách tự nhiên; (4) Discovery API và tài liệu dễ hơn. Vendor MIME giữ lại như kiến thức để hiểu API khác, không implement trong series.
  5. 406 Not Acceptable trả khi tập giao giữa Accept của client và capability của server là rỗng — không một media type nào trong Accept khớp với type server có khả năng generate. Trong thực tế hiếm gặp vì hầu hết client gửi */* hoặc application/json. Shop API chọn policy pragmatic: (a) fallback JSON với 200 khi Accept missing, chứa */*, chứa application/json, hoặc chứa wildcard match được application/*; (b) trả 406 chỉ khi Accept liệt kê chỉ những type explicit non-JSON không kèm wildcard (vd Accept: application/xml hoặc Accept: text/html đơn lẻ). Body 406 dùng error envelope đã lock (B3): { error, code: "NOT_ACCEPTABLE", request_id, supported: [...] }, vẫn trả qua Content-Type: application/json bất chấp Accept để client còn parse được lỗi.
11

Bài Tiếp Theo

— đi sâu vào JSON syntax recap, vì sao JSON thắng XML cho web API, JSON Schema và OpenAPI, date format ISO 8601, null vs missing, number precision pitfall (i64 vs f64), chuẩn bị cho serde deserialization ở G4.