อ้างอิง › GraphQL
เอกสาร/อ้างอิง/GraphQL

GraphQL

หน้านี้สอนอะไร

การเพิ่ม GraphQL API ข้าง REST API: schema ที่สร้างจาก config, query, mutation, ขีดจำกัด และการเพิ่ม field ของคุณเอง

ควรอ่านก่อน: REST API และ Plugins

ทดลอง (experimental)

plugin นี้อยู่ในขั้นทดลอง ใช้เลขเวอร์ชันเดียวกับ Easy CMS ส่วนอื่น แต่ option, endpoint และข้อมูลยังอาจเปลี่ยนใน minor release ได้ แม้หลัง 1.0 เพราะไม่อยู่ใต้สัญญาความเสถียร changelog และ คู่มืออัปเกรด จะบอกว่าเปลี่ยนอะไร

@easy-cms/plugin-graphql ให้บริการ GraphQL API ที่สร้างจาก collection และ global ของคุณ อ่านและเขียนผ่าน Local API กฎสิทธิ์ สิทธิ์ระดับ field, hook, การตรวจข้อมูล, draft และหลายภาษาจึงทำงานเหมือน REST ทุกอย่าง เหมาะกับ frontend ที่ใช้ Apollo, urql หรือ Relay อยู่แล้ว หรืออยากขอเฉพาะ field ที่ต้องใช้ใน request เดียว

ติดตั้ง

npm
npm install @easy-cms/plugin-graphql graphql
pnpm
pnpm add @easy-cms/plugin-graphql graphql
yarn
yarn add @easy-cms/plugin-graphql graphql
bun
bun add @easy-cms/plugin-graphql graphql

โปรเจกต์ของคุณติดตั้ง graphql 16 หรือ 17 เอง plugin กับโค้ด extend ของคุณจึงใช้ชุดเดียวกัน

import { graphqlPlugin } from '@easy-cms/plugin-graphql'
 
export default defineConfig({
  // …
  plugins: [graphqlPlugin()],
})

endpoint อยู่ที่ <routes.api>/graphql เช่น https://example.com/api/cms/graphql รับ POST พร้อม { "query", "variables", "operationName" } และ GET พร้อม ?query= สำหรับ query (ไม่รับ mutation) เปิดใน browser นอก production จะได้ GraphiQL ถ้าล็อกอิน admin อยู่ จะอ่านได้ตามสิทธิ์ของคุณ

Schema

แต่ละ collection และ global ได้ type และ operation ตั้งชื่อตาม slug:

Collection postsGlobal site-settings
type Posttype SiteSettings
post(id): เอกสารเดียวsiteSettings: ตัว global
posts(where, sort, limit, page): เอกสารหนึ่งหน้า
createPost, updatePost, deletePostupdateSiteSettings

และ me คือผู้ใช้ที่ล็อกอินอยู่ (หรือเจ้าของ API key) collection ในตัวคือ User (user, users), Media (mediaItem, media) และ MediaFolder เมื่อเปิดโฟลเดอร์ API key และ collection ที่ Easy CMS ใช้ภายในไม่อยู่ใน schema

# `locale` เมื่อเปิด localization, `sizes` เมื่อตั้ง upload.imageSizes
query Home($category: ID!) {
  posts(where: { category: { equals: $category } }, sort: [publishedAt_DESC], limit: 5, locale: en) {
    docs {
      id
      title
      cover { url alt sizes { thumbnail { url width height } } }
      category { name }
    }
    totalDocs
    hasNextPage
  }
  siteSettings { name }
}

field แปลงเป็น:

FieldGraphQL
text, textarea, email, slugString
numberFloat
booleanBoolean
dateDateTime (ข้อความ ISO 8601)
selectenum ของตัวเลือก (เป็น String เมื่อมีตัวเลือกที่ไม่ใช่ชื่อที่ GraphQL ใช้ได้) เป็น list เมื่อ hasMany
relationship, uploadtype ของเอกสารปลายทาง เลือก field ของมันได้ เป็น list เมื่อ hasMany
groupobject type
arraylist ของ type ของแถว แต่ละแถวมี id
blockslist ของ union ของ block: ... on HeroBlock { heading }
richText, jsonJSON (rich text เป็นเอกสาร Tiptap)
field type ของคุณเองtype ที่มันสร้างทับ

ทุก field เป็น null ได้ ยกเว้น id, createdAt และ updatedAt เพราะ field อาจถูกซ่อนด้วย สิทธิ์ระดับ field หรือว่างในบางภาษาแม้จะ required field ที่ hidden: true ไม่อยู่ใน schema collection ที่มี draft มี status

กรองและเรียง

