7.3 · Apollo Client + checkLinkURL
บทนี้เล่าว่า vtrc-web ส่ง request ไปยัง vtrc-api อย่างไร เลือก endpoint ตาม env อย่างไร จัดการ error และ token refresh อย่างไร
จุดที่ทำให้ vtrc-web แตกต่างจาก React app ทั่วไปคือ — endpoint เลือกตอน runtime ตาม URL ที่ผู้ใช้เปิด ไม่ใช่ตอน build
checkLinkURL() — runtime env resolver
const checkLinkURL = () =>{
if(window.location.href.includes("https://vtrc.redcross.or.th/")){
return "https://vtrcapi.redcross.or.th/api/graphql"
}else if(window.location.href.includes("https://uat-vtrc.redcross.or.th/")){
return "https://uat-vtrcapi.redcross.or.th/api-uat/graphql"
}else if(window.location.href.includes("http://localhost:3000/") || window.location.href.includes("http://192.168.0.105:3000/")){
return "https://uat-vtrcapi.redcross.or.th/api-uat/graphql"
}
}ตารางการ mapping
| Origin ที่ผู้ใช้เปิด | GraphQL endpoint |
|---|---|
https://vtrc.redcross.or.th/ | https://vtrcapi.redcross.or.th/api/graphql (prod) |
https://uat-vtrc.redcross.or.th/ | https://uat-vtrcapi.redcross.or.th/api-uat/graphql (uat) |
http://localhost:3000/ หรือ http://192.168.0.105:3000/ | UAT endpoint (default สำหรับ dev) |
| origin อื่น ๆ | undefined — app จะ crash ตอนสร้าง ApolloClient |
ข้อสังเกตสำคัญ
- ตรวจด้วย
window.location.href.includes(...)— เป็น substring match ไม่ใช่ exact origin - ถ้าเปิดผ่าน origin อื่น (เช่น IP ตรง ๆ หรือ domain สำรอง) จะได้
undefinedทำให้new ApolloClient({ link: ... undefined ... })ทำงานผิดพลาด - ใน source code มีบรรทัด comment ที่เป็น endpoint สำรอง (
backup-site/graphql) แสดงว่าเคยมีการสลับ endpoint ฉุกเฉินในอดีต - ไม่ใช้
NODE_ENVหรือREACT_APP_*— เพราะ bundle ตัวเดียวใช้กับทุก env
กฎเหล็กเมื่อเพิ่ม env
ถ้าต้องการรองรับ origin ใหม่ ให้แก้ checkLinkURL() ตาม pattern เดิม — ห้าม สร้างกลไก .env คู่แข่ง เพราะจะทำให้มีสองทางเลือก endpoint และสับสน
โครงสร้าง Apollo Client
const apolloClient = new ApolloClient({
link: ApolloLink.from([errorLink, authLink, uploadLink]),
cache: new InMemoryCache()
})
// cache-and-network
apolloClient.defaultOptions = {
watchQuery: {
fetchPolicy: 'no-cache',
errorPolicy: 'ignore'
},
query: {
fetchPolicy: 'no-cache',
errorPolicy: 'all'
}
}
export default apolloClientLink chain 3 ตัว
request ──► errorLink ──► authLink ──► uploadLink ──► server| Link | ที่มา | หน้าที่ |
|---|---|---|
errorLink | apollo.js:69-164 | จับ GraphQL error แยกตาม extensions.code |
authLink | apollo.js:48-67 | แปะ Authorization: Bearer [token] + apiKey: [key] |
uploadLink | apollo.js:38 (createUploadLink) | link สุดท้ายส่งไป server รองรับ multipart สำหรับ upload ไฟล์ |
fetchPolicy: 'no-cache' เป็นค่า default
ทุก query และ watchQuery ใช้ no-cache — cache ของ Apollo แทบไม่ถูกใช้ ทุกครั้งที่เรียกดูข้อมูลจะดึงใหม่จาก server
ผลกระทบ:
- ข้อมูลเสมอภาคสดทุกครั้ง (good)
- ไม่ต้อง invalidate cache (good)
- ความรู้สึกว่าแอปช้าเวลาเปลี่ยนหน้าเพราะดึงข้อมูลใหม่ทุกครั้ง (bad)
ใน source code มี comment // fetchPolicy: 'cache-and-network' อยู่ใต้บรรทัด no-cache — เป็นทางเลือกที่เคยพิจารณาแต่ปิดไว้
authLink — แปะ headers 2 ตัว
const authLink = setContext((_, { headers }) => {
const { token } = getToken()
const API_DEVICE_KEY = atob(apiKeyNews())
if (token) {
return {
headers: {
...headers,
Authorization: `Bearer ${token}`,
apiKey : API_DEVICE_KEY
}
}
}
return {
headers: {
apiKey : API_DEVICE_KEY
}
}
})| Header | ที่มา | หน้าที่ |
|---|---|---|
Authorization: Bearer [token] | getToken().token ใน utils/auth.js:23-35 | JWT ของ user ปัจจุบัน |
apiKey: [key] | atob(apiKeyNews()) — base64 decode | device key ที่ระบุ APP_TYPE: WEB |
ถ้ายังไม่ login (ไม่มี token) จะส่งแค่ apiKey อย่างเดียว — สำหรับ query สาธารณะเช่น refreshToken, generateCaptcha, sendVerifyCode
apiKey เป็น base64 ไม่ใช่ encryption
apiKeyNews() ใน utils/apiKey.js:1-12 คืน string base64 ที่ถูก hardcoded ตาม origin — ถ้า origin เป็น prod จะได้ key prod ถ้าเป็น uat จะได้ key uat
base64 decode ด้วย atob() ได้ค่าจริงทันที — ทุกคนที่เปิด DevTools จะเห็น key จริงได้ จึงไม่ใช่การเข้ารหัส แต่เป็นการ encode ไม่ให้ตรวจจับได้ง่ายเวลา grep bundle
รายละเอียดด้านความปลอดภัยใน บท 6.9
errorLink — แยกตาม extensions.code
const errorLink = onError(({ graphQLErrors, operation, forward }) => {
if (graphQLErrors && graphQLErrors[0].extensions.code === 'TOKEN_EXPIRED') {
return new Observable(async observer => {
// ... refresh token แล้ว retry ...
})
}
if(graphQLErrors){
if(graphQLErrors && graphQLErrors[0].extensions.code === 'TOKEN_INVALID'){
// logout
}else if(... === 'SESSION_EXPIRED' || ... === 'SESSION_INVALID'){
// logout
}else if(... === 'AUTH_LIMIT'){
// logout
}else if(... === 'PROFILE_INVALID' || ... === 'VERIFY_CODE_INVALID'){
// แสดงข้อความ + remainingCount
}else if(... === 'INTERNAL_SERVER_ERROR'){
// logout
}else if(... === 'PROFILE_NOT_FOUND'){
// logout
}else if(... === 'INVALID_PASSWORD_5_TIMES'){
// logout
}else{
// default → ErrorMessageFormAPIApollo()
}
}
})ตาราง error code → พฤติกรรม
| code | พฤติกรรม | บรรทัด |
|---|---|---|
TOKEN_EXPIRED | refresh token ผ่าน apolloFetch แล้ว retry | 71-101 |
TOKEN_INVALID | alert + logout + redirect /login (ชะละ 2 วินาที) | 107-112 |
SESSION_EXPIRED, SESSION_INVALID | alert + logout + redirect /login | 113-121 |
AUTH_LIMIT | alert + logout + redirect /login | 122-128 |
PROFILE_INVALID, VERIFY_CODE_INVALID | message.error + remainingCount ถ้ามี | 129-136 |
INTERNAL_SERVER_ERROR | alert "พบข้อผิดพลาด กรุณาติดต่อผู้ดูแลระบบ" + logout | 137-142 |
PROFILE_NOT_FOUND | alert "ไม่พบข้อมูล" + logout | 143-148 |
INVALID_PASSWORD_5_TIMES | alert + logout | 149-154 |
| อื่น ๆ | ErrorMessageFormAPIApollo() แมป code → ข้อความไทย | 155-162 |
กฎเหล็กของ errorLink
ทุก GraphQL error ทำงานที่นี่ที่เดียว — ห้าม เพิ่ม try/catch หรือ .catch() รอบ mutation เพื่อแสดงข้อความเอง (ยกเว้นกรณีที่ต้องการ field-level form error ใช้ ErrorMessageFormAPIApollo(graphQLErrors[0]) ตรง ๆ)
Token refresh flow (code TOKEN_EXPIRED)
if (graphQLErrors && graphQLErrors[0].extensions.code === 'TOKEN_EXPIRED') {
return new Observable(async observer => {
const oldHeaders = operation.getContext().headers
const { errors, data } = await apolloFetch({
query: Queries.refreshToken,
variables: {
refreshToken: getToken().refreshToken,
token: getToken().token
},
})
if (!errors) {
setToken(data.refreshToken)
operation.setContext({
headers: {
...oldHeaders,
Authorization: `Bearer ${data.refreshToken.accessToken}`
}
})
const subscriber = {
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer)
}
forward(operation).subscribe(subscriber)
} else if (errors && errors[0].extensions.code === 'TOKEN_INVALID') {
alertMessage("error",graphQLErrors[0].message)
clearAllStorage()
window.location.href = '/login'
}
})
}algorithm:
- ถ้า vtrc-api ตอบ
TOKEN_EXPIRED→ เรียกQueries.refreshTokenผ่านapolloFetch(ไม่ใช่ apolloClient เพื่อไม่ให้วน errorLink) - ถ้า refresh สำเร็จ →
setTokenเก็บ token ใหม่ + update header + retry operation เดิมผ่านforward(operation) - ถ้า refresh fail ด้วย
TOKEN_INVALID→ logout + redirect/login
ข้อควรระวัง — ใช้ apolloFetch (createApolloFetch จาก apollo-fetch library) ไม่ใช่ apolloClient เพราะ apolloClient จะวนกลับมาที่ errorLink ทำให้เกิด infinite loop
Token storage — utils/auth.js
export const setToken = ({ accessToken, refreshToken }, isRemember) => {
if (isRemember === undefined) {
isRemember = localStorage.getItem('isRemember')
}
if (isRemember) {
localStorage.setItem('token', accessToken)
if(refreshToken){
localStorage.setItem('refreshToken', refreshToken)
}
localStorage.setItem('isRemember', 'true')
} else {
sessionStorage.setItem('token', accessToken)
if(refreshToken){
sessionStorage.setItem('refreshToken', refreshToken)
}
}
}
export const getToken = () => {
if (localStorage.getItem('isRemember')) {
return {
token: localStorage.getItem('token'),
refreshToken: localStorage.getItem('refreshToken')
}
} else {
return {
token: sessionStorage.getItem('token'),
refreshToken: sessionStorage.getItem('refreshToken')
}
}
}กฎการเลือก storage
isRemember | token เก็บที่ | refreshToken เก็บที่ | isRemember เก็บที่ |
|---|---|---|---|
true | localStorage | localStorage | localStorage (เสมอ) |
false หรือ undefined | sessionStorage | sessionStorage | localStorage (เสมอ) |
- localStorage เก็บข้าม tab และอยู่ข้ามวัน
- sessionStorage เก็บเฉพาะ tab ปัจจุบัน ปิด tab แล้วหาย
isRememberflag เก็บใน localStorage เสมอเพื่อให้รู้ว่าต้องอ่าน token จากที่ไหนเวลา reload
clearAllStorage()
export const clearAllStorage = () => {
localStorage.clear()
sessionStorage.clear()
}logout ทำครั้งเดียว — ล้างทั้งสอง storage เพราะไม่รู้ว่า user ตอนนี้ใช้ localStorage หรือ sessionStorage
helpers อื่นใน utils/auth.js
นอกจาก token storage ยังมี helpers ที่ใช้บ่อย:
| helper | หน้าที่ |
|---|---|
isLoggedIn() (auth.js:91-97) | ตรวจ token + refreshToken ครบทั้งคู่ — ใช้ใน App/index.js:27-28 |
getLsId() (auth.js:104-106) | อ่าน lsid (login session id) |
getIsTrial() (auth.js:108-110) | อ่าน IsTrial flag |
getSessionID() (auth.js:112-114) | อ่าน sessionID |
getOrganization() (auth.js:116-119) | คืน string คงที่ "3F3BF3AD-..." (hardcoded) |
appVersion() (auth.js:170-172) | คืน '1.8.0' (hardcoded) |
versionWeb() (auth.js:174-182) | คืน label ตาม origin ('Web App version' หรือ 'UAT Web App version') |
URL resolver สำหรับ REST services อื่น
นอกจาก GraphQL endpoint (checkLinkURL() ใน apollo.js) ยังมี URL resolver สำหรับ REST services อื่นใน utils/auth.js:
| helper | endpoint ตัวอย่าง (prod) | ใช้ที่ไหน |
|---|---|---|
checkLinkURL() (auth.js:184-192) | https://vtrcapi.redcross.or.th/ | base URL ของ vtrc-api (REST) |
UrlMeeting() (auth.js:131-140) | https://vtrcdev.redcross.or.th | meeting service |
UrlAbstainTime() (auth.js:142-151) | https://vtrcdev.redcross.or.th | abstain time service |
UrleventLog() (auth.js:154-163) | .../log-event-api/api/v1/logEvent | event log service |
workforce() (auth.js:204-212) | .../workforce-api/api/restful/ | workforce service |
urlCommon() (auth.js:214-222) | .../vtrc-common/api/v1/ | common service |
urlBenefit() (auth.js:225-233) | .../benefit-api/api/v1/ | benefit service |
checkLinkUrlVtrcCustom() (auth.js:194-202) | .../vtrc-common/api/v1 | common service (duplicates) |
ข้อควรระวัง
UrlMeeting()และUrlAbstainTime()ใน prod ชี้ไปvtrcdev.redcross.or.th(ผิดปกติ — น่าจะเป็น dev ที่ใช้จริงใน prod) ต้อง verify กับทีมก่อนแก้checkLinkUrlVtrcCustom()prod ชี้ไปuat-vtrcapi...(ชัดเจนว่าเป็น bug) — แต่ถ้าเรียกใช้ไม่ได้จะรู้ตัว- มี helper ซ้ำซ้อน (
checkLinkURLใน apollo.js กับ auth.js คนละตัว) — ดูให้ดีว่าใช้ตัวไหน
refreshToken.js — wrapper สำหรับ axios 401
src/config/refreshToken.js:1-35 — เป็น promise-based wrapper รอ apolloClient.query ด้วย Queries.refreshToken ใช้ตอนที่ axios interceptor พบ 401 (ดู บท 6.8 เรื่อง axios-bo.js)
เคล็ดลับตอนทำงานจริง
ตอน debug "request ไม่ถึง server"
ตรวจสอบตามลำดับ:
- ดู origin ใน address bar — ตรงกับที่
checkLinkURL()รองรับไหม - ดู Network tab — request ไป endpoint ที่คาดไว้ไหม
- ดู request headers — มี
AuthorizationและapiKeyครบไหม - ดู response — ถ้าเป็น
extensions.codeให้ดูในตาราง errorLink ว่าควรเกิดอะไรขึ้น
ตอนเจอ "token refresh แล้ววนลูป"
อาจเป็นเพราะ apolloFetch เองก็ได้รับ TOKEN_EXPIRED แล้ววนกลับมา — ตรวจดูว่า apolloFetch.use() (บรรทัด 40-46) ใส่ apiKey แล้วหรือยัง และ refreshToken ที่ส่งไปยังใช้ได้ไหม
ตอนเพิ่ม error code ใหม่
- เพิ่มใน vtrc-api ฝั่ง resolver ก่อน (
throw new ApolloError(..., { code: 'NEW_CODE' })) - เพิ่ม mapping ใน
utils/checkError.js:36-66เพื่อแมป code → ข้อความไทย - ถ้า behavior ไม่ใช่ "แสดงข้อความ" ให้เพิ่ม
else ifใน errorLink (apollo.js:106-163)
ห้ามแยก handling ออกไปที่ .catch() ของแต่ละ mutation
ตอนที่ query ช้าเพราะ no-cache
บาง query ที่ไม่ควรเปลี่ยนบ่อย (เช่น dropdown master data) สามารถเปลี่ยน fetchPolicy เฉพาะตัวเป็น 'cache-first' ได้ — แต่ต้องแน่ใจว่า cache invalidation จะไม่เป็นปัญหา
this.props.client.query({
query: Queries.dropdownListProvince,
fetchPolicy: 'cache-first', // override default 'no-cache'
variables: { ... }
})อย่าอ่าน localStorage ตรง ๆ ใน component
ใช้ getToken(), setToken(), clearAllStorage() จาก utils/auth.js เสมอ — ถ้าอ่าน localStorage.getItem('token') ตรง ๆ จะไม่รองรับกรณีที่ user เลือก isRemember: false (เก็บใน sessionStorage)