ข้ามไปยังเนื้อหา

ใช้งาน Headless API

TomeCMS ตอบ HTTP request สำหรับเนื้อหาที่เผยแพร่แล้วโดยไม่ต้องเข้าสู่ระบบ เว็บที่สร้างด้วยเฟรมเวิร์กอื่นหรือแอปมือถือจึงอ่านบทความและเพจชุดเดียวกับที่ธีมในตัวแสดงอยู่ได้ API นี้อ่านได้อย่างเดียว และส่งกลับเฉพาะเนื้อหาที่เผยแพร่แล้ว ฉบับร่างหรือบทความที่ตั้งเวลาไว้จะยังไม่อยู่ในนี้จนกว่าจะถึงเวลาเผยแพร่

API ใช้ได้ทั้งโหมด bundled และโหมด headless ในโหมด headless (TOME_CMS_FRONTEND_MODE=headless) TomeCMS จะไม่แสดงหน้าเว็บสาธารณะเอง เว็บที่คุณสร้างจะดึงเนื้อหาจาก API นี้ไปแสดงให้ผู้อ่านแทน

route มีเลขเวอร์ชันอยู่ใน path ตั้งแต่ 1.0.0 เป็นต้นไป /api/v1 จะเปลี่ยนเฉพาะแบบที่ client เดิมยังใช้ได้ เช่น เพิ่ม route ใหม่หรือเพิ่ม field ใหม่ การเปลี่ยนที่จะทำให้ client ใช้ไม่ได้จะได้ path ใหม่ เช่น /api/v2 ส่วนรุ่น 0.x ก่อนหน้านั้นไม่ได้รับประกันเรื่องนี้

ทุก route ตอบ GET และตอบ OPTIONS สำหรับ preflight ของเบราว์เซอร์

Route สิ่งที่ส่งกลับ
/api/v1/content/site ชื่อเว็บ คำโปรย คำอธิบาย ภาษา เขตเวลา ผู้เขียน และรูปแบรนด์
/api/v1/content/posts?locale=th บทความที่เผยแพร่แล้วในภาษาเดียว เรียงจากใหม่ไปเก่า แบ่งเป็นชุด
/api/v1/content/posts/{slug}?locale=th บทความที่เผยแพร่แล้วหนึ่งบทความ
/api/v1/content/pages?locale=th เพจที่เผยแพร่แล้วในภาษาเดียว เรียงจากใหม่ไปเก่า แบ่งเป็นชุด
/api/v1/content/pages/{slug}?locale=th เพจที่เผยแพร่แล้วหนึ่งเพจ
/api/v1/content/categories?locale=th หมวดหมู่ที่บทความซึ่งเผยแพร่แล้วใช้อยู่
/api/v1/content/navigation?locale=th เมนูส่วนหัวและส่วนท้าย
/api/v1/content/slides?locale=th สไลด์หน้าแรกที่แสดงอยู่ตอนนี้ ตามลำดับ ไม่เกินห้าสไลด์
/api/v1/content/openapi.json เอกสาร OpenAPI 3.1 ที่อธิบายทุก route ข้างบน

route ที่ส่งเนื้อหาจะวางข้อมูลไว้ใต้ data รายการบทความและรายการเพจมี meta กับ links เพิ่มมา ส่วนหมวดหมู่ เมนู และสไลด์มี meta ที่บอกภาษา บทความหรือเพจหนึ่งชิ้นมี HTML อยู่ใน contentHtml และเอกสารต้นฉบับของ editor อยู่ใน contentJson ฟิลด์ทั้งหมดดูได้ในเอกสารอ้างอิง API

slug อาจเป็นภาษาไทย เพราะบทความที่ตั้งชื่อเป็นภาษาไทยจะได้ที่อยู่เป็นภาษาไทย จึงต้อง encode ก่อนนำไปต่อเป็น path

const url = `https://cms.example.com/api/v1/content/posts/${encodeURIComponent(slug)}?locale=th`;
const { data: post } = await (await fetch(url)).json();