where มี type ของแต่ละ collection ใช้ operator เดียวกับ REST: equals, not_equals, in, not_in, like, exists และ gt, gte, lt, lte สำหรับตัวเลขและวันที่ รวมเงื่อนไขด้วย AND และ OR field ใน group ซ้อนเข้าไปตามโครง

posts(where: {
  OR: [{ tags: { in: [news] } }, { views: { gte: 100 } }]
  seo: { noindex: { equals: false } }
}) { totalDocs }

sort รับ list เช่น [publishedAt_DESC, title_ASC] (ค่าเริ่มต้น: ใหม่สุดก่อน) limit 1 ถึง 100 (ค่าเริ่มต้น 10) page เริ่มที่ 1

ภาษาและ draft

เมื่อเปิด หลายภาษา query รับ locale และ fallbackLocale ความสัมพันธ์อ่านในภาษาเดียวกัน ถ้าต้องการหลายภาษาพร้อมกัน ใช้ alias:

{
  th: post(id: "1", locale: th) { title }
  en: post(id: "1", locale: en) { title }
}

draft: true รวม draft ด้วย เฉพาะผู้ใช้ที่ล็อกอินและ API key เหมือน REST

Mutation

mutation {
  createPost(data: { title: "Hello", body: "First paragraph\n\nSecond", category: "3" }, draft: true) {
    id
    status
  }
}
  • create<Type>(data, locale, draft), update<Type>(id, data, locale, draft) และ delete<Type>(id) คืนเอกสาร global ใช้ update<Global>(data, locale, draft)
  • draft: true บันทึกเป็น draft, draft: false เผยแพร่ ถ้าไม่ส่ง collection ทำตามปกติของมัน
  • relationship และ upload รับ id, rich text รับเอกสาร Tiptap หรือข้อความธรรมดา (บรรทัดว่างขึ้นย่อหน้าใหม่), blocks รับแถวเป็น JSON ที่มี blockType
  • field ที่ required ต้องส่งใน input ของ create รวมถึงใน group และแถวของ array ด้วย (type สำหรับ create ชื่อ …CreateInput เช่น PostLinksRowCreateInput) ส่วน input ของ update เปลี่ยนเฉพาะที่ส่งมา
  • ไฟล์อัปโหลดผ่าน REST แล้วใช้ id ส่วนการล็อกอิน, version และการตั้งเวลาก็ใช้ REST

ใครทำอะไรได้

ยืนยันตัวตนเหมือน REST: cookie session ของ admin, Authorization: Bearer กับ session token หรือ API key ซึ่งทำได้เฉพาะที่ระบุไว้ POST จาก browser ผ่าน CSRF check เดียวกับ REST

error อยู่ใน errors พร้อมรหัสใน extensions.code แยกแบบเดียวกับ REST:

รหัสเมื่อไร
UNAUTHORIZEDยังไม่ล็อกอินในที่ที่ต้องล็อกอิน
FORBIDDENไม่มีสิทธิ์
NOT_FOUNDไม่มีเอกสารนั้น (ตอนแก้หรือลบ)
VALIDATION_ERRORข้อมูลไม่ถูกต้อง extensions.fields บอกปัญหาของแต่ละ field
BAD_USER_INPUTargument ผิดหรือ request ผิดรูป เช่น limit: 500
PAYLOAD_TOO_LARGE, UNSUPPORTED_MEDIA_TYPE, TOO_MANY_REQUESTSเหมือน REST
QUERY_TOO_DEEP, QUERY_TOO_LARGEเกินขีดจำกัด
INTERNAL_SERVER_ERRORserver มีปัญหา

เอกสารที่คุณไม่มีสิทธิ์เห็นเป็น null เหมือน REST ส่วน collection ที่อ่านไม่ได้เลยได้ UNAUTHORIZED หรือ FORBIDDEN query ที่ผิดรูปตอบ status 400 นอกนั้นตอบ 200 พร้อม errors

ขีดจำกัด

GraphQL ให้ client ขอได้มากใน request เดียว plugin จึงจำกัดไว้:

  • ความลึก: field ซ้อนกันได้ไม่เกิน 7 ชั้น (ไม่นับ introspection)
  • จำนวนเอกสาร: โหลดได้ไม่เกิน 2000 เอกสารต่อ request รวมความสัมพันธ์
  • list: limit ไม่เกิน 100

ความสัมพันธ์โหลดเป็นชุด: บทความ 100 ชิ้นกับหมวดหมู่ของมันใช้ query เดียวสำหรับบทความ และอีกหนึ่งสำหรับหมวดหมู่

ตัวเลือก

