Skip to content

Hợp đồng API giữa api và các frontend ​

Hai repo (16.10.2026)

Từ 16.10.2026 www (loader, fetchContract) nằm ở repo conan-web, còn gói packages/api-contract ở repo này. CI ở đây không còn build www: sửa một schema www đọc thì PR ở cả hai repo trong cùng ngày. Xem Hai repo.

Trạng thái (14.10.2026): hợp đồng chạy ở api ở chế độ BÓNG (chỉ ghi log, không chặn), được THI HÀNH ở loader của www (lệch là 503) và ở trang What's New của tokyo (lệch là khung báo lỗi). Sentry chưa có DSN (packages/observability/dsn.ts còn rỗng), nên mọi captureMessage / captureException của hợp đồng hiện không gửi đi đâu cả. Tín hiệu duy nhất đang sống là dòng "event":"contract_mismatch" trong Workers Logs của conan-api-gateway và của Pages ww3-conan-school. Đừng đọc "Sentry không có lỗi hợp đồng nào" thành "hợp đồng khớp".

Gói packages/api-contract mô tả bằng zod hình dạng của các phản hồi công khai mà www và tokyo đọc. Một file, ba bên dùng: api kiểm phản hồi sắp gửi, www parse phản hồi vừa nhận, script kiểm sống gọi production sau mỗi lần deploy. Skill đi kèm: api-contract.

Vì sao có nó ​

Trước gói này, gần như mọi schema phản hồi trong api là z.any(), /openapi.json không ai đọc, và mỗi frontend tự đoán hình dạng dữ liệu bằng if (!Array.isArray(json.data)) throw hoặc một kiểu chép tay. Khi đoán sai, hệ không đỏ: nó hiện một trang 200 trống ruột. Rà ngày 14.10.2026 tìm ra chín chỗ hỏng như vậy chỉ trên 12 endpoint (bảng cuối trang).

Endpoint đã có hợp đồng ​

Hợp đồngEndpointAi đọc
courseListGET /api/public/courseswww /courses, www sitemap-courses, tokyo /calendars
courseDetailGET /api/public/courses/{slug}www /courses/:slug
communityListGET /api/public/communitieswww /communities, www sitemap-community
communityHubGET /api/public/communities/{slug}www /communities/:slug
whatsNewGET /api/public/whats-newwww /whats-new, tokyo /whats-new
whatsNewLatestGET /api/public/whats-new/latesttokyo, chấm "chưa đọc" ở thanh bên
newsList (cũ)GET /api/public/content/newskhông ai từ 17.10.2026 (www đọc api-cms); đường quay lui tới conan-web PR 10
newsDetail (cũ)GET /api/public/content/news/{slug}như trên
playbookList (cũ)GET /api/public/content/playbookswww /playbooks, /playbooks/:slug (bài liên quan), sitemap-library, cho tới conan-web PR 9
playbookDetail (cũ)GET /api/public/content/playbooks/{slug}www /playbooks/:slug, cho tới conan-web PR 9
ebookListGET /api/content/ebookswww /books, www sitemap-books, tokyo /books (chưa parse)
ebookDetailGET /api/public/ebooks/{slug}www /books/:slug, /books/:slug/chapters/:n, /books/:slug/sections/:id

Sổ này nằm trong mã, ở mảng ENDPOINTS của packages/api-contract/index.ts: script kiểm sống đọc nó, nên thêm endpoint mà quên khai thì endpoint đó nằm ngoài lưới.

Vì sao là một nhà máy makeContracts(z) ​

packages/ không có node_modules. esbuild (wrangler), vite và node đều phân giải import 'zod' từ vị trí của file đang import, không từ site gọi nó. Một import { z } from 'zod' trong gói này nổ "Cannot find module" ở cả ba nơi, đúng bẫy đã cắn packages/rich-doc ngày 08.09.2026 (CI cài sạch, tsc đỏ với TS2307 trỏ vào một file không có lỗi gì).

Nên gói xuất một hàm nhận zod của bên gọi:

ts
// www.conan.school/app/lib/contract.ts
import { z } from "zod";
import { makeContracts } from "../../../packages/api-contract/index";
export const contracts = makeContracts(z);

