[Vapor/Swift] Request contents 검증하기

Ryan (Geonhee) Son·2021년 7월 7일
0

지난 시간까지 CRUD 기능을 구현해 보았습니다. 이번 시간에는 Request contents를 검증하는 방법을 다루어보겠습니다.

Request contents를 왜 검증하나요?

클라이언트의 요청에 따라 CRUD 기능을 수행할 때, 특히 DB의 수정 작업이 이루어지는 Create, Update, Delete 작업을 수행할 때 특정 필드가 반드시 포함되어야 한다거나 해당 필드 입력값에 대한 제한이 필요한 경우 검증 (Validation)을 적용합니다.

Vapor Validation API를 사용하면 Content API를 통해 데이터를 디코딩하기 전에 받은 요청을 검증할 수 있습니다. 사실 Vapor는 Swift의 타입 안전성을 가진 Codable 프로토콜과 통합되어 있어 동적 타입 언어에 비해 데이터 검증에 대해 많이 걱정할 필요는 없기는 하지만, 아래 이유들로 인해 검증을 사용할 가치는 충분히 있습니다.

에러가 발생한 이유를 쉽게 이해할 수 있다.

Content API를 이용하여 구조체를 디코딩하면 데이터가 적절하지 않을 경우에 에러가 발생합니다. 하지만 이런 에러 메시지는 때로는 이해하기 어려운 경우가 있죠. 예를 들어 아래 예시와 같은 상황이 있을 수 있습니다.

enum Color: String, Codable {
    case red, blue, green
}

만약 사용자가 Color 타입에 "purple"이라는 문자열을 전달하면 아래와 같은 에러를 받을 것입니다.

Cannot initialize Color from invalid String value purple for key favoriteColor

이러한 에러는 기술적으로는 잘못된 값을 입력받지 않음으로써 endpoint를 성공적으로 보호했지만 사용성은 떨어진다고 볼 수 있습니다. 이러한 방식 대신에 사용자에게 에러와 입력할 수 있는 옵션을 알리는 것이 더 나을 수 있습니다. Validation API를 사용하면 다음과 같은 에러를 보여줌으로써 사용자에게 안내 문구를 보여줄 수 있습니다.

favoriteColor is not red, blue, or green

세부적인 내용을 검증할 수 있다.

Codable은 타입 검증을 잘 다루지만, 때로는 타입 검증 이상의 것을 하기를 원할 때도 있습니다. 예를 들어, 문자열의 컨텐츠를 검증하거나 정수의 크기를 검증하는 것이 있을 수 있죠. Validation API로 이메일, 문자 세트 (character sets), 정수 범위 이상의 것을 검증할 수 있습니다.

Validatable

요청을 검증하려면 Validations 컬렉션을 만들어야 합니다. 이는 기존의 타입에 Validatable 프로토콜을 준수하게 만들면 됩니다.

아래의 예시를 통해 POST /users endpoint에 검증을 추가하는 방법을 알아보겠습니다.

enum Color: String, Codable {
    case red, blue, green
}

struct CreateUser: Content {
    var name: String
    var username: String
    var age: Int
    var email: String
    var favoriteColor: Color?
}

app.post("users") { req -> CreateUser in
    let user = try req.content.decode(CreateUser.self)
    // Do something with user.
    return user
}

검증 추가하기

첫 번째 해야할 일은 디코딩할 타입(이 경우 CreateUser)이 Validable 프로토콜을 준수하게 하는 것입니다. 이는 아래와 같이 extension을 통해 할 수 있습니다.

extension CreateUser: Validatable {
    static func validations(_ validations: inout Validations) {
        // Validations go here.
    }
}

스태틱 메서드인 validations(_:)CreateUser가 검증될 때 호출될 것입니다. 수행하기를 원하는 모든 검증을 Validations 컬렉션에 추가해주어야 합니다. 아래는 사용자가 입력한 이메일이 유효한지를 검증하는 간단한 검증을 추가하는 예시입니다.

validations.add("email", as: String.self, is: .email)

