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.tscòn rỗng), nên mọicaptureMessage/captureExceptioncủ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ủaconan-api-gatewayvà của Pagesww3-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 đồng | Endpoint | Ai đọc |
|---|---|---|
courseList | GET /api/public/courses | www /courses, www sitemap-courses, tokyo /calendars |
courseDetail | GET /api/public/courses/{slug} | www /courses/:slug |
communityList | GET /api/public/communities | www /communities, www sitemap-community |
communityHub | GET /api/public/communities/{slug} | www /communities/:slug |
whatsNew | GET /api/public/whats-new | www /whats-new, tokyo /whats-new |
whatsNewLatest | GET /api/public/whats-new/latest | tokyo, chấm "chưa đọc" ở thanh bên |
newsList (cũ) | GET /api/public/content/news | khô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/playbooks | www /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 |
ebookList | GET /api/content/ebooks | www /books, www sitemap-books, tokyo /books (chưa parse) |
ebookDetail | GET /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:
// 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:
"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_mismatchkè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
anycũ, nêntscbắ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 | Ở đâu | Làm gì khi lệch | Vì sao chế độ đó |
|---|---|---|---|
| Bóng | api.conan.school/src/shared/contract.ts, shadowContract() | Ghi console.error có cấu trúc + captureMessage, vẫn gửi phản hồi | Mộ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ành | www.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ống | scripts/contract-live.mjs | Liệt kê từng chỗ lệch, exit 1 | Ngay 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:
| Endpoint | Hàm | 404 của API thành |
|---|---|---|
| Chi tiết (một bản ghi theo slug) | assertOk | 404 của trang |
Danh sách (/reviews, /members, /photos) | assertListOk | 503 (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
- Đọc payload thật.
curl -s https://api.conan.school/<đường>(đọc API công khai là được phép), và đọc handler. Ghinullable/nullishtheo 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. - 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àocourseDetaillà quyết định đưa nó ra HTML công khai. - Khai vào
ENDPOINTS(vàSAMPLE_ROWStrongscripts/contract-live.mjsnếu là danh sách có trang chi tiết lấy mẫu từ nó). - 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émHTTPException(503)), không thành{ data: [] }. - Parse ở frontend: www
fetchContract(url, 'tênHợpĐồng', 'nhãn', { notFoundIs404 }); tokyoparseContract(...)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 đó. - Chạy:
npm run typecheckở api và site,npm run test:contractở api, vànode --experimental-strip-types scripts/contract-live.mjs --samples allvới production.
Chạy
# 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:contractcheckContracts({ 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ỏng | Trông như | Thật ra | Sửa |
|---|---|---|---|
GET /api/public/courses/{slug} trả classes.*, products(*), padlets(*), ebooks(*), zalo_group_url; www rải nguyên payload vào HTML | Trang khoá học bình thường | Link 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ại | API 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 online | Lịch lớp ở tokyo | Người soạn dán link Meet vào location (lớp của khoá sales); route công khai | location 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ỏ qua | Kệ 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 quiz | Chia 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..?238 | www 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ỗi | Danh 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 404 | 404 | Lỗ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 correctIndex | Quiz hiện bình thường | Ở 50/70 bài, mọi đáp án đều bị chấm sai | Hợ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ọn | Trang /en/communities bình thường | Tagline luôn tiếng Việt | API 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: [] } 200 | 503 qua app.onError |
www /playbooks: không kiểm ok, mảng lạ thành [], catch trả rỗng | Thư viện trống | Bốn tầng đổi lỗi thành rỗng | fetchContract, 503 |
www /books/:slug, chương, section, /courses/:slug, /communities/:slug: mọi lỗi thành 404 | 404 | Một sự cố API làm Google gỡ trang | Chỉ 404 thật là 404 |
www /reviews, /members: lỗi thành danh sách rỗng | Trang không có đánh giá | /reviews thành trang trống chỉ có tiêu đề ẩn | 503; 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) | 503 | API trả 401 cho mọi chương khác 1 với khách, assertOk đổi 401 thành 503 | Loader trả 404 trước khi gọi API |
| Playbook chi tiết tra theo slug không lọc trạng thái | Bình thường | Bả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, categoryLabel | Khô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ện | tsc 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 slug | Danh mục bình thường | Tiề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 |
Link đã lộ thì chặn rò không thu hồi được
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_mismatchsạch liên tục đủ lâu, và Sentry đã có DSN để thấy được. Hợp nhất: xong ở CMS (lô nhập playbook hợp nhất vềcorrect/correctIndexcorrectIndex,--target cmsghicorrectIndex). Hợp đồng cũplaybookQuizItemkhông sửa nữa, gỡ cùng route ở PR 13.- Tokyo
/booksvà/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/*).