เอกสาร OpenAPI ไม่ได้อธิบาย node ต่าง ๆ ใน contentJson จึงอธิบาย node ของวิดีโอไว้ที่นี่ วิดีโอคือ node ชนิด video ที่มี attribute ห้าตัว

Attribute เก็บอะไร
provider youtube หรือ vimeo
videoId รหัสของคลิป ถ้าเป็น YouTube คือตัวอักษร ตัวเลข - หรือ _ 11 ตัว ถ้าเป็น Vimeo คือตัวเลข
start วินาทีที่ให้คลิปเริ่มเล่น หรือ null
title ชื่อคลิปที่ได้จาก YouTube หรือ Vimeo ยาวไม่เกิน 200 ตัวอักษร หรือเป็นสตริงว่าง
mediaId id ของภาพปกในคลังไฟล์ของเว็บ หรือ null ถ้าไม่มีภาพปก

node นี้ไม่เก็บที่อยู่ URL ไว้ ถ้าต้องใช้ ให้สร้างจาก provider กับ videoId

ใน contentHtml สีตัวอักษรที่ผู้เขียนเลือกคือ span ที่มีคลาสใดคลาสหนึ่งในหกคลาสนี้ tome-color-red tome-color-orange tome-color-green tome-color-blue tome-color-purple และ tome-color-grey คลาสบอกแค่ชื่อสี ไม่มีค่าสีมาด้วย เว็บของคุณจึงต้องกำหนดสีให้แต่ละคลาสเอง ควรมีทั้งสีสำหรับพื้นหลังสว่างและพื้นหลังมืด ถ้าไม่มี CSS นี้ ข้อความจะเป็นสีเดียวกับข้อความรอบข้าง

ใน contentHtml วิดีโอคือ figure.tome-video ลิงก์ข้างใน คือ a.tome-video__play เปิดคลิปบน YouTube หรือ Vimeo ในลิงก์มีภาพปก และมี span.tome-video__title ไว้ให้โปรแกรมอ่านหน้าจออ่าน เว็บแบบ bundled ซ่อนส่วนนี้ไม่ให้เห็นบนจอ เว็บของคุณก็ควรซ่อนด้วยสไตล์ของตัวเองเช่นกัน ส่วน figcaption บอกชื่อคลิปและผู้ให้บริการ

<figure class="tome-video"><a class="tome-video__play" href="https://www.youtube.com/watch?v=dQw4w9WgXcQ&amp;t=30" rel="noopener noreferrer"><img alt="" src="/media/66666666-6666-4666-8666-666666666666" decoding="async" loading="lazy" /><span class="tome-video__title">A clip</span></a><figcaption>A clip · YouTube</figcaption></figure>

ภาพปกอยู่ใน media ของบทความหรือเพจนั้น พร้อมที่อยู่และขนาด เหมือนภาพในเนื้อหา ถ้าแสดง contentHtml ตามที่ได้มา วิดีโอจะเป็นภาพที่ลิงก์ไปที่คลิป ถ้าอยากให้เล่นในหน้าเลย ให้เขียนตัวจัดการการคลิกเอง หรือสร้างตัวเล่นจาก contentJson เว็บแบบ bundled โหลดตัวเล่นเฉพาะตอนที่ผู้อ่านกดเล่น จาก https://www.youtube-nocookie.com/embed/<videoId>?autoplay=1 หรือ https://player.vimeo.com/video/<videoId>?dnt=1&autoplay=1 หน้าสิ่งที่เบราว์เซอร์ของผู้อ่านเก็บไว้ อธิบายเหตุผลไว้

locale มีค่าเป็น th หรือ en ทุก route ที่เนื้อหาแยกตามภาษาต้องส่งค่านี้ ได้แก่ posts, pages, categories, navigation และ slides ถ้าไม่ส่งจะได้ 400

บทความหรือเพจฉบับภาษาไทยกับฉบับภาษาอังกฤษเป็นสองชิ้นแยกกัน แต่ละชิ้นมี slug ของตัวเอง ทั้งคู่ใช้ translationGroupId เดียวกัน และแต่ละชิ้นบอกฉบับที่เผยแพร่อยู่ทั้งหมดไว้ใน translations

