웹 api 서버에서 사용자 입력 검증은 매우 중요하다. 사용자가 개발자의 의도를 벗어지 않도록 제한함으로써 불필요한 예외 상황을 줄일 수 있고, 무엇보다 보안을 위해 필수적이다.
이를 위해 express-validator라는 패키지가 이미 존재하지만, 미들웨어를 request body의 field마다 주루룩 이어줘야하는 것이 지저분하다고 느껴졌다. 또 Typescript를 사용할 경우 form마다 interface를 정해줄 수 있을 텐데, 이 interface를 활용해서 validation을 하면 더 깔끔하지 않을까 하는 생각이 들었다.
그래서 직접 만들어보기로 했다.
만들고 보니 맘에 들어서 npm에 배포했다.
소스코드: github
나는 이런식으로 쓸 수 있는 validator를 만들고 싶었다.
interface UserEditForm {
username: string;
email: string;
bio?: string;
socialMediaLinks?: {
twitter: string;
instagram: string;
};
websiteURLs?: string[];
heroImgURL?: string;
avatarImgURL?: string;
}
baseValidator<UserEditForm>({
username: [notEmpty, safeUsername],
email: [notEmpty, safeEmail],
bio: [maxLength(100)],
socialMediaLinks: [],
websiteURLs: [maxLength(3)],
heroImgURL: [],
avatarImgURL: [],
}); // validator middleware
이 패키지를 개발하며 Typescript의 Generic에 대해 많이 공부할 수 있었다.
export interface ValidationResult {
success: boolean;
error?: string;
}
function baseValidator<T = { [key: string]: string }>(
validators: { [key in keyof T]: ((value: string, form: T) => ValidationResult)[] },
req: Request,
res: Response,
next: NextFunction
)
우선 from의 interface를 type argument로 받는다. form이므로 key, value모두 string
이다. 그 다음 인자로 validators
를 받는데, form의 key마다 validation을 위한 함수를 받는다. 이 함수의 첫번째 인자는 key에 해당하는 form의 value이고, 두번째 인자는 form 전체이다(두번째 인자에 대한 이유는 뒤에 설명하겠다). 이 함수의 return 값은 valid 여부와 error message이다.
const validatorKeys: string[] = Object.keys(validators);
const errors: { [key: string]: string }[] = [];
validatorKeys.forEach((key) => {
validators[key as keyof T].forEach((validator) => {
const result = validator(req.body[key], req.body);
if (!result.success && result.error) errors.push({ [key]: result.error });
});
});
form(T
)의 key마다 validators를 참조하여 validation을 실행한다. valid하지 않을 경우 에러 정보를 저장한다.
if (errors.length > 0) return res.status(400).json({ errors: errors });
else next();
에러가 발생했을 경우 에러와 함께 400
응답을 보내고 그렇지 않을 경우 controller로 요청을 넘긴다.
전체 코드다.
function baseValidator<T = { [key: string]: string }>(
validators: { [key in keyof T]: ((value: string, form: T) => ValidationResult)[] },
req: Request,
res: Response,
next: NextFunction
) {
const validatorKeys: string[] = Object.keys(validators);
const errors: { [key: string]: string }[] = [];
validatorKeys.forEach((key) => {
validators[key as keyof T].forEach((validator) => {
const result = validator(req.body[key], req.body);
if (!result.success && result.error) errors.push({ [key]: result.error });
});
});
if (errors.length > 0) return res.status(400).json({ errors: errors });
else next();
}
사실 baseValidator
로 거의 끝이다. 나머지는 여기에 끼워줄, validation 함수를 만들어주면는게 전부다.
export const notEmpty = (value: string | undefined): ValidationResult => {
if (value) return { success: true };
return { success: false, error: "Empty." };
};
export const safeUsername = (value: string): ValidationResult => {
if (/^[a-z0-9_]{1,15}$/i.test(value)) return { success: true };
return { success: false, error: "Invalid username." };
};
export const safeEmail = (value: string): ValidationResult => {
if (/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(value)) return { success: true };
return { success: false, error: "Invalid email." };
};
딱 보면 뭐하는지 알 수 있는 validation 함수들이다.
export const maxLength =
(length: number) =>
(value: string): ValidationResult => {
if (value.length <= length) return { success: true };
return { success: false, error: "Too long." };
};
적당히 curried function을 이용하는 것도 좋다.
export const pass = (value: string): ValidationResult => {
return { success: true };
};
validations를 빈 array로 두기 싫다면 이런 module을 이용할 수 있다.
export const exact =
(exact: string) =>
(value: string): ValidationResult => {
if (exact == value) return { success: true };
return { success: false, error: `Must be ${exact}.` };
};
export const startsWith =
(start: string) =>
(value: string): ValidationResult => {
if (value.startsWith(start)) return { success: true };
return { success: false, error: `Must start with ${start}.` };
};
export const oneOf =
(validValues: string[]) =>
(value: string): ValidationResult => {
if (validValues.includes(value)) return { success: true };
return { success: false, error: `Must be one of ${validValues}.` };
};
curried function을 잘 쓰면 이 validator를 정말 확장성 있게 잘 쓸 수 있다.
제일 신나는 시간이다.
export const profileValidator = (req: Request, res: Response, next: NextFunction) =>
baseValidator<UserEditForm>(
{
username: [notEmpty, safeUsername],
email: [notEmpty, safeEmail],
bio: [],
socialMediaLinks: [],
websiteURLs: [maxLength(3)],
heroImgURL: [],
avatarImgURL: [],
},
req,
res,
next
);
이렇게 하여 가독성 좋은 validation middleware를 만들 수 있다.
route.post("/edit", isAuth, profileValidator, UserController.updateUser);
라우터에 이렇게 달아주면 끝이다.
export const imageUploadValidator = (req: Request, res: Response, next: NextFunction) =>
baseValidator<Express.Multer.File>(
{
fieldname: [exact("image")],
originalname: [pass],
encoding: [pass],
mimetype: [startsWith("image")],
destination: [exact(config.upload.images)],
filename: [pass],
path: [pass],
size: [pass],
stream: [pass],
buffer: [pass],
},
req,
res,
next
);
이런 것도 가능하다. 와우!
만들고 보니 생각보다 맘에 들어 npm으로 배포했다.
$ npm install express-validator-middleware
이렇게 설치할 수 있다.
import baseValidator, { safeUsername, safeEmail } from "express-validator-middleware";
가져다 쓸 땐 이렇게