Skip to content

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

17:34:vtrc-web/src/config/apollo.js
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

ข้อสังเกตสำคัญ

  1. ตรวจด้วย window.location.href.includes(...) — เป็น substring match ไม่ใช่ exact origin
  2. ถ้าเปิดผ่าน origin อื่น (เช่น IP ตรง ๆ หรือ domain สำรอง) จะได้ undefined ทำให้ new ApolloClient({ link: ... undefined ... }) ทำงานผิดพลาด
  3. ใน source code มีบรรทัด comment ที่เป็น endpoint สำรอง (backup-site/graphql) แสดงว่าเคยมีการสลับ endpoint ฉุกเฉินในอดีต
  4. ไม่ใช้ NODE_ENV หรือ REACT_APP_* — เพราะ bundle ตัวเดียวใช้กับทุก env

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

ถ้าต้องการรองรับ origin ใหม่ ให้แก้ checkLinkURL() ตาม pattern เดิม — ห้าม สร้างกลไก .env คู่แข่ง เพราะจะทำให้มีสองทางเลือก endpoint และสับสน


โครงสร้าง Apollo Client

166:184:vtrc-web/src/config/apollo.js
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 apolloClient
request ──► errorLink ──► authLink ──► uploadLink ──► server
Linkที่มาหน้าที่
errorLinkapollo.js:69-164จับ GraphQL error แยกตาม extensions.code
authLinkapollo.js:48-67แปะ Authorization: Bearer [token] + apiKey: [key]
uploadLinkapollo.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 — เป็นทางเลือกที่เคยพิจารณาแต่ปิดไว้


48:67:vtrc-web/src/config/apollo.js
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-35JWT ของ user ปัจจุบัน
apiKey: [key]atob(apiKeyNews()) — base64 decodedevice 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


69:164:vtrc-web/src/config/apollo.js
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_EXPIREDrefresh token ผ่าน apolloFetch แล้ว retry71-101
TOKEN_INVALIDalert + logout + redirect /login (ชะละ 2 วินาที)107-112
SESSION_EXPIRED, SESSION_INVALIDalert + logout + redirect /login113-121
AUTH_LIMITalert + logout + redirect /login122-128
PROFILE_INVALID, VERIFY_CODE_INVALIDmessage.error + remainingCount ถ้ามี129-136
INTERNAL_SERVER_ERRORalert "พบข้อผิดพลาด กรุณาติดต่อผู้ดูแลระบบ" + logout137-142
PROFILE_NOT_FOUNDalert "ไม่พบข้อมูล" + logout143-148
INVALID_PASSWORD_5_TIMESalert + logout149-154
อื่น ๆErrorMessageFormAPIApollo() แมป code → ข้อความไทย155-162

ทุก GraphQL error ทำงานที่นี่ที่เดียว — ห้าม เพิ่ม try/catch หรือ .catch() รอบ mutation เพื่อแสดงข้อความเอง (ยกเว้นกรณีที่ต้องการ field-level form error ใช้ ErrorMessageFormAPIApollo(graphQLErrors[0]) ตรง ๆ)

Token refresh flow (code TOKEN_EXPIRED)

71:101:vtrc-web/src/config/apollo.js
	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:

  1. ถ้า vtrc-api ตอบ TOKEN_EXPIRED → เรียก Queries.refreshToken ผ่าน apolloFetch (ไม่ใช่ apolloClient เพื่อไม่ให้วน errorLink)
  2. ถ้า refresh สำเร็จ → setToken เก็บ token ใหม่ + update header + retry operation เดิมผ่าน forward(operation)
  3. ถ้า refresh fail ด้วย TOKEN_INVALID → logout + redirect /login

ข้อควรระวัง — ใช้ apolloFetch (createApolloFetch จาก apollo-fetch library) ไม่ใช่ apolloClient เพราะ apolloClient จะวนกลับมาที่ errorLink ทำให้เกิด infinite loop


Token storage — utils/auth.js

4:35:vtrc-web/src/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

isRemembertoken เก็บที่refreshToken เก็บที่isRemember เก็บที่
truelocalStoragelocalStoragelocalStorage (เสมอ)
false หรือ undefinedsessionStoragesessionStoragelocalStorage (เสมอ)
  • localStorage เก็บข้าม tab และอยู่ข้ามวัน
  • sessionStorage เก็บเฉพาะ tab ปัจจุบัน ปิด tab แล้วหาย
  • isRemember flag เก็บใน localStorage เสมอเพื่อให้รู้ว่าต้องอ่าน token จากที่ไหนเวลา reload

clearAllStorage()

99:102:vtrc-web/src/utils/auth.js
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:

helperendpoint ตัวอย่าง (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.thmeeting service
UrlAbstainTime() (auth.js:142-151)https://vtrcdev.redcross.or.thabstain time service
UrleventLog() (auth.js:154-163).../log-event-api/api/v1/logEventevent 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/v1common service (duplicates)

ข้อควรระวัง

  1. UrlMeeting() และ UrlAbstainTime() ใน prod ชี้ไป vtrcdev.redcross.or.th (ผิดปกติ — น่าจะเป็น dev ที่ใช้จริงใน prod) ต้อง verify กับทีมก่อนแก้
  2. checkLinkUrlVtrcCustom() prod ชี้ไป uat-vtrcapi... (ชัดเจนว่าเป็น bug) — แต่ถ้าเรียกใช้ไม่ได้จะรู้ตัว
  3. มี 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"

ตรวจสอบตามลำดับ:

  1. ดู origin ใน address bar — ตรงกับที่ checkLinkURL() รองรับไหม
  2. ดู Network tab — request ไป endpoint ที่คาดไว้ไหม
  3. ดู request headers — มี Authorization และ apiKey ครบไหม
  4. ดู response — ถ้าเป็น extensions.code ให้ดูในตาราง errorLink ว่าควรเกิดอะไรขึ้น

ตอนเจอ "token refresh แล้ววนลูป"

อาจเป็นเพราะ apolloFetch เองก็ได้รับ TOKEN_EXPIRED แล้ววนกลับมา — ตรวจดูว่า apolloFetch.use() (บรรทัด 40-46) ใส่ apiKey แล้วหรือยัง และ refreshToken ที่ส่งไปยังใช้ได้ไหม

ตอนเพิ่ม error code ใหม่

  1. เพิ่มใน vtrc-api ฝั่ง resolver ก่อน (throw new ApolloError(..., { code: 'NEW_CODE' }))
  2. เพิ่ม mapping ใน utils/checkError.js:36-66 เพื่อแมป code → ข้อความไทย
  3. ถ้า behavior ไม่ใช่ "แสดงข้อความ" ให้เพิ่ม else if ใน errorLink (apollo.js:106-163)

ห้ามแยก handling ออกไปที่ .catch() ของแต่ละ mutation

ตอนที่ query ช้าเพราะ no-cache

บาง query ที่ไม่ควรเปลี่ยนบ่อย (เช่น dropdown master data) สามารถเปลี่ยน fetchPolicy เฉพาะตัวเป็น 'cache-first' ได้ — แต่ต้องแน่ใจว่า cache invalidation จะไม่เป็นปัญหา

js
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)


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

ไป บท 6.4 Routing + auth gate + menu visibility