첫 번째 파라미터의 전달인자에는 값의 예상되는 키를 입력합니다. 이 경우에는 "email"입니다. 이는 검증될 타입의 프로퍼티 이름과 동일해야 합니다. 두 번째 파라미터인 as의 전달인자에는 예상되는 타입을 입력해야 합니다. 이 경우에는 String입니다. 타입은 대개는 프로퍼티의 타입과 동일하지만, 항상 그런 것은 아닙니다. 마지막으로, 세 번째 파라미터 is의 전달인자에는 하나 혹은 그 이상의 validator가 올 수 있습니다. 이 경우에는 입력된 값이 이메일 주소인지를 검증하는 하나의 validator만 추가한 모습을 확인하실 수 있습니다.

요청 컨텐츠 검증하기

타입이 Validatable 프로토콜을 준수하게 만들었다면, 스태틱 함수인 validate(content:)를 통해 request content를 검증할 수 있습니다. 아래 내용을 라우트(루트) 핸들러의 req.content.decode(CreateUser.self) 이전에 위치시키면 됩니다.

try CreateUser.validate(content: req)

이제 유효하지 않은 이메일을 포함한 요청을 보내봅시다.

POST /users HTTP/1.1
Content-Length: 67
Content-Type: application/json

{
    "age": 4,
    "email": "foo",
    "favoriteColor": "green",
    "name": "Foo",
    "username": "foo"
}

그럼 아래와 같은 에러를 확인하실 수 있습니다.

email is not a valid email address

요청 쿼리 검증하기

Validable을 준수하는 타입에는 요청의 쿼리 문자열을 검증하는 데 사용할 수 있는 validate(query:)도 있습니다. 아래 코드를 라우트(루트) 핸들러에 넣으시면 됩니다.

try CreateUser.validate(query: req)
req.query.decode(CreateUser.self)

이제 아래와 같이 쿼리 문자열에 유효하지 않은 이메일을 포함해서 요청을 보내봅시다.

GET /users?age=4&email=foo&favoriteColor=green&name=Foo&username=foo HTTP/1.1

그럼 아래와 같은 메시지를 확인하실 수 있으실 것입니다.

email is not a valid email address

정수 검증하기

이제 age에 대한 검증을 추가해봅시다.

validations.add("age", as: Int.self, is: .range(13...))

age 검증은 age가 13 이상임을 요구하고 있습니다. 만약 위에서 했던 요청을 다시 시도한다면 아래와 같은 에러를 만나시게 될 것입니다.

age is less than minimum of 13, email is not a valid email address

문자열 검증하기

다음으로 nameusername에 대한 검증을 추가해봅시다.

validations.add("name", as: String.self, is: !.empty)
validations.add("username", as: String.self, is: .count(3...) && .alphanumeric)

name 검증은 .empty 검증을 반전시키기 위해 ! 연산자를 사용했습니다. 이는 요청 시 문자열이 비어있지 않을 것을 요구하게 됩니다.

username 검증은 &&을 이용해 두 개의 validators를 결합하고 있습니다. 이는 문자열이 최소 3 개의 characters를 가지고 알파벳과 숫자만 포함(alphanumeric)할 것을 요구하게 됩니다.

열거형 검증하기

마지막으로 favoriteColor이 유효한지 체크하기 위해 조금 더 어려운 검증을 살펴보겠습니다.

validations.add(
    "favoriteColor", as: String.self,
    is: .in("red", "blue", "green"),
    required: false
)

유효하지 않은 값으로부터 Color를 디코딩할 수 없기 때문에 이 검증은 String을 베이스 타입으로 사용합니다. 값이 유효한 옵션인 red, blue, green 중에 있는지 여부를 검증하기 위해 .in validator를 사용하고 있습니다. 이 값은 옵셔널이므로 요청 데이터에 키가 없더라도 검증이 실패하지 않도록 required가 false로 설정되어 있습니다.

위의 검증은 favorite color 키가 없다면 통과하지만 null을 넣으면 통과되지 않습니다. null 입력을 지원하려면 검증 타입을 String?으로 변경하고 .nil ||을 추가해주세요.

validations.add(
    "favoriteColor", as: String?.self,
    is: .nil || .in("red", "blue", "green"),
    required: false
)

Validators

아래와 같은 Validators를 사용할 수 있습니다.

ValidationDescription
.asciiASCII 문자만 포함할 수 있음
.alphanumeric알파벳 또는 숫자 문자만 포함할 수 있음
.characterSet(_:)CharacterSet에 작성된 문자들만 포함할 수 있음
.count(_:)컬렉션 내 요소의 개수가 작성한 범위 내에 있어야 함
.email유효한 이메일을 포함해야 함
.empty컬렉션이 비어있어야 함
.in(_:)값이 작성한 Collection 안에 있어야 함
.nil값이 null이어야 함
.range(_:)값이 제공된 Range 안에 있어야 함
.url유효한 URL을 포함해야함