Trong gói chỉ có import type { z } from 'zod', bị xoá sạch khi biên dịch, nên lúc chạy gói không cần zod. tsc thì vẫn cần tìm kiểu của zod, và tìm qua dòng này trong tsconfig của mỗi bên dùng:

json
"paths": { "zod": ["./node_modules/zod"] }

Đã có ở api.conan.school/tsconfig.json, api.conan.school/tsconfig.node.json, www.conan.school/tsconfig.json, com.conan.school/tsconfig.json. Quên dòng này thì tsc đỏ với TS2307 "Cannot find module 'zod'" ở packages/api-contract/index.ts: ồn ào, đúng ý. Nó không thay đổi gì lúc chạy (wrangler/vite không đọc paths cho zod vì gói không import zod lúc chạy).

Gói chỉ dùng cú pháp xoá được (không enum, không namespace, không parameter property), vì scripts/contract-live.mjs nạp thẳng file .ts bằng node --experimental-strip-types.

Chế độ strip: thêm trường không hỏng, bớt trường thì ồn ​

Mọi object trong hợp đồng là z.object (chế độ strip), không bao giờ z.strictObject:

  • API thêm một trường: bên đọc không hỏng, trường đó chỉ bị bỏ khỏi kết quả parse.
  • API bớt hay đổi kiểu một trường hợp đồng khai: parse hỏng, www trả 503, log có contract_mismatch kèm đường dẫn trường.
  • Kết quả parse chỉ chứa trường hợp đồng khai, nên loader của www dùng nó làm luôn phép chiếu trường (LUẬT SEO: thứ loader trả về đi nguyên vào HTML).
  • Kiểu suy từ hợp đồng chặt hơn any cũ, nên tsc bắt được trang đọc một trường API không bao giờ gửi. Ba chỗ như vậy lộ ra ngay lần đầu nối hợp đồng (bảng cuối trang).

Hai mức "có thể không có", không dùng lẫn: nullable() khi khoá luôn có nhưng giá trị có thể NULL; nullish() khi khoá có thể vắng (bản API cũ chưa gửi, hay một nhánh mã không chọn cột). Khi thêm một trường mà api và www deploy ở hai workflow khác nhau, khai trường mới nullish() để www không 503 trong vài phút api chưa lên.

Ngoại lệ duy nhất có biến đổi: câu quiz playbook (playbookQuizItem) nhận cả correct lẫn correctIndex và trả ra correctIndex, lý do ở bảng sự cố.

Bốn hợp đồng tin và playbook là hợp đồng CŨ từ 17.10.2026. Nội dung đó sang Conan CMS; hợp đồng mới là publicContracts của conan-web/packages/cms-contract. Route cũ mang header Deprecation/Sunset/Link: rel="successor-version", không thêm trường mới vào bốn hợp đồng này, và PR 13 gỡ chúng cùng route (sửa ở cả hai repo cùng ngày). Xem nội dung biên tập đã sang CMS.

Ba lớp, ba chế độ ​

LớpỞ đâuLàm gì khi lệchVì sao chế độ đó
Bóngapi.conan.school/src/shared/contract.ts, shadowContract()Ghi console.error có cấu trúc + captureMessage, vẫn gửi phản hồiMột schema viết sai mà api chặn thì đánh sập đúng trang hợp đồng sinh ra để bảo vệ
Thi hànhwww.conan.school/app/lib/contract.ts, fetchContract() / parseContract()Ném Response 503, ghi log + captureExceptionỞ đây hình dạng sai không nổ, nó thành trang 200 "đang cập nhật"
Kiểm sốngscripts/contract-live.mjsLiệt kê từng chỗ lệch, exit 1Ngay sau deploy chưa ai mở trang nào, lưới thứ hai chưa chạy

Kiểm bóng lấy mẫu một lần mỗi phút mỗi route mỗi isolate: lệch hợp đồng là lỗi hệ thống (một cột đổi kiểu thì mọi phản hồi đều lệch), nên mẫu là đủ; còn parse mọi lượt thì danh mục khoá học (~220 KB) tốn vài mili giây CPU cho một câu trả lời giống lần trước. Hàng hỏng lẻ ở một trang chi tiết ít người xem có thể lọt mẫu này; lưới của nó là loader www (parse mọi lượt) và kiểm sống.

