Skip to content

API ​

API của CMS là Worker api-cms-conan-school (Hono) ở api-cms.conan.school, dữ liệu ở D1 conan-cms. Mã nằm ở api-cms.conan.school/src/.

Có ba nhóm route:

  • /health: kiểm sống, trả { "ok": true }.
  • /public/*: công khai, chỉ đọc, chỉ trả bài đã xuất bản. www dùng nhóm này. Mở CORS cho mọi origin, chỉ GET.
  • /admin/*: cần JWT của Cloudflare Access. CMS gọi nhóm này qua Pages Function /api/* cùng origin, nên trên trình duyệt đường dẫn là cms.conan.school/api/admin/....

:collection là một trong pages, news, announcements, playbooks. Giá trị khác trả 404 collection_not_found.

Hợp đồng dùng chung ​

Kiểu bài, thân request, phản hồi từng route, mã lỗi, luật slug, giới hạn phân trang, If-Match và bộ đọc bản lịch sử nằm ở packages/cms-contract/ (@conan/cms-contract). Cả api-cms lẫn cms import từ đó; cms không tự khai lại kiểu nào của API.

  • api-cms giữ zod làm lược đồ kiểm lúc chạy (src/validation.ts), lấy mọi hằng số từ hợp đồng. src/contract-assertions.ts kiểm lúc biên dịch rằng kiểu của từng lược đồ BẰNG kiểu của hợp đồng: sửa một bên mà quên bên kia thì npm run check đỏ.
  • Mỗi route trả JSON qua satisfies <Response> của hợp đồng.
  • Gói không có phụ thuộc nào, kể cả zod, và không có bước build: hai app import thẳng mã .ts qua alias. Lý do: CI chỉ chạy npm ci trong thư mục app, nên packages/ không có node_modules riêng.

Vì sao có gói này: trước đó hợp đồng được khai hai lần (zod ở api-cms, kiểu viết tay ở cms) và không gì so hai bản với nhau. Ba lỗi đã lọt ra từ đúng chỗ đó: PUT gửi thừa collection nên bị 400, thanh bên đọc key trong khi API trả slug, và lịch sử đọc trường ở tầng trên trong khi bản chụp có dạng { action, entry }.

Route công khai ​

Phương thứcĐường dẫnLàm gì
GET/public/:collectionDanh sách bài đã xuất bản, mới xuất bản nhất trước
GET/public/:collection/:slugMột bài đã xuất bản, kèm thân bài

Tham số:

Tham sốRouteMặc địnhGhi chú
localecả haivivi hoặc en
limitdanh sách201 tới 50
cursordanh sáchLấy từ next_cursor của trang trước

Danh sách trả { collection, locale, items, next_cursor }. Mỗi item chỉ có các trường thẻ danh sách cần: slug, title, summary, cover_url, published_at. Thân bài chỉ có ở route một bài ({ collection, entry }, entry có thêm locale, body, updated_at), để danh sách không kéo theo hàng trăm KB Markdown.

Ghi chú:

  • Bài nháp không có mặt trong phản hồi: điều kiện status = 'published' nằm ngay trong câu SQL. Slug của bài nháp trả 404 entry_not_found, giống hệt slug không tồn tại, nên không lộ ra là có bài đang soạn.
  • Phản hồi có Cache-Control: public, max-age=60, s-maxage=300. Xuất bản, sửa hay gỡ bài có thể mất tới 5 phút mới thấy trên www.

Route quản trị ​

Mọi route dưới /admin/* đi qua hai middleware, theo thứ tự: accessMiddleware (danh tính, nên người chưa đăng nhập luôn nhận 401 trước mọi lỗi khác), rồi requireJsonBody.

Phương thứcĐường dẫnIf-MatchLàm gì
GET/admin/meEmail của người đang đăng nhập
GET/admin/collectionsBốn bộ sưu tập, kèm số bài nháp, đã xuất bản và tổng
GET/admin/entriesDanh sách bài, gồm cả nháp, mới sửa nhất trước. Xem Phân trang
POST/admin/entriesTạo bài. Luôn là nháp. Thân: collection, slug, title, tuỳ chọn locale, summary, body, cover_url. Trả 201
GET/admin/entries/:idĐọc một bài, kèm header ETag
PUT/admin/entries/:idbắt buộcLưu. Chỉ gửi trường cần đổi (slug, locale, title, summary, body, cover_url). Không đổi được collection và status qua đây
POST/admin/entries/:id/publishbắt buộcXuất bản. Giữ published_at cũ nếu bài từng xuất bản
POST/admin/entries/:id/unpublishbắt buộcGỡ về nháp
DELETE/admin/entries/:idbắt buộcXoá bài, sau khi ghi một bản lịch sử delete. Trả { deleted: true, id }
GET/admin/entries/:id/revisionsLịch sử bản sửa, mới nhất trước, tối đa 200

Mọi route trả một bài (GET, POST, PUT, publish, unpublish) trả { entry } kèm header ETag: "<updated_at>".

Mọi route ghi (tạo, lưu, xuất bản, gỡ, xoá) thêm một dòng vào cms_revisions trong cùng batch với lệnh ghi bài.

Thân yêu cầu được kiểm bằng zod ở chế độ strict: gửi một trường lạ là 400, không bị lặng lẽ bỏ qua. PUT không có trường nào cũng là 400.

Khoá lạc quan: If-Match ​

Hai người cùng mở một bài. Người thứ nhất lưu. Người thứ hai lưu sau, cầm bản cũ: nếu không có gì chặn, bản của người thứ hai đè lên bản của người thứ nhất mà không ai được báo. If-Match chặn đúng chuyện đó.

  • PUT, publish, unpublish và DELETE bắt buộc header If-Match là updated_at của bản đã nạp, dạng ETag mạnh: If-Match: "2026-10-16T08:30:00.123Z". Dạng không ngoặc kép cũng nhận. *, ETag yếu (W/...) hay danh sách nhiều ETag bị coi như thiếu.
  • Thiếu: 428 precondition_required.
  • Bài đã đổi kể từ lúc nạp: 409 conflict.
  • Bài không còn: 404 entry_not_found.
  • Lấy giá trị từ header ETag của lượt đọc hay lượt ghi gần nhất (hoặc từ entry.updated_at).

Điều kiện thật nằm trong câu ghi (UPDATE ... WHERE id = ? AND updated_at = ?), không chỉ ở lượt kiểm sớm, vì giữa lúc đọc và lúc ghi vẫn có thể có người ghi. Bản lịch sử đi TRƯỚC trong cùng batch với cùng điều kiện (INSERT ... SELECT ... WHERE EXISTS), nên ghi khớp 0 hàng thì không có bản lịch sử nào. updated_at mới luôn lớn hơn bản cũ, kể cả khi hai lượt ghi rơi vào cùng một mili giây.

Thân request phải là JSON ​

Lượt ghi có thân vào /admin/* phải mang Content-Type: application/json (cho phép ; charset=..., không phân biệt hoa thường), nếu không trả 415 unsupported_media_type. Lượt ghi không thân (publish, unpublish, DELETE) đi qua.

Đây là một lớp chống CSRF: form HTML liên miền chỉ gửi được text/plain, form-urlencoded hay multipart, còn application/json liên miền thì trình duyệt buộc preflight CORS, mà /admin/* không mở CORS. Lớp chính là gác cùng origin ở Pages Function của cms, xem Kiến trúc.

Phân trang danh sách quản trị ​

GET /admin/entries nhận:

Tham sốMặc địnhGhi chú
collectionBộ sưu tập lạ trả 404 collection_not_found
statusdraft hoặc published
qTìm trong tiêu đề và slug, tối đa 200 ký tự
limit1001 tới 200
cursorLấy từ next_cursor của trang trước

Trả { entries, next_cursor }. next_cursor khác null nghĩa là còn mục chưa trả: gửi lại làm cursor để lấy trang sau. Con trỏ mã hoá cặp (updated_at, id), nên trang sau bắt đầu đúng chỗ trang trước dừng kể cả khi nhiều bài cùng thời điểm. Con trỏ hỏng trả 400 invalid_cursor, không bị coi là "trang đầu". Mỗi mục trong entries có đủ trường trừ body.

Danh sách công khai dùng cùng cơ chế, với khoá là published_at.

Bản lịch sử ​

GET /admin/entries/:id/revisions trả { revisions }, mỗi phần tử:

json
{
  "id": 42,
  "entry_id": "3f0c...",
  "author": "[email protected]",
  "created_at": "2026-10-16T08:30:00.123Z",
  "snapshot": { "action": "update", "entry": { "id": "3f0c...", "title": "...", "...": "..." } }
}
  • snapshot là { action, entry }: action là một trong create, update, publish, unpublish, delete; entry là toàn bộ bài sau hành động đó (với delete là bản cuối cùng trước khi xoá).
  • Kiểu của snapshot trong hợp đồng là unknown, có chủ ý: đó là JSON lưu trong D1 từ lúc ghi, không theo lược đồ hiện tại. Nơi đọc phải đi qua readRevisionSnapshot của hợp đồng. Hàm này không bao giờ ném lỗi; bản hỏng trả { ok: false, error } để giao diện hiện dạng thô thay vì vỡ cả danh sách.
  • Cột hỏng trong D1 không làm hỏng cả phản hồi: API trả nguyên chuỗi và ghi log cảnh báo.
  • Bài đã xoá vẫn đọc được lịch sử theo id cũ.

Luật dữ liệu ​

TrườngLuật
slug1 tới 120 ký tự, chỉ a-z, 0-9, gạch nối; không mở đầu, kết thúc hay lặp gạch nối
title1 tới 300 ký tự, cắt khoảng trắng hai đầu
summaryTối đa 2.000 ký tự, cho phép null
bodyTối đa 500.000 ký tự, cho phép null
cover_urlURL http hoặc https, tối đa 2.000 ký tự, cho phép null

Các giới hạn này là hằng số trong packages/cms-contract/, nên trình soạn của cms kiểm đúng cùng một luật trước khi gửi.

Lỗi ​

Mọi lỗi có cùng một dạng, trả qua apiError ở src/http.ts:

json
{ "error": { "code": "duplicate_slug", "message": "...", "details": [] } }

details chỉ có khi có chi tiết (ví dụ danh sách ô sai của validation_error).

Mã HTTPcodeKhi nào
400invalid_json, validation_error, invalid_cursor, bad_requestDữ liệu gửi lên sai
401unauthorizedThiếu header Cf-Access-Jwt-Assertion, hoặc JWT không hợp lệ
404not_found, collection_not_found, entry_not_foundKhông có route, bộ sưu tập hay bài
409duplicate_slugSlug đã có trong cùng bộ sưu tập và ngôn ngữ
409conflictIf-Match lệch: có người ghi trước
415unsupported_media_typeLượt ghi có thân mà không phải application/json
428precondition_requiredThiếu If-Match ở PUT, publish, unpublish, DELETE
503access_not_configuredChưa đặt CF_ACCESS_TEAM_DOMAIN hoặc CF_ACCESS_AUD. Đóng hết /admin/*
500internal_errorLỗi chưa lường trước, có ghi log

Pages Function của cms tự trả hai mã, trước khi request tới được api-cms (cùng dạng lỗi):

Mã HTTPcodeKhi nào
403cross_site_requestLượt ghi không đến từ chính cms.conan.school
502upstream_unreachableKhông gọi được api-cms

Danh sách mã đầy đủ là API_ERROR_CODES và PROXY_ERROR_CODES trong hợp đồng.

Gọi thử ​

Route công khai gọi thẳng được:

bash
curl https://api-cms.conan.school/health
curl https://api-cms.conan.school/public/news
curl "https://api-cms.conan.school/public/news/khai-giang-thang-11?locale=vi"

Route quản trị cần JWT của Access, nên gọi qua CMS sau khi đăng nhập. Đừng tắt kiểm JWT để gọi thử cho tiện.

Đổi route trong mã thì sửa trang này trong cùng PR.

Tài liệu nội bộ của Conan CMS.