ตัวเลือกค่าเริ่มต้น
path/graphqlตำแหน่งของ endpoint ใต้ routes.api
namesจาก slugชื่ออื่นตาม slug: { news: { type: 'NewsItem', one: 'newsItem', many: 'news' } }
collectionsทั้งหมดcollection ที่อยู่ใน schema ตาม slug เช่น ['posts', 'categories']
globalsทั้งหมดglobal ที่อยู่ใน schema ตาม slug
limits{ depth: 7, documents: 2000 }
introspectionนอก productionให้ client อ่าน schema ได้ อย่างที่ GraphiQL และ codegen ที่อ่านจาก endpoint ใช้
graphiqlนอก productionแสดง GraphiQL ให้ browser
extend—query, mutation และ field ของคุณเอง

ชื่ออาจชนกันได้ เช่น collection data ที่เอกพจน์ก็คือ data แอปจะหยุดตอนเริ่มทำงานและบอกว่าต้องตั้งชื่ออะไร

Field ของคุณเอง

extend เพิ่มเข้า schema ด้วย object ของ graphql-js resolver ได้ context ที่มี cms (Local API), user และ load(collection, id) ซึ่งโหลดเป็นชุดเหมือนความสัมพันธ์ในตัว:

import { GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLString } from 'graphql'
 
graphqlPlugin({
  extend: ({ type }) => ({
    query: {
      search: {
        type: new GraphQLList(new GraphQLNonNull(type('Post'))),
        args: { text: { type: new GraphQLNonNull(GraphQLString) } },
        resolve: async (_root, { text }, { cms, user }) =>
          (await cms.find('posts', { user, overrideAccess: false, where: { title: { like: text } } })).docs,
      },
    },
    fields: {
      Post: {
        readingMinutes: {
          type: GraphQLInt,
          resolve: (post) => Math.ceil(JSON.stringify(post.body ?? '').length / 1500),
        },
      },
    },
  }),
})

ส่ง { user, overrideAccess: false } เพื่อให้ resolver ใช้กฎสิทธิ์ของผู้อ่าน

Codegen และ server อื่น

เขียน schema ลงไฟล์ให้ GraphQL Code Generator และเครื่องมืออื่นๆ:

npx easy-cms generate:graphql            # schema.graphql
npx easy-cms generate:graphql web/schema.graphql

คำสั่งนี้เปิดฐานข้อมูลเหมือนคำสั่งอื่น และใช้ได้ไม่ว่าจะเปิด introspection หรือไม่ codegen อ่าน schema จาก endpoint ที่รันอยู่ก็ได้ เมื่ออยู่นอก production หรือตั้ง introspection: true

ตัวอย่างกับ client preset และ urql:

codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli'
 
export default {
  schema: './schema.graphql', // หรือ 'http://localhost:3000/api/cms/graphql'
  documents: ['src/**/*.ts', '!src/gql/**'],
  generates: { './src/gql/': { preset: 'client' } },
} satisfies CodegenConfig
import { Client, fetchExchange } from '@urql/core'
import { graphql } from './gql'
 
const client = new Client({
  url: 'https://example.com/api/cms/graphql',
  exchanges: [fetchExchange],
  // จาก server หรือสคริปต์ ส่วนหน้าเว็บในเบราว์เซอร์ใช้ session ของผู้เยี่ยมชมแทน
  fetchOptions: { headers: { authorization: `Bearer ${process.env.EASY_CMS_API_KEY}` } },
})
 
const CreatePost = graphql(`
  mutation CreatePost($data: PostCreateInput!) {
    createPost(data: $data, draft: true) { id status }
  }
`)
const { data, error } = await client
  .mutation(CreatePost, { data: { title: 'Hello', category: '1' } })
  .toPromise()
// data.createPost.status มี type แล้ว ส่วน error.graphQLErrors[0].extensions.code บอกว่าผิดอะไร

ส่งค่าผ่าน variable แบบด้านบน เพราะ client preset จับคู่ document จากข้อความ และ escape อย่าง \n ใน query ทำให้จับคู่ไม่เจอ ชื่อ input type คือ <Type>CreateInput, <Type>UpdateInput และ <Global>Input ถ้ารันด้วย ES module ของ Node โดยตรง ให้ตั้ง config: { importExtension: '.ts' } (หรือ '.js') ที่ output

ถ้าจะให้บริการ schema จาก GraphQL server ของคุณเอง (Apollo Server, Yoga, gateway) ให้สร้าง schema แล้วให้ context ต่อ request:

import { buildGraphQLSchema, createContext } from '@easy-cms/plugin-graphql'
 
const cms = await getEasyCMS()
const schema = buildGraphQLSchema(cms.config)
// ต่อ request:
const contextValue = createContext(cms, await cms.auth.userFromHeaders(request.headers))

ขั้นต่อไป

  • REST API: อัปโหลด ล็อกอิน version และการตั้งเวลา
  • Access control: กฎที่ใช้กับ GraphQL ด้วย
  • API keys: key สำหรับ script และ server อื่น