route ทุกตัวเข้มงวดเรื่องพารามิเตอร์ ถ้าส่งพารามิเตอร์ที่ route ไม่รู้จักจะได้ 400 พิมพ์ชื่อผิดจึงรู้ทันที ไม่ถูกปล่อยผ่านไปเงียบ ๆ ส่วน /site กับ /openapi.json ไม่รับพารามิเตอร์ใดเลย

รายการบทความและเพจส่งมาทีละชุด เรียงจากใหม่ไปเก่า limit กำหนดจำนวนต่อชุดได้ตั้งแต่ 1 ถึง 50 ถ้าไม่ส่งจะเป็น 20 ส่วน category ใช้กรองบทความให้เหลือหมวดเดียว โดยเทียบจากชื่อหมวดและไม่สนตัวพิมพ์เล็กพิมพ์ใหญ่ ส่วน q ใช้ค้นบทความ ตามที่หัวข้อค้นหาบทความอธิบาย

ถ้ายังมีชุดถัดไป meta.hasMore จะเป็น true และ links.next คือที่อยู่ของชุดนั้น พอถึงชุดสุดท้าย links.next จะเป็น null

Terminal window
curl -s 'https://cms.example.com/api/v1/content/posts?locale=th&limit=2' | jq '{meta, links}'

เรียก links.next ตามที่ได้มาได้เลย ในนั้นมี locale limit category และ q เดิมของคุณ พร้อม cursor ที่เพิ่มเข้ามา ลูปนี้พิมพ์ slug ของบทความภาษาอังกฤษทุกบทความ

Terminal window
url='https://cms.example.com/api/v1/content/posts?locale=en&limit=50'
while [ "$url" != null ]; do
body=$(curl -s "$url")
echo "$body" | jq -r '.data[].slug'
url=$(echo "$body" | jq -r '.links.next')
done

เซิร์ฟเวอร์เซ็น cursor ทุกตัว และ cursor แต่ละตัวใช้ได้กับ query ชุดเดิมที่ได้ cursor นั้นมาเท่านั้น ถ้าใช้ cursor เดิมแต่เปลี่ยน limit category หรือ q หรือแก้ cursor เอง จะได้ 400 พร้อมข้อความ Invalid pagination cursor. ลายเซ็นนี้ใช้ TOME_CMS_CONTEXT_SECRET ถ้าเปลี่ยนค่านี้ cursor ทุกตัวที่ออกไปก่อนหน้าจะใช้ไม่ได้อีก

q กรองให้เหลือบทความที่มีครบทุกคำ ในชื่อ บทคัดย่อ หรือเนื้อหา ไม่สนตัวพิมพ์เล็กพิมพ์ใหญ่ ยาวได้ไม่เกิน 100 ตัวอักษร นับแค่ห้าคำแรก และ q ที่ไม่มีคำเลยจะได้ 400 ใช้ร่วมกับ category limit และ cursor ได้ และ links.next เก็บ q ไว้ให้

คำสั่งนี้พิมพ์ชื่อบทความภาษาอังกฤษที่พูดถึง compost

Terminal window
curl -s 'https://cms.example.com/api/v1/content/posts?locale=en&q=compost' | jq -r '.data[].title'

การค้นเป็นการหาข้อความที่ปรากฏตรงตัว จึงใช้กับภาษาไทยที่ไม่เว้นวรรคระหว่างคำได้ บทความเรียงจากใหม่ไปเก่าเหมือนเดิม ไม่ได้จัดอันดับตามความตรงของคำค้น