Validators는 아래 연산자를 사용해서 복합적으로 검증을 구성할 수 있습니다.

OperatorPositionDescription
!앞(prefix)Validator를 뒤집어 반대의 경우를 요구함
&&중간(infix)두 개의 validators를 결합하여 두 가지 모두를 요구함
||중간(infix)두 개의 validators를 결합하여 두 가지 중에 하나를 요구함

적용하기

예시 프로젝트에는 POST, PATCH 메서드로 요청을 보낼 경우 아래와 같은 검증들을 하도록 구성했습니다.

extension PostProjectItem: Validatable {
    static func validations(_ validations: inout Validations) {
        validations.add("title", as: String.self, required: true)
        validations.add("content", as: String.self, is: .count(...1000), required: true)
        validations.add("progress", as: String.self, is: .in("todo", "doing", "done"), required: true)
        validations.add("index", as: Int.self, required: true)
        validations.add("deadlineDate", as: Date.self, required: true)
    }
}

extension PatchProjectItem: Validatable {
    static func validations(_ validations: inout Validations) {
        validations.add("id", as: String.self, required: true)
        validations.add("title", as: String.self, required: false)
        validations.add("content", as: String.self, is: .count(...1000), required: false)
        validations.add("progress", as: String.self, is: .in("todo", "doing", "done"), required: false)
        validations.add("index", as: Int.self, required: false)
        validations.add("deadlineDate", as: Date.self, required: false)
    }
}

POST의 경우만 간략히 설명 드리겠습니다.

  1. "title"String 타입이어야 하고 반드시 요청에 키를 포함하여야 한다.
  2. "content"String 타입이어야 하고 길이가 0 ~ 1000 자 사이어야 하지만 반드시 요청에 키를 포함하여야 할 필요는 없다.
  3. "progress"String 타입이어야 하고 "todo", "doing", "done" 중에 하나여야 하고, 반드시 요청에 키를 포함하여야 한다.
  4. "index"Int 타입이어야 하고 반드시 요청에 키를 포함하여야 한다.
  5. "deadlineDateDate 타입이어야 하고 반드시 요청에 키를 포함하여야 한다.

앞서 보셨듯이 위의 검증을 적용하려면 HTTP 메서드 처리에 사용되는 메서드에 request 디코딩 전에 validate(content:) 메서드를 추가하시면 됩니다.

func create(req: Request) throws -> EventLoopFuture<ProjectItem> {
    try PostProjectItem.validate(content: req)
    let exist = try req.content.decode(PostProjectItem.self)
    let newProjectItem = ProjectItem(exist)
    return newProjectItem.save(on: req.db).map { (result) -> ProjectItem in
        return newProjectItem
    }
}

func update(req: Request) throws -> EventLoopFuture<ProjectItem> {
    try PatchProjectItem.validate(content: req)
    let exist = try req.content.decode(PatchProjectItem.self)
    
    return ProjectItem.find(exist.id, on: req.db)
        .unwrap(or: HTTPError.invalidID)
        .flatMap { item in
            if let title = exist.title { item.title = title }
            if let content = exist.content { item.content = content }
            if let progress = exist.progress { item.progress = progress }
            if let deadlineDate = exist.deadlineDate { item.deadlineDate = deadlineDate }
            if let index = exist.index { item.index = index }
            return item.update(on: req.db).map { return item }
        }
}

다음은?

검증을 통해 입력값에 대한 제한과 확인을 하였고, 검증 결과에 의해 요청을 받아들일 수 없는 경우 사용자에게 적절한 에러 메시지도 제공해주었습니다. 하지만 헤더의 Content-Type이 잘못되었다거나 기타 요청을 처리하는 도중 에러가 발생했을 때 우리 상황에 더 적절한 에러 메시지를 제공해줄 수도 있을 것입니다. 다음 시간에는 Vapor의 AbortError 프로토콜을 이용해 사용자가 받아보는 메시지를 커스터마이징 하는 방법에 대해 알아보겠습니다!

참고자료

profile
합리적인 해법 찾기를 좋아합니다.

0개의 댓글