มาทำ Axios response interceptor ให้สามารถ re-sending FormData ได้จาก Node.js (Back-end) กันเถอะ
Developer หลายๆท่านคงเคยใช้ library Axios ในการ request ไปยัง API ต่างๆ ไม่ว่าจะเป็น Front-end to Back-end หรือ Back-end to Back-end ซึ่งนอกจาก Axios จะใช้งานง่ายด้วย code เพียงไม่กี่บรรทัดแล้ว อีกหนึ่ง feature ที่มีความ popular ของ axios ก็คือ interceptor นั่นเอง
Axios interceptor
Axios interceptor อธิบายแบบเข้าใจง่ายๆ ก็คือ functions ที่ Axios จะมีการเรียกใช้งานก่อนที่จะทำการส่ง request และหลังจากที่ได้รับ response แล้วนั่นเอง
ทีนี้ลองนึกถึงการใช้งาน interceptor เรื่องที่จะวิ่งผ่านมาในหัวเป็นอันดับแรกๆเลย ถ้าในด้าน request ก็คือการแปะ access token ไปกับ request headers และส่วนในด้าน response ก็คือการทำ refresh access token หากได้ error http status = 401 นั่นเอง
การ request API ผ่าน axios นั้นเราสามารถส่ง body ได้หลายแบบ รวมไปถึง Body ที่เป็น FormData เพื่อใช้ Upload file ด้วย
const url = process.env.API_HOST + "/files/upload"
const formData = new FormData()
formData.append("file", file, file.name)
const result = await httpClient.post(url, formData)
โดยปกติแล้ว Axois จะใช้จากฝั่ง Front-end เป็นส่วนใหญ่ และ JavaScript engine ก็ support แทบจะทุก function ก็เลยอาจจะไม่เจอปัญหาที่กำลังจะกล่าวถึงนี้
แล้วถ้า API หรือฝั่ง Back-end(API) ของเราต้องการเอาไฟล์ไป Upload ต่อไปยัง Storage services ละ ถ้าในกรณีที่เราไม่ได้ใช้ service ที่มี SDK ให้ใช้ (S3, Google) เราก็ใช้ Code เหมือนกับ Front-end ไปเลยสิ
คำตอบคือได้…แต่ไม่ซะทีเดียว
ในฝั่ง Front-end นั้น FormData สามารถเรียกใช้ได้เลยเพราะเป็น class ที่ global อยู่ที่ object window ใน browser อยู่แล้ว แต่ว่าฝั่ง Back-end ไม่มี class FormData ให้ใช้นี่สิ เราจึงต้องลง library FormData เพิ่มเติมคือ “form-data” นั่นเอง
Code ที่ Back-end เราก็จะประมาณนี้
// upload.service.ts
import FormData from "form-data"
import { httpClient } from "./http.ts"
...
async function upload(file: Express.Multer.File) {
const url = "http://storage.service.com"
const result = await httpClient.post(url, getUploadFileFormData(file))
return result.data
}
function getUploadFileFormData(file: Express.Multer.File) {
const formData = new FormData()
formData.append("storageKey", config.storageKey)
formData.append("data1", "value1")
formData.append("file", file.buffer, file.originalname)
return formData
}
และ response interceptor ก็จะเป็นประมาณนี้
// http.ts
export const httpClient = axios.create({
baseURL: "<API_BASE_URL>"
})
...
httpClient.interceptors.response.use(async (error: any) => {
if (axios.isAxiosError(error)) {
const config: AxiosRequestConfig = error.config ?? {}
const { retry = 0 } = config
// Check retry less than 3 times
if (error.response?.status === 401 && retry < 3) {
const { accessToken } = await refreshToken()
config.headers = {
...config.headers,
Authorization: `Bearer ${accessToken}`
}
config.retry = retry + 1
return httpClient.request(config)
}
}
return Promise.reject(error)
})
มาถึงตรงนี้แล้วทุกอย่างก็ดูปกติดีนี่ แล้วปัญหาคืออะไรเหรอครับ???
ทีนี้ลองเอาเรื่อง interceptor กับ request axios แบบ FormData ที่ฝั่ง Back-end มายำรวมกันดู ปัญหาที่เกิดขึ้นกับผมคือ…
เมื่อเราทำการ request ไปยัง storage service แล้วมีการ error unauthorized เกิดขึ้น interceptor เราก็จะทำการ re-authen แล้ว re-sending request อีกครั้ง
แล้วก็…บึ้ม request ค้างไปแล้วครับพี่น้อง….
What’s wrong?
เอาละถึงขั้นที่เราต้องมาสืบกัน ผมจึงต้องปลุกวิชาที่ได้จากโคนันหลังจากดูไปพันกว่าตอน เพื่อสืบหาองค์กรชายชุดดำ(ติดการ์ตูนนะเราเนี่ย ><) และแล้วก็เจอตัวผู้ร้าย…นั่นก็คือเจ้า “form-data” นั่งเอง
อ้างอิงจากลูกพี่ stackoverflow สรุปตามความเข้าใจของผม
FormData เมื่อมีการส่งไปกับ request แล้ว จำเป็นต้องมีการใช้ FormData ใหม่สำหรับการส่งไปยัง request ครั้งใหม่
พอเรารู้ปัญหาแล้วเราก็แค่ใส่ FormData ใหม่ไปยัง request ตัวใหม่ก็จบแล้ว
โดยผมจะทำการ inject function ตัวนึงเป็น optional ไปกับ config ของ Axios ซึ่งจะ return FormData ตัวใหม่ พอมีการ retry request และ data ของ request เป็น FormData เราก็จะแทนที่ config.data ด้วยค่าที่ return จาก function ตัวนี้
Code ก็จะหน้าตาประมาณนี้
// http.ts
import axios, { AxiosRequestConfig } from "axios"
import FormData from "form-data"
const httpClient = axios.create({
baseURL: "<API_BASE_URL>"
})
type MyAxiosRequestConfig = AxiosRequestConfig & {
// retry count
retry?: number
// inject generate new form data function to request config
getFormData?: () => FormData
}
...
httpClient.interceptors.response.use(async (error: any) => {
if (axios.isAxiosError(error)) {
const config: MyAxiosRequestConfig = error.config ?? {}
const { retry = 0 } = config
// Check retry less than 3 times
if (error.response?.status === 401 && retry < 3) {
const { accessToken } = await refreshToken()
config.headers = {
...config.headers,
Authorization: `Bearer ${accessToken}`
}
const { data, getFormData } = config
if (data instanceof FormData && typeof getFormData === "function") {
// Replace old form data with new form data instance base on same model
config.data = getFormData()
}
config.retry = retry + 1
return httpClient.request(config)
}
}
return Promise.reject(error)
})
// upload.service.ts
import FormData from "form-data"
import { httpClient } from "./http.ts"
...
async function upload(file: Express.Multer.File) {
const url = "http://storage.service.com"
const result = await httpClient.post(url, getUploadFileFormData(file), {
getFormData: () => getUploadFileFormData(file)
})
return result.data
}
function getUploadFileFormData(file: Express.Multer.File) {
const formData = new FormData()
formData.append("storageKey", config.storageKey)
formData.append("data1", "value1")
formData.append("file", file.buffer, file.originalname)
return formData
}
...
Let’s try again
ทีนี้หลังจากแก้ code แล้วมาลอง request อีกครั้ง
สรุป…
ด้วยการที่ภาษา JavaScript (TypeScript) นั้นเป็นภาษาที่สามารถเขียนได้ทั้ง Front-end และ Back-end หลายคนอาจจะคิดว่าหลายๆอย่างที่เราใช้ที่ Front-end ได้ก็ต้องใช้ที่ Back-end ได้เหมือนกัน ซึ่งก็ไม่ถูกทั้งหมด บางอย่างมีให้ใช้แค่เฉพาะใน Back-end บางอย่างมีให้ใช้แค่เฉพาะใน Front-end เช่นกัน ตัวอย่างเลยก็คือเจ้า FormData นี่เอง
FormData ที่มีจากฝั่ง Front-end สามารถ retry request ได้เลยโดยไม่ต้อง create instance ใหม่ ซึ่งต่างจาก library ที่นำมาใช้ในฝั่ง Back-end โดยเจ้าตัว library นี้เป็น class ที่ extends มาจาก stream.Readable แต่ตัวที่มีให้ใช้ใน Front-end นั้นเป็น class ของ form โดยแท้เลย ฉะนั้นการทำงานของทั้งสองตัวนี้เลยมีทั้งความเหมือนคือสามารถใช้ stream data ไปยัง request ได้ แต่ก็ยังมีข้อแตกต่างกันเช่นกันเหมือนกับปัญหาที่ได้เจอมานี้
ก็หวังว่าบทความนี้จะเป็นประโยชน์กับพี่น้องชาว dev และผู้ที่สนใจไม่มากก็น้อยนะครับ
My CODE will go on…