Next.js
在Next JS的api route加上rate limit
Next.js雖然是一個frontend的框架,但也支援處理一些server request,這在Next.js 中被稱為**Route Handlers**,本篇文章首先會簡單介紹Next.js的API路由和其功能。然後,文章講解了Next.js的API路由不支援TypeScript的裝飾器模式的問題,並對裝飾器模式做了一定的介紹。最後,筆者將會提出了一種使用高階函數模擬裝飾器模式並在API路由上添加速率限制的解決方案。 本篇是針對Next.js 13之後推出的app router而非舊版的page router。在App router下要創建API路由時,我們只需要在頁面目錄中建立一個API目錄,然後在API目錄下建立 `route.ts` 文件。每個文件都相當於一個API endpoint,Next.js會針對其 `export` 出的 `GET` 、 `POST` 、 `PATCH` 、 `DELETE` 來處理這些request,詳細的文件可以參考[官方文件](https://nextjs.org/docs/app/building-your-application/routing/route-handlers)。  例如上方的資料夾架構如果我在 `route.ts` 這個檔案裏面 `export` 了以下的 `GET` `function` : ```typescript export async function GET() { const redisCheck = await check(); return Response.json( { redisCheck, }, { status: 200 }, ); } ``` 這樣當我們發 `GET` 請求到 `/api/health-check` 的時候就會拿到一個有redisCheck的response。配合新的server component或server action讓使用Next.js的開發者可以無需另外架設backend直接完成一個全端專案(有種回到php時代的感覺)。 而本站就是用Next.js進行開發的,在做某些server端功能的時候,想說需要限制使用者一段時間內的使用次數,故而想要在route handlers 上面加上 rate limit 的限制。這邊主要是想用 user 的 ip 來做為身分識別,並搭配 `redis` 作為 cache 紀錄user的使用量。所以這個rateLimit的基本邏輯是先從 `header` 裡面拿到 user 的 `ip` 再與 `method` 與 `endpointPath` 組成 `key` 並呼叫 `redis` 的 `incr` 去增加該筆 key 的值,如果 increase 後的值為1代表該筆key是新加入的,則幫他設定一個expire time,之後檢查如果該筆紀錄已經超過設定的最大值,則提早 `return 429`代表超過了rate limit。 ```typescript async function rateLimit (req: Request, max: number, window: string) { const ip = req.headers.get('x-forwarded-for') || 'no-ip'; const key = `${ip}:${method}:${endpointPath}`; const count = await incr(key); if (count === 1) { await expire(key, parseTime(window)); } if (count > max) { return new Response('Rate limit exceeded', { status: 429 }); } // rest of codes }; ``` 其實上述了邏輯稱為 Fix window counter 並不是一個好的window algorithm,例如設定 `max = 10` 、 `window = 1min` ,在第一個window的第1秒發一個request觸發counter開始計時,到第59秒時在連續發9個request,而60秒counter計時完畢,又可重新發送時,再發滿下一個window的10個request,結果就是在2秒內發了19個request。不過本篇主要是想要將rate limie函數以decorator的方式加到Nest.js的route handler上,所以先暫且用這個簡單的 window algorithm。 另外ip位址的取得在一般情況下可以從 `req.ip` 拿到,但如果有經過nginx或其他的proxy server,user的真實ip位置就會被設到 `x-forwarded-for` 這個header。到這邊為止,其實rate limit的功能已經大致完成了,但如果每次需要用的時候都要copy paste這個邏輯過去,或是在handler的最前端加上這個function,感覺有點不太好用,在維護性上面也不高。 由於之前的server端都是用 Nest.js 去寫的,所以很自然地想到了像是 `useGuard` 之類的decoration pattern,關於decorator的介紹可以看[這篇(typescript decorator)](https://hub.warrenww.com/posts/3),利用裝飾器的好處是可以簡單地加上或移除掉該限制,不過由於decorator只能加在class上面,所以需要稍微改寫一下,將原本的 `GET` 、 `POST` 等function,包進一個class中。 首先先定義一個抽象的calss`Controller`來規範之後的handler要有甚麼method,並加上default的method ```typescript export abstract class Controller { public static async GET(req: Request): Promise<Response> { // Default implementation return new Response('', { status: 404 }); } public static async POST(req: Request): Promise<Response> { // Default implementation return new Response('', { status: 404 }); } public static async PUT(req: Request): Promise<Response> { // Default implementation return new Response('', { status: 404 }); } public static async DELETE(req: Request): Promise<Response> { // Default implementation return new Response('', { status: 404 }); } static readonly path: string; } ``` 由於這邊只是為了要利用到class可以加decorator的特性,並不需要實例化,所以`Controller`裡面的method跟property都是`static`的,接著修改前面的`RateLimit` function如下 ```typescript {3,18} function RateLimit({ max, window }: Options) { return function (target: typeof Controller, propertyKey: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; descriptor.value = async function (req: Request) { const ip = req.headers.get('x-forwarded-for') || 'no-ip'; const key = `${ip}:${propertyKey}:${target.path}`; const count = await incr(key); if (count === 1) { await expire(key, parseTime(window)); } if (count > max) { return new Response('Rate limit exceeded', { status: 429 }); } const result = await originalMethod.call(this, req); return result; }; }; } ``` 採用decorator factory的方式,接收`max`跟`window`作為參數,其中`descriptor.value`是裝飾對象的屬性值,到時候這個decorator會裝飾在`GET`、`POST`...等function上,這時候value就是原本的function,先把它存起來,接者改寫原本的function,在其中加入前述的rate limit logic,其中`propertyKey`是裝飾對象的名稱,也就是`GET`、`POST`...等;而target是被裝飾的類別,也就是到時候會繼承`Controller`的subclass,我們在`Controller`中有定義了一個static的`path` property,就用做分別不同endpoint的名稱。 如果rate limit的條件通過了,就會呼叫原本存起來的方法`originalMethod`並回傳結果,反之則early return 429狀態碼回去。 這時我們就可以將這個decorator用在handler上面: ```typescript // src/api/plaground/rate-limit/route.ts import { Controller } from '@/app/api/utils/Controller'; import { RateLimit } from '@/app/api/utils/RateLimit'; class RouteHandler extends Controller { static path = 'playground/rate-limit'; @RateLimit({ max: 10, window: '1m' }) public static async GET() { return Response.json('ok', { status: 200 }); } } export const GET = RouteHandler.GET; export const dynamic = 'force-dynamic'; ``` 下面是一個playground,該endpoint設定了1分鐘內最多只能存取10次,timer會在首次發請求時開始計時,並記錄目前發送請求的次數,可以試試看10次之後的request會拿到什麼結果、如過離第一次發送請求超過了60秒,再發送會拿到什麼結果吧! <Playground data-id="RateLimit" />
2024-04-22Vitest 設定檔筆記
## 在 Vitest中使用path alias 大部分的專案會在`tsconfig.json`中設定path alias,這樣在import的時候會比較簡潔,假設今天在tsconfig中設定了如下的alias ```json "paths": { "@/*": ["./src/*"] } ``` 在寫test case時想要用同樣的alias去引入component或function進行測試,這時候就可以在`vitest.config.ts`中設定如下: ```typescript {6-8} import path from 'node:path'; import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { alias: { '@': path.resolve(__dirname, './src'), }, }, }); ``` ## 在Vitest中使用環境變數 在vitest中預設只能access到process.env中有`VITE_`前綴的環境變數,如果想要用到其他環境變數,則可以在`vitest.config.ts`中設定如下: ```typescript {6} import { defineConfig } from 'vitest/config'; import { loadEnv } from 'vite'; export default defineConfig({ test: { env: loadEnv('', process.cwd(), ''), }, }); ```
2024-04-23