การค้นต้องอ่านบทความทุกชิ้นของภาษานั้น จึงให้ผู้ส่งหนึ่งคนค้นได้นาทีละ 60 ครั้ง และรันพร้อมกันได้ไม่เกินสองการค้น เกินอย่างใดอย่างหนึ่งจะได้ 429 พร้อม Retry-After 60 วินาที รายการที่ไม่มี q ไม่ถูกจำกัด ผู้ส่งคือ address ที่ proxy ของเซิร์ฟเวอร์รายงานสำหรับคำขอนั้น และการค้นจากหน้าแรกนับรวมใน 60 ครั้งเดียวกัน เว็บ headless ที่ดึงข้อมูลจากเซิร์ฟเวอร์ของตัวเองจึงเป็นผู้ส่งคนเดียวของผู้เข้าชมทุกคน ให้เบราว์เซอร์ของผู้เข้าชมเรียก API เอง หรือเก็บผลที่เซิร์ฟเวอร์ดึงไว้สักนาที

คำตอบที่สำเร็จทุกครั้งมี header ETag และ Last-Modified พร้อม Cache-Control: public, max-age=60, s-maxage=60, stale-while-revalidate=300 เบราว์เซอร์หรือ CDN จึงเก็บคำตอบไว้ได้หนึ่งนาที ส่วน /openapi.json เก็บได้หนึ่งวัน

ส่ง ETag กลับมาใน If-None-Match หรือส่งเวลาจาก Last-Modified มาใน If-Modified-Since ถ้าไม่มีอะไรเปลี่ยน จะได้ 304 Not Modified แบบไม่มี body ถ้าส่งมาทั้งสองตัว เซิร์ฟเวอร์จะตัดสินจาก If-None-Match

Terminal window
etag=$(curl -si 'https://cms.example.com/api/v1/content/site' | awk -F': ' 'tolower($1) == "etag" { print $2 }' | tr -d '\r')
curl -s -o /dev/null -w '%{http_code}\n' -H "If-None-Match: $etag" 'https://cms.example.com/api/v1/content/site'
# 304

ETag คือ hash ของ body จึงเปลี่ยนทุกครั้งที่คำตอบเปลี่ยน ส่วนคำตอบที่เป็น error ส่งมาพร้อม Cache-Control: no-store และไม่ถูกแคช

คำตอบสาธารณะทุกครั้ง รวมถึง error มี Access-Control-Allow-Origin: * หน้าเว็บจาก origin ไหนก็เรียก API จากเบราว์เซอร์ได้ preflight อนุญาต GET และ OPTIONS พร้อม header If-None-Match และ If-Modified-Since และเบราว์เซอร์จำผล preflight ไว้ได้หนึ่งวัน

API นี้ไม่มีการยืนยันตัวตน อย่าส่ง credentials ไปกับคำขอ เพราะถ้าเรียกด้วย credentials: 'include' เบราว์เซอร์จะไม่รับคำตอบที่เป็น wildcard

error ส่งกลับมาเป็น application/problem+json มีฟิลด์ type title status detail instance (path ที่ขอมา) และ requestId

Status เกิดเมื่อ
400 ขาดพารามิเตอร์ ส่งพารามิเตอร์ที่ไม่รู้จักหรือค่าเกินขอบเขต หรือ cursor ไม่ถูกต้อง
404 ไม่มีบทความหรือเพจที่เผยแพร่แล้วซึ่งใช้ slug นี้ในภาษานี้
429 ค้นถี่เกินไป หรือมีการค้นอื่นรันอยู่แล้วสองรายการ รอตาม Retry-After
500 ทำคำขอให้เสร็จไม่ได้
503 เว็บยังไม่พร้อม หรือกำลังปิดปรับปรุง

คำตอบทุกครั้งมี header X-Request-ID และ log ของเซิร์ฟเวอร์บันทึก ID เดียวกันไว้ แนบ ID นี้มาด้วยเมื่อรายงานปัญหา

ทั้งหมดนี้ใช้เมื่อติดตั้ง TomeCMS แล้ว ก่อนติดตั้ง ทุก route จะตอบ 503 เป็น JSON ธรรมดาที่มี setupUrl ชี้ไปที่ /install โดยไม่มี X-Request-ID และไม่มี header CORS

ระหว่างที่เจ้าของเว็บเปิดโหมดปิดปรับปรุง route เนื้อหาจะตอบ 503 และ error จะมีฟิลด์ maintenance เพิ่มมา ซึ่งเป็นข้อความที่เจ้าของเขียนไว้ให้ผู้เข้าชม

