Skip to content

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:

ClientEndpoint patternไฟล์export
หลัก/api/graphql หรือ /api-uat/graphqlsrc/config/apollo.jsdefaultapolloClient
WorkForce/workforce-api/api/graphqlsrc/config/apollo-workforce.jsdefaultapolloClient_wf

ทั้งคู่ใช้ react-apollo@3.1.5 (Apollo Client v2) — ไม่ใช่ Apollo Client v3+


Request

errorLink ─────────── จัดการ GraphQL error + TOKEN_EXPIRED refresh

authLink ──────────── setContext → Authorization + apiKey

uploadLink ────────── createUploadLink → checkLinkURL()

Server
171:187:vtrc-rc-backoffice/src/config/apollo.js
const 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ที่มาหน้าที่
errorLinkonErrorแยกตาม extensions.code + refresh เมื่อ TOKEN_EXPIRED
authLinksetContext จาก apollo-link-contextแปะ Authorization + apiKey
uploadLinkcreateUploadLink จาก apollo-upload-clientHTTP สุดท้าย รองรับ multipart upload

ไม่มี createHttpLink เป็น link หลัก — ใช้ createUploadLink แทน (ครอบคลุมทั้ง query/mutation ปกติและ upload)


checkLinkURL() — เลือก endpoint ตาม origin

14:36:vtrc-rc-backoffice/src/config/apollo.js
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 endpointenv
vtrcbackoffice.redcross.or.thvtrcapi.redcross.or.th/api/graphqlprod
rc-vtrcbackoffice.redcross.or.th (alias)vtrcapi.redcross.or.th/api/graphqlprod
uat-vtrcbackoffice.redcross.or.thuat-vtrcapi.redcross.or.th/api-uat/graphqlUAT
uat-rc-vtrcbackoffice.redcross.or.th (alias)uat-vtrcapi.redcross.or.th/api-uat/graphqlUAT
localhost:3000 หรือ localhost:3001UATdev (default)
backoffice-vtrc.web.appapi.vtrcapp.com/api-dev/graphqlFirebase dev

ข้อระวัง

  1. ใช้ window.location.href.includes() — substring match ไม่ใช่ parse origin แบบเข้มงวด
  2. ไม่มี fallback return — origin ที่ไม่ตรงเงื่อนไขได้ undefined ทำให้ createUploadLink({ uri: undefined }) พัง
  3. ไม่ใช้ REACT_APP_API_URL / NODE_ENV — GraphQL เลือกตอน runtime ตาม URL ที่เปิดเท่านั้น

กฎเหล็กเมื่อเพิ่ม env

แก้ checkLinkURL() ตาม pattern เดิม — ห้าม สร้างกลไก .env คู่แข่งสำหรับ GraphQL หลัก


48:65:vtrc-rc-backoffice/src/config/apollo.js
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 }

29:41:vtrc-rc-backoffice/src/utils/auth.js
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 เท่านั้น

43:49:vtrc-rc-backoffice/src/utils/auth.js
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 / FirebaseUUID UAT

apiKey บอก backend ว่า request มาจากแอปประเภท backoffice (WEB_ADMIN ฝั่ง API) — ดู Volume 3

มี apolloFetch (createApolloFetch) ที่ตั้ง apiKey: apiKeyNews() แยกต่างหาก แต่ link chain หลักของ client ใช้ uploadLink + authLink


เมื่อได้ TOKEN_EXPIRED จะ ไม่ ยิง axios ไป /refreshToken — แต่เรียก GraphQL ผ่าน Apollo client เดิม:

67:104:vtrc-rc-backoffice/src/config/apollo.js
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

6:20:vtrc-rc-backoffice/src/config/refreshToken.js
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

extensions.codeพฤติกรรมโดยย่อ
TOKEN_EXPIREDrefresh แล้ว 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.codealertMessage ด้วย message จาก server

ข้อระวัง

  1. ไม่มี queue รวมสำหรับ Apollo refresh (axios ฝั่ง AxiosBO มี isRefreshing / subscribers)
  2. refresh token อยู่ใน localStorage/sessionStorage — XSS อ่านได้ (ดู บท 7.9)
  3. 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() ภายในไฟล์คืน:

OriginEndpoint
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.jsfactory AxiosBO + interceptor auth/refresh
axios-workforce.js.../workforce-api/api/restful
axios-workforce-upload.jsworkforce upload (multipart, setHeader: false)
axios-recruitment.js.../recruitment-api
axios-recruitment-upload.jsrecruitment upload
axios-benefit.js.../benefit-api
axios-benefit-i3.js.../benefit-api/api/v1
axios-pms.jsREACT_APP_PMS_API_END_POINT หรือ UAT default
axios-ai.jsREACT_APP_AI_RECRUITMENT_API
axios-payments.jsworkforce restful (payments)
axios-meeting.js.../meeting (สร้าง instance เอง ไม่ผ่าน AxiosBO)
axios-common.js.../vtrc-common/api/v1
axios-vtrc.jsbase host vtrcapi / uat
axios-i3.jsbase host (pattern คล้าย vtrc)
axiosAll.jsAxiosBO() ไม่บังคับ baseURL

รวม HTTP client ในแอป: 2 Apollo + 15 axios modules

เมนู dynamic ใน SiderLayout ใช้ axiosWorkforce (axios-workforce.js) — ดู บท 7.4


เปรียบเทียบกับ vtrc-web (ดู Volume 8 บท 6.3)

ด้านvtrc-rc-backofficevtrc-web
Apollo Client ตัว2 (main + WorkForce)1
Link สุดท้ายcreateUploadLinkcreateUploadLink
Auth headersetContext + getToken() + apiKeyNews()คล้ายกัน (atob(apiKeyNews()) ฝั่ง web)
RefreshQueries.refreshTokenBO ผ่าน Apollopattern refresh ผ่าน Apollo
axios modules15 ไฟล์น้อยกว่ามาก
fetchPolicy defaultno-cacheno-cache
checkLinkURLใช้ (หลาย origins รวม Firebase)ใช้

เคล็ดลับตอน debug

ดู request จริง

  1. DevTools → Network → กรอง graphql
  2. Request Headers ต้องมี Authorization: Bearer … และ apiKey: …
  3. ถ้าได้ TOKEN_EXPIRED จะเห็น query refreshTokenBO ตามมา

ถ้า endpoint ผิด

  1. ตรวจ window.location.href ว่า match สาขาใดใน checkLinkURL()
  2. ถ้าได้ undefined — ต้องเพิ่ม case ให้ origin นั้น

ขั้นตอนถัดไป

ไป บท 7.4 Routing + auth gate + dynamic menu