Log chỉ có đường dẫn và mã lỗi (data.courses.[].slug [invalid_type]), không bao giờ giá trị: giá trị của một trường sai kiểu có thể là dữ liệu người dùng. Chỉ số mảng gộp thành [], nên 200 hàng hỏng cùng một cột là một dòng.

Trong www, fetchContract không bao giờ trả "rỗng" thay cho lỗi: lỗi mạng, 5xx, JSON hỏng, lệch hợp đồng đều là 503. 404 chỉ là 404 khi gọi với notFoundIs404: true (trang chi tiết); với một danh sách, 404 nghĩa là route biến mất (/api/public/home từng như vậy) nên cũng là 503. Khối phụ của một trang (bài liên quan) dùng tryParseContract: lệch thì vẫn ghi log + Sentry rồi ẩn khối.

Route chưa có hợp đồng dùng hai hàm của app/lib/loader-errors.ts, và chọn sai là đổi 503 thành 404:

EndpointHàm404 của API thành
Chi tiết (một bản ghi theo slug)assertOk404 của trang
Danh sách (/reviews, /members, /photos)assertListOk503 (route biến mất, không phải "không có gì")

Và kiểm tham số URL trước khi gọi API: endpoint chương của api chỉ mở cho khách với /chapters/1, mọi số khác trả 401, và 401 qua assertOk thành 503. Trước khi sửa, /books/x/chapters/abc hay /books/x/sections/0.1 (URL gõ sai) là 503, lỗi máy chủ mà máy tìm kiếm thử lại mãi; giờ loader trả 404 ngay khi số chương, số section không phải số nguyên dương.

Sitemap con có nguồn đã có hợp đồng (sitemap-courses, sitemap-library, sitemap-books, sitemap-community) trả 503 khi nguồn hỏng, không trả sitemap 200 thiếu URL: 200 thiếu URL nói với máy tìm kiếm rằng các URL đó đã biến mất, 503 nói "thử lại sau" và bản cũ được giữ.

Tokyo là SPA, không có loader: lệch hợp đồng thành lỗi mà trang phải hiện ra (khung đỏ), không bao giờ thành danh sách rỗng. Hook chấm "chưa đọc" ở thanh bên chỉ dùng kiểu của hợp đồng và kiểm tay một trường: parse bằng zod ở đó kéo zod + Sentry (~98 KB gzip) vào bundle của mọi trang.

Khoá bằng id, không bằng slug ​

Hai bảng khoá học chung id nhưng khác slug: legacy_courses (slug tiếng Việt, cột is_active) và courses không tiền tố (slug tiếng Anh, cột status). Đo 14.10.2026: 228 id có ở cả hai bảng, 206 trong số đó mang hai slug khác nhau. Hợp đồng khai id là chuỗi bắt buộc ở mọi hàng, và mọi phép nối giữa hai payload phải nối bằng id.

Lỗi tiềm ẩn, chưa cắn ai: trang Courses của tokyo nối danh mục (/api/learning/courses, đọc legacy_courses) với khoá đang học (/api/course-intelligence/my-courses, đọc courses) theo slug. Hôm nay mới có 8 khoá có người ghi danh (25 lượt, 21 người), và cả 8 tình cờ mang cùng một slug ở hai bảng, nên 0/8 bị ảnh hưởng. Nhưng người đầu tiên ghi danh vào một trong 206 khoá kia sẽ thấy khoá mình đang học nằm trong danh mục như chưa học, không lỗi, không cảnh báo. Sửa 14.10.2026 trước khi chuyện đó xảy ra: nối theo id; và lỗi tải khoá đang học hiện thành một dòng báo lỗi thay vì { data: [] } (phần này thì là nuốt lỗi thật).

Cột *_en luôn khai nullable(): phần lớn chưa điền, và pick(lang, vi, en) rơi về tiếng Việt.

