Next.js 는 백엔드 API 쪽에 대한 지원이 참 빈약하다. Logger 도 기본 제공이 되지 않고, 라우팅도 HTTP Method 에 따라 분기해줄 수 있는 app.post
과 같은 함수도 없다. 2022년에 if
또는 switch
문을 써서 req.method === 'POST'
를 검사해 분기를 해주어야 하다니..
또 하나 빠져있는 주요한 기능이 Request Body 에 대한 검사다. 다행히 header의 Content-Type을 검사해서 request.body
객체를 만들어주는 건 해준다. 빠진 건, 이 Request body 의 타입을 검사하는 부분이다.
이전 글인 Go 언어로 HTTP API 서버에 필요한 것들에서 소개한 go-validator 같은 라이브러리가 JavaScript 에도 있다. ajv 라는 라이브러리인데, 주간 다운로드 수도 8,000만이 넘고, express 의 뒤를 잇는 차세대 웹 서버 프레임워크인 Fastify에서도 Request Body 를 파싱하는데 사용하고 있다.
ajv의 타입 검사는 크게 다음 단계로 진행된다.
new Ajv()
를 통해 validator
객체를 만든다.validator
객체는 compile
이라는 함수를 갖고 있는데, 이 함수는 미리 자바스크립트 객체로 작성된 Schema 를 입력받아 해당 Schema 를 검사하는 validate
함수를 출력값으로 준다.-- 여기까지는 전역변수로 미리 정의해둔다. 아래 부분은 API 가 호출 될 때마다 실행하면 된다 --
validate
함수에 검사하고자 하는 객체를 넣는다. 출력값으로 true
또는 false
가 나온다.false
가 출력될 경우, 검사에 실패하고 뭔가 에러가 있다는 말이다. 해당 에러들은 validate.errors
를 통해 접근할 수 있다. validate
는 함수이지만, errors
라는 프로퍼티도 갖고 있다. 아주 자바스크립트다운(?) (거지같은) 구현이다. 이 errors
배열의 아이템은 객체인데, message
프로퍼티가 예쁘게 에러 메세지를 만들어준다.코드를 보면 바로 이해가 된다.
import Ajv, { ValidateFunction, Schema } from 'ajv'
// 1. validator 객체를 만든다.
const validator = new Ajv()
// 2. Schema 는 미리 정의되어 있다.
const createSessionBodySchema: Schema = {
type: 'object',
properties: {
idToken: { type: 'string' },
},
required: ['idToken'],
}
// 2. compile 을 하여, validate 함수를 만들어준다.
const validate = validator.compile(createSessionBodySchema)
async function createSession(req: NextApiRequest, res: NextApiResponse) {
// 3. validate 함수에 검사하고자 하는 req.body 를 넣어준다.
if (!validate(req.body)) {
// 4. 반환값이 false 일 경우, errors 프로퍼티를 이용하여 적절한 에러 메세지를 출력해준다.
res.status(400)
throw new Error(validate.errors?.map((error) => error.message).join(','))
}
}
여기서 Schema 정의는 JSON Schema(널리 쓰임), JSON Type Definition(표준임, RFC8927) 모두 지원한다. 링크를 참고하자.
validate
함수는 깔끔하게 true
, false
만 반환하기 때문에 TypeScript 의 user defined type guard 와 조합하면 request.body
를 타이핑할 수도 있다. 아래와 같은 함수를 만들어보자.
function parseBody<T>(validate: ValidateFunction, body: unknown): body is T {
return validate(body)
}
parseBody
함수는 validate
함수와 검사하고자 하는 객체 body
를 받아서, 만약 validate
결과가 true
라면, body
객체가 generic으로 지정된 T
타입 임을 확인한다.
이 함수를 이용하여 위의 코드를 고치면 다음과 같다.
import Ajv, { ValidateFunction, Schema } from 'ajv'
const validator = new Ajv()
const createSessionBodySchema: Schema = {
type: 'object',
properties: {
idToken: { type: 'string' },
},
required: ['idToken'],
}
interface CreateSessionBody {
idToken: string
}
const validate = validator.compile(createSessionBodySchema)
async function createSession(req: NextApiRequest, res: NextApiResponse) {
// 3. validate 함수에 검사하고자 하는 req.body 를 넣어준다.
if (!parseBody<CreateSessionBody>(validateCreateSession, req.body)) {
res.status(400)
throw new Error(validate.errors?.map((error) => error.message).join(','))
}
// req.body 가 parseBody 함수에 의해 이미 type guard 되었으므로, idToken 은 별도로 타입 지정을 안해줘도 자동으로 string 으로 인식한다.
const idToken = req.body.idToken
...
}
여기서 코드를 좀 더 분할하면, 아래와 같이 두 파일로 분리하여 리팩토링할 수 있겠다.
// services/ajv.ts
import Ajv, { ValidateFunction } from 'ajv'
export const validator = new Ajv()
export function parseBody<T>(validate: ValidateFunction, body: unknown): body is T {
return validate(body)
}
// pages/api/users/[userId]/sessions.ts
import { NextApiRequest, NextApiResponse } from 'next'
import { withIronSessionApiRoute } from 'iron-session/next'
import { Schema } from 'ajv'
import { parseBody, validator } from 'services/ajv'
import { sessionMaxAge, sessionOptions } from 'services/session'
...
export default withIronSessionApiRoute(async (req: NextApiRequest, res: NextApiResponse) => {
switch (req.method) {
case 'POST':
await createSession(req, res)
return
...
default:
respond(res.status(404))
}
}, sessionOptions)
const createSessionBodySchema: Schema = {
type: 'object',
properties: {
idToken: { type: 'string' },
},
required: ['idToken'],
}
interface CreateSessionBody {
idToken: string
}
const validateCreateSession = validator.compile(createSessionBodySchema)
async function createSession(req: NextApiRequest, res: NextApiResponse) {
try {
if (!parseBody<CreateSessionBody>(validateCreateSession, req.body)) {
res.status(400)
throw new Error(validateCreateSession.errors?.map((error) => error.message).join(','))
}
const idToken = req.body.idToken
...
} catch (err) {
...
}
}