{
"detail": "The site is closed for maintenance.",
"instance": "/api/v1/content/posts",
"maintenance": {
"backAt": "2026-10-01T09:00:00.000Z",
"heading": "Back on Thursday",
"locale": "en",
"message": "We are moving to a new server."
},
"requestId": "0b7c6a52-3f1e-4d2a-9a55-2f4c1d8e9b10",
"status": 503,
"title": "Service Unavailable",
"type": "about:blank"
}

ข้อความจะเป็นภาษาตาม locale ของคำขอ ถ้าคำขอไม่ได้ระบุ จะใช้ภาษาหลักของเว็บ ถ้าเจ้าของไม่ได้เขียนอะไรไว้ จะเป็นข้อความตั้งต้นของ TomeCMS backAt เป็น null เว้นแต่เจ้าของตั้งเวลากลับมาไว้ และระหว่างที่ยังไม่ถึงเวลานั้น คำตอบจะมี header Retry-After ด้วย ให้แสดงหัวข้อและข้อความนี้แทนหน้าเว็บของคุณจนกว่า API จะกลับมาตอบตามปกติ

/openapi.json และ draft preview ยังเปิดอยู่ ส่วน preflight ก็ยังตอบตามปกติ เบราว์เซอร์จาก origin อื่นจึงยังอ่าน 503 นี้ได้

route สาธารณะไม่ส่งฉบับร่างออกมาเลย ถ้าเจ้าของอยากดูฉบับร่างบนเว็บ headless ก่อนเผยแพร่ TomeCMS มี preview token ให้ใช้

POST /api/admin/previews พร้อม body JSON อย่าง {"contentType": "post", "contentId": "<the post's id>"} จะออก token ให้หนึ่งตัว คำขอนี้ต้องมี session ของเจ้าของ และต้องมาจาก origin ของ CMS เอง ในแอดมินไม่มีปุ่มที่เรียก route นี้ คำตอบคือ 201 พร้อม {"url": "/api/v1/content/preview/<token>"} และเมื่อ GET ที่อยู่นั้นจะได้ฉบับร่างอยู่ใน data.content ในรูปแบบเดียวกับบทความหรือเพจที่เผยแพร่แล้ว และมี data.contentType อยู่ข้างกัน

token มีอายุ 30 นาที การออก token ใหม่ให้บทความหรือเพจเดิมจะยกเลิก token ตัวก่อนหน้า ส่วน token ที่ไม่รู้จัก หมดอายุ หรือถูกยกเลิกแล้วจะได้ 404

คำตอบของ preview มี Cache-Control: private, no-store และ Referrer-Policy: no-referrer แต่ไม่มี header CORS เลย จึงไม่ถูกแคชไว้ที่ไหน และเบราว์เซอร์จาก origin อื่นอ่านไม่ได้ ให้ดึง preview จากเซิร์ฟเวอร์ของเว็บคุณ ไม่ใช่จากเบราว์เซอร์ของผู้อ่าน route ของ preview ไม่อยู่ในเอกสาร OpenAPI เพราะเอกสารนั้นอธิบายเฉพาะส่วนที่เป็นสาธารณะ

/api/v1/content/openapi.json คือสัญญาของ API เป็นเอกสาร OpenAPI 3.1 ที่บอกทุก route สาธารณะ พร้อมพารามิเตอร์และคำตอบของแต่ละ route เอกสารนี้สร้างจากโค้ดชุดเดียวกับที่ตอบคำขอ จึงอธิบายระบบที่ส่งเอกสารนั้นออกมา ใช้สร้าง client แบบมี type หรือเปิดอ่านเพื่อดูว่าเวอร์ชันของคุณรองรับอะไรบ้าง

เอกสารอ้างอิง API ในเว็บนี้สร้างจากเอกสารเดียวกันใน repository และคำอธิบาย schema ในนั้นเป็นภาษาอังกฤษ

route เดียวที่เขียนข้อมูลได้มีหน้าของตัวเอง ดูที่นับผู้อ่านจากเว็บแบบ headless