Thêm một endpoint vào hợp đồng ​

  1. Đọc payload thật. curl -s https://api.conan.school/<đường> (đọc API công khai là được phép), và đọc handler. Ghi nullable/nullish theo dữ liệu và theo cột D1, không theo cảm giác. Một hàng có cột rỗng mà hợp đồng khai bắt buộc là cả trang 503.
  2. Khai schema trong makeContracts, kèm một dòng "Ai đọc". Chỉ khai trường bên đọc thật sự đọc: thêm một trường vào courseDetail là quyết định đưa nó ra HTML công khai.
  3. Khai vào ENDPOINTS (và SAMPLE_ROWS trong scripts/contract-live.mjs nếu là danh sách có trang chi tiết lấy mẫu từ nó).
  4. Kiểm bóng ở api: return c.json(shadowContract('GET /api/...', 'tênHợpĐồng', body), 200). Lỗi D1 của route đó phải thành 5xx (ném HTTPException(503)), không thành { data: [] }.
  5. Parse ở frontend: www fetchContract(url, 'tênHợpĐồng', 'nhãn', { notFoundIs404 }); tokyo parseContract(...) trong .then, lỗi rơi xuống trạng thái lỗi của trang. Xoá guard "unexpected shape" và kiểu chép tay của route đó.
  6. Chạy: npm run typecheck ở api và site, npm run test:contract ở api, và node --experimental-strip-types scripts/contract-live.mjs --samples all với production.

Chạy ​

bash
# Kiểm sống production: mọi danh sách + 1 trang chi tiết mỗi danh sách
node --experimental-strip-types scripts/contract-live.mjs
# Mọi trang chi tiết (~580 request, vài phút)
node --experimental-strip-types scripts/contract-live.mjs --samples all
# Bài kiểm của api (SQLite trong bộ nhớ): hình dạng, không lộ link lớp, lỗi D1 thành 503
cd api.conan.school && npm run test:contract

checkContracts({ base, log }) là export cho bước kiểm sau deploy (scripts/smoke-production.mjs). Thiếu --experimental-strip-types (Node 22) hoặc thiếu api.conan.school/node_modules/zod thì nó trả một lỗi nói rõ thiếu gì, không bao giờ trả "0 lỗi trên 0 phản hồi".

Kết quả lượt chạy đầu với production (14.10.2026, TRƯỚC khi các sửa đổi dưới đây lên): 12/12 phản hồi khớp với --samples 1; 557/557 với --samples all (7 danh sách, 211 khoá, 18 hub, 15 tin, 70 playbook, 236 sách). Hợp đồng được viết để khớp CẢ payload cũ lẫn payload mới, vì api và www deploy ở hai workflow khác nhau và không ai hứa thứ tự.

Sự cố hợp đồng đã trả lời (14.10.2026) ​

