Typescript friendly Express validator

Chung Hwan·2021년 8월 9일
2

Express

목록 보기
1/1
post-thumbnail

Form Validator

웹 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

개발

baseValidator

이 패키지를 개발하며 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();
}

modules

사실 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를 정말 확장성 있게 잘 쓸 수 있다.

implement

제일 신나는 시간이다.

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";

가져다 쓸 땐 이렇게

0개의 댓글