8.3 · Apollo Client + checkLinkURL
บทนี้อธิบายว่า vtrc-rc-backoffice สร้าง Apollo Client อย่างไร แนบ auth header อะไรบ้าง จัดการ error อย่างไร และเลือก GraphQL endpoint อย่างไรตาม origin ของ browser
ภาพรวม Apollo Client
vtrc-rc-backoffice มี Apollo Client 2 ตัว เพราะคุยกับ GraphQL endpoint คนละ path:
| Client | Endpoint pattern | ไฟล์ | export |
|---|---|---|---|
| หลัก | /api/graphql หรือ /api-uat/graphql | src/config/apollo.js | default → apolloClient |
| WorkForce | /workforce-api/api/graphql | src/config/apollo-workforce.js | default → apolloClient_wf |
ทั้งคู่ใช้ react-apollo@3.1.5 (Apollo Client v2) — ไม่ใช่ Apollo Client v3+
ลำดับ link ของ Apollo Client หลัก
Request
↓
errorLink ─────────── จัดการ GraphQL error + TOKEN_EXPIRED refresh
↓
authLink ──────────── setContext → Authorization + apiKey
↓
uploadLink ────────── createUploadLink → checkLinkURL()
↓
Serverconst apolloClient = new ApolloClient({
link: ApolloLink.from([errorLink, authLink, uploadLink]),
cache: new InMemoryCache()
})
apolloClient.defaultOptions = {
watchQuery: {
fetchPolicy: 'no-cache',
errorPolicy: 'ignore'
},
query: {
fetchPolicy: 'no-cache',
errorPolicy: 'all'
}
}
export default apolloClient| Link | ที่มา | หน้าที่ |
|---|---|---|
errorLink | onError | แยกตาม extensions.code + refresh เมื่อ TOKEN_EXPIRED |
authLink | setContext จาก apollo-link-context | แปะ Authorization + apiKey |
uploadLink | createUploadLink จาก apollo-upload-client | HTTP สุดท้าย รองรับ multipart upload |
ไม่มี createHttpLink เป็น link หลัก — ใช้ createUploadLink แทน (ครอบคลุมทั้ง query/mutation ปกติและ upload)
checkLinkURL() — เลือก endpoint ตาม origin
export const checkLinkURL = () => {
if (window.location.href.includes("https://vtrcbackoffice.redcross.or.th/")
|| window.location.href.includes("https://rc-vtrcbackoffice.redcross.or.th/")) {
return "https://vtrcapi.redcross.or.th/api/graphql"
} else if (window.location.href.includes("https://uat-vtrcbackoffice.redcross.or.th/")) {
return "https://uat-vtrcapi.redcross.or.th/api-uat/graphql"
} else if (window.location.href.includes("https://uat-rc-vtrcbackoffice.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://localhost:3001/")) {
return "https://uat-vtrcapi.redcross.or.th/api-uat/graphql"
} else if (window.location.href.includes("https://backoffice-vtrc.web.app")) {
return "https://api.vtrcapp.com/api-dev/graphql"
}
}การจับคู่ origin → endpoint
| Origin ที่เปิด | GraphQL endpoint | env |
|---|---|---|
vtrcbackoffice.redcross.or.th | vtrcapi.redcross.or.th/api/graphql | prod |
rc-vtrcbackoffice.redcross.or.th (alias) | vtrcapi.redcross.or.th/api/graphql | prod |
uat-vtrcbackoffice.redcross.or.th | uat-vtrcapi.redcross.or.th/api-uat/graphql | UAT |
uat-rc-vtrcbackoffice.redcross.or.th (alias) | uat-vtrcapi.redcross.or.th/api-uat/graphql | UAT |
localhost:3000 หรือ localhost:3001 | UAT | dev (default) |
backoffice-vtrc.web.app | api.vtrcapp.com/api-dev/graphql | Firebase dev |
ข้อระวัง
- ใช้
window.location.href.includes()— substring match ไม่ใช่ parse origin แบบเข้มงวด - ไม่มี fallback
return— origin ที่ไม่ตรงเงื่อนไขได้undefinedทำให้createUploadLink({ uri: undefined })พัง - ไม่ใช้
REACT_APP_API_URL/NODE_ENV— GraphQL เลือกตอน runtime ตาม URL ที่เปิดเท่านั้น
กฎเหล็กเมื่อเพิ่ม env
แก้ checkLinkURL() ตาม pattern เดิม — ห้าม สร้างกลไก .env คู่แข่งสำหรับ GraphQL หลัก
การแนบ header — authLink (setContext)
const authLink = setContext((_, { headers }) => {
const { token } = getToken()
const API_DEVICE_KEY = apiKeyNews()
if (token) {
return {
headers: {
...headers,
Authorization: `Bearer ${token}`,
apiKey: API_DEVICE_KEY
}
}
}
return {
headers: {
apiKey: API_DEVICE_KEY
}
}
})getToken() — คืน { token, 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')
}
}
}JWT ที่ใส่ใน Authorization มาจาก getToken().token ไม่ใช่จาก isLoggedIn()
isLoggedIn() — คืน boolean เท่านั้น
export const isLoggedIn = () => {
if (localStorage.getItem('isRemember')) {
return !!localStorage.getItem('token') && !!localStorage.getItem('refreshToken')
} else {
return !!sessionStorage.getItem('token') && !!sessionStorage.getItem('refreshToken')
}
}ใช้ที่ auth gate ของ router (ดู บท 7.4) — ไม่ได้ใช้เป็นค่า Bearer
apiKeyNews() — คืน device apiKey ตาม origin
อยู่ที่ src/utils/apiKey.js (ไม่ใช่ getApikey() และไม่อยู่ใน auth.js):
| Origin | คืนค่า |
|---|---|
prod (vtrcbackoffice / rc-vtrcbackoffice) | UUID production |
| UAT / localhost / Firebase | UUID UAT |
apiKey บอก backend ว่า request มาจากแอปประเภท backoffice (WEB_ADMIN ฝั่ง API) — ดู Volume 3
มี apolloFetch (createApolloFetch) ที่ตั้ง apiKey: apiKeyNews() แยกต่างหาก แต่ link chain หลักของ client ใช้ uploadLink + authLink
Error handling — errorLink + refresh
เมื่อได้ TOKEN_EXPIRED จะ ไม่ ยิง axios ไป /refreshToken — แต่เรียก GraphQL ผ่าน Apollo client เดิม:
const errorLink = onError(({ graphQLErrors, operation, forward }) => {
if (graphQLErrors && graphQLErrors[0].extensions.code === 'TOKEN_EXPIRED') {
return new Observable(async observer => {
const oldHeaders = operation.getContext().headers
const { errors, data } = await apolloClient.query({
query: Queries.refreshTokenBO,
variables: {
refreshToken: getToken().refreshToken,
token: getToken().token
},
});
if (!errors) {
setToken(data.refreshTokenBO)
operation.setContext({
headers: {
...oldHeaders,
Authorization: `Bearer ${data.refreshTokenBO.accessToken}`
}
})
// ... forward(operation).subscribe(...)
} else if (errors && errors[0].extensions.code === 'TOKEN_INVALID') {
alertMessage("error", "พบข้อผิดพลาด กรุณาติดต่อผู้ดูแลระบบ")
clearAllStorage()
window.location.href = '/login'
}
})
}
// ... TOKEN_INVALID, SESSION_EXPIRED, CANNOT_ACCESS, ฯลฯ
})src/config/refreshToken.js — helper เดียวกันสำหรับ axios
export const refreshToken = () => {
return new Promise(async (resolve, reject) => {
const { errors, data } = await apolloClient.query({
query: Queries.refreshTokenBO,
variables: {
refreshToken: getToken().refreshToken,
token: getToken().token
},
});
if (!errors) {
setToken(data.refreshTokenBO)
const { accessToken } = data.refreshTokenBO
resolve({ newToken: accessToken })
}
// ...
})
}AxiosBO ใน axios-bo.js เรียก refreshToken() นี้เมื่อได้ HTTP 401 / TOKEN_EXPIRED — จึงยังเป็นการ refresh ผ่าน GraphQL refreshTokenBO ไม่ใช่ REST /refreshToken
รหัส error ที่ errorLink จัดการ (สรุป)
extensions.code | พฤติกรรมโดยย่อ |
|---|---|
TOKEN_EXPIRED | refresh แล้ว replay |
TOKEN_INVALID / INTERNAL_SERVER_ERROR / PROFILE_NOT_FOUND / AUTH_LIMIT / CANNOT_ACCESS | แจ้งเตือน → clear storage → /login |
SESSION_EXPIRED / SESSION_INVALID | แจ้งด้วย message จาก server → /login |
AUTH_USER_OR_PASS_INVALID / USER_OR_ORG_WRONG / DATA_USED | แจ้งเตือน ไม่บังคับ logout ทุกกรณี |
อื่น ๆ ที่มี extensions.code | alertMessage ด้วย message จาก server |
ข้อระวัง
- ไม่มี queue รวมสำหรับ Apollo refresh (axios ฝั่ง
AxiosBOมีisRefreshing/ subscribers) - refresh token อยู่ใน localStorage/sessionStorage — XSS อ่านได้ (ดู บท 7.9)
fetchPolicy: 'no-cache'ทั้ง query และ watchQuery
fetchPolicy: 'no-cache'
ทุก query/watchQuery ยิง server เสมอ — ข้อมูลสด แต่ไม่ใช้ InMemoryCache เพื่ออ่านซ้ำหรือ optimistic UI
Apollo Client WorkForce — ตัวที่สอง
src/config/apollo-workforce.js โครงเดียวกัน: createUploadLink + setContext + onError + Queries.refreshTokenBO
ต่างที่ checkLinkURL() ภายในไฟล์คืน:
| Origin | Endpoint |
|---|---|
prod (vtrcbackoffice / rc-vtrcbackoffice) | .../workforce-api/api/graphql |
| อื่น ๆ (รวม UAT/localhost) | uat-.../workforce-api/api/graphql |
export เป็น apolloClient_wf (default)
HTTP clients อื่น ๆ — axios 15 ไฟล์
นอกจาก Apollo 2 ตัว มีไฟล์ axios*.js ภายใต้ src/config/ 15 ไฟล์ (ชื่อจริงจาก filesystem):
| ไฟล์ | บทบาท / base (ย่อ) |
|---|---|
axios-bo.js | factory AxiosBO + interceptor auth/refresh |
axios-workforce.js | .../workforce-api/api/restful |
axios-workforce-upload.js | workforce upload (multipart, setHeader: false) |
axios-recruitment.js | .../recruitment-api |
axios-recruitment-upload.js | recruitment upload |
axios-benefit.js | .../benefit-api |
axios-benefit-i3.js | .../benefit-api/api/v1 |
axios-pms.js | REACT_APP_PMS_API_END_POINT หรือ UAT default |
axios-ai.js | REACT_APP_AI_RECRUITMENT_API |
axios-payments.js | workforce restful (payments) |
axios-meeting.js | .../meeting (สร้าง instance เอง ไม่ผ่าน AxiosBO) |
axios-common.js | .../vtrc-common/api/v1 |
axios-vtrc.js | base host vtrcapi / uat |
axios-i3.js | base host (pattern คล้าย vtrc) |
axiosAll.js | AxiosBO() ไม่บังคับ baseURL |
รวม HTTP client ในแอป: 2 Apollo + 15 axios modules
เมนู dynamic ใน SiderLayout ใช้ axiosWorkforce (axios-workforce.js) — ดู บท 7.4
เปรียบเทียบกับ vtrc-web (ดู Volume 8 บท 6.3)
| ด้าน | vtrc-rc-backoffice | vtrc-web |
|---|---|---|
| Apollo Client ตัว | 2 (main + WorkForce) | 1 |
| Link สุดท้าย | createUploadLink | createUploadLink |
| Auth header | setContext + getToken() + apiKeyNews() | คล้ายกัน (atob(apiKeyNews()) ฝั่ง web) |
| Refresh | Queries.refreshTokenBO ผ่าน Apollo | pattern refresh ผ่าน Apollo |
| axios modules | 15 ไฟล์ | น้อยกว่ามาก |
fetchPolicy default | no-cache | no-cache |
| checkLinkURL | ใช้ (หลาย origins รวม Firebase) | ใช้ |
เคล็ดลับตอน debug
ดู request จริง
- DevTools → Network → กรอง
graphql - Request Headers ต้องมี
Authorization: Bearer …และapiKey: … - ถ้าได้
TOKEN_EXPIREDจะเห็น queryrefreshTokenBOตามมา
ถ้า endpoint ผิด
- ตรวจ
window.location.hrefว่า match สาขาใดในcheckLinkURL() - ถ้าได้
undefined— ต้องเพิ่ม case ให้ origin นั้น