Chỗ hỏngTrông nhưThật raSửa
GET /api/public/courses/{slug} trả classes.*, products(*), padlets(*), ebooks(*), zalo_group_url; www rải nguyên payload vào HTMLTrang khoá học bình thườngLink Google Meet của một lớp (is_public = 0), giá lớp, sĩ số tối đa nằm trong HTML công khai; thêm 6 link Meet sẽ lộ ngay khi khoá của chúng mở lạiAPI chiếu đúng trường trang đọc; www dùng kết quả parse; bài kiểm test:contract soi chuỗi bí mật
GET /api/public/courses trả location của lớp onlineLịch lớp ở tokyoNgười soạn dán link Meet vào location (lớp của khoá sales); route công khailocation chỉ còn với lớp offline, và không bao giờ là một đường link
GET /api/content/ebooks (nhánh khách): IN (236 id) vượt trần 100 biến D1, lỗi bị bỏ quaKệ sách đầy đủMỌI quyển báo 0 chương, 0 quiz, không tác giả; admin "Chưa có quiz" ở mọi quyển trong khi D1 có 1.175 chương, 1.417 câu quizChia lô qua selectIn (ném khi lỗi), số đếm bằng GROUP BY
GET /api/content/ebooks (nhánh THÀNH VIÊN): ebookEngagementById buộc ?1..?238www và kiểm sống đều xanh (chúng đi nhánh khách)Mọi thành viên mở tokyo /books nhận 500, kệ trống kèm lỗiDanh sách id đi vào một biến JSON: IN (SELECT value FROM json_each(?3)); vỏ D1 của test:contract giờ chặn ở 100 biến như D1 thật
GET /api/content/ebooks/:slug/chapters/:n: .single(), gộp lỗi với "không có hàng" thành 404404Lỗi D1 ở chương 1 (www đọc cho chương 1 và MỌI trang section) thành 404, trang bị gỡ.maybeSingle(): lỗi thành 5xx, chỉ "không có hàng" là 404
Quiz playbook: 150/210 câu ghi correct, BlogQuiz đọc correctIndexQuiz hiện bình thườngỞ 50/70 bài, mọi đáp án đều bị chấm saiHợp đồng nhận cả hai, trả correctIndex; hợp nhất dữ liệu còn treo
Cộng đồng: tagline_en có trong D1 từ 09.09 nhưng API không chọnTrang /en/communities bình thườngTagline luôn tiếng ViệtAPI chọn tagline_en, name_en của khoá, description_en của sách
public/content.ts bỏ qua lỗi D1"Chưa có bài nào"Lỗi D1 thành { data: [] } 200503 qua app.onError
www /playbooks: không kiểm ok, mảng lạ thành [], catch trả rỗngThư viện trốngBốn tầng đổi lỗi thành rỗngfetchContract, 503
www /books/:slug, chương, section, /courses/:slug, /communities/:slug: mọi lỗi thành 404404Một sự cố API làm Google gỡ trangChỉ 404 thật là 404
www /reviews, /members: lỗi thành danh sách rỗngTrang không có đánh giá/reviews thành trang trống chỉ có tiêu đề ẩn503; API fb-reviews và featured-members cũng ném khi lỗi D1; 404 của endpoint danh sách cũng là 503 (assertListOk), cùng /photos
www chương/section với số rác (/chapters/abc, /sections/0.1)503API trả 401 cho mọi chương khác 1 với khách, assertOk đổi 401 thành 503Loader trả 404 trước khi gọi API
Playbook chi tiết tra theo slug không lọc trạng tháiBình thườngBản nháp có slug mở được bằng URL đoán từ tiêu đềLọc cùng tập trạng thái với danh sách
www đọc article.tags, cover_image_url, image_url, categoryLabelKhông gìAPI chưa từng gửi các trường đó; khối thẻ và nhãn danh mục chưa bao giờ hiệntsc bắt khi nối hợp đồng; gỡ khối chết, nhãn danh mục giờ hiện
Tokyo nối khoá đang học theo slugDanh mục bình thườngTiềm ẩn: 206/228 id chung mang hai slug; hôm nay 0/8 khoá có người học bị dính, khoá kế tiếp có người ghi danh trong 206 khoá đó sẽ không hiện "đang học"Nối theo id

Sửa API chỉ chặn lần lộ sau. Link Meet của lớp khoá customer-understanding (qua trang chi tiết) và link Meet dán trong location của lớp khoá sales (qua danh sách) đã nằm trong HTML và JSON công khai, ai và máy nào cũng chép được, và link vẫn vào được phòng. Hai việc chỉ chủ hệ làm được: đổi link phòng của hai lớp đó (và của 6 lớp thuộc khoá đang tắt trước khi mở lại khoá), rồi xoá cache www.conan.school/courses/* và /en/courses/* (edge giữ HTML cũ tới 24 giờ sau deploy). Tên lớp và khoá ở đây, giá trị link thì không: tài liệu về một chỗ rò không được thành chỗ rò thứ hai.

Lộ trình ​

  • Chuyển api sang chặn (trả 500 thay vì gửi phản hồi lệch) chỉ khi sổ contract_mismatch sạch liên tục đủ lâu, và Sentry đã có DSN để thấy được.
  • Hợp nhất correct/correctIndex: xong ở CMS (lô nhập playbook hợp nhất về correctIndex, --target cms ghi correctIndex). Hợp đồng cũ playbookQuizItem không sửa nữa, gỡ cùng route ở PR 13.
  • Tokyo /books và /calendars đọc endpoint đã có hợp đồng mà chưa parse.
  • Mở rộng sang các endpoint công khai còn lại (/api/public/mentors, /learner-stories, /course-units/*).

Tài liệu nội bộ nền tảng Conan School.