이전 포스팅의 말미에 잠시 언급 드렸듯이 검증 이외에도 클라이언트 요청에 따라 서버에서 작업을 수행하다 에러가 일어나는 경우가 있습니다. 이런 경우에 서버에서 기본적으로 지정된 HTTP status code와 에러 메시지를 response로 반환하게 되지만 이해하기 어려운 경우가 많습니다. 예를 들어 "Something went wrong!"
, "Bad Request"
와 같은 에러 메시지를 받으면 도대체 무엇이 잘못 되었는지 알 수 없는 경우가 많습니다. 그래서 이번 시간에도 이전 포스팅의 순서와 같이 Vapor의 에러와 관련한 내용을 먼저 알아보고 적용하는 모습을 살펴보도록 하겠습니다.
Vapor는 에러 처리를 위해 Swift의 Error
프로토콜을 사용합니다. 라우트(루트) 핸들러는 에러를 throw
하거나 실패한 EventLoopFuture
를 반환할 수 있습니다. Swift의 Error
를 던지거나 반환하면 500
상태 코드 응답과 함께 에러가 기록되는 결과를 낳습니다. AbortError
과 DebuggableError
를 통해 응답과 기록을 각각 변경할 수 있습니다. 에러 처리는 ErrorMiddleware
에 의해 수행되는데, 이 middleware는 애플리케이션에 기본적으로 추가되어 있고 원할 경우 사용자 지정 로직으로 교체할 수 있습니다.
Vapor는 Abort
라는 이름의 구조체로 기본 에러를 제공합니다. 이 구조체는 AbortError
과 DebuggableError
프로토콜을 모두 준수하고 있고, 필요에 따라 HTTP 상태와 failure에 대한 원인을 이니셜라이즈 할 수 있습니다.
// 404 error, default "Not Found" reason used.
throw Abort(.notFound)
// 401 error, custom reason used.
throw Abort(.unauthorized, reason: "Invalid Credentials")
flatMap
클로저와 같이 throwing이 지원되지 않는 비동기 상황에서는 failed future를 반환할 수 있습니다.
guard let user = user else {
req.eventLoop.makeFailedFuture(Abort(.notFound))
}
return user.save()
Vapor는 옵셔널 값을 가진 future를 언래핑하기 위한 도우미 익스텐션으로 unwrap(or:)
메서드를 가지고 있습니다.
User.find(id, on: db)
.unwrap(or: Abort(.notFound))
.flatMap
{ user in
// Non-optional User supplied to closure.
}
만약 User.find
가 nil
을 반환한다면, future는 실패하며 지정된 에러의 이유를 보여줄 것입니다. 그렇지 않은 경우에는 flatMap
에게 옵셔널이 아닌 값을 제공해줄 것입니다.
기본적으로 라우트(루트) 클로저가 던지거나 반환한 모든 Swift Error
는 500 Internal Server Error
응답으로 처리됩니다. 디버그 모드로 빌드된 경우에는 ErrorMiddleware
가 에러 설명을 포함해줍니다. 이는 프로젝트가 release mode로 빌드되면 보안 상의 이유로 완전히 제거됩니다.
특정 에러에 대해 HTTP 응답 상태 또는 원인을 구성하고자 한다면 AbortError
를 준수하면 됩니다.
import Vapor
enum MyError {
case userNotLoggedIn
case invalidEmail(String)
}
extension MyError: AbortError {
var reason: String {
switch self {
case .userNotLoggedIn:
return "User is not logged in."
case .invalidEmail(let email):
return "Email address is not valid: \(email)."
}
}
var status: HTTPStatus {
switch self {
case .userNotLoggedIn:
return .unauthorized
case .invalidEmail:
return .badRequest
}
}
}
Skip 하셔도 되는 구간입니다.
ErrorMiddleware
은 루트에서 던져진 에러를 기록하기 위해 Logger.report(error:)
메서드를 사용합니다. 이 메서드는 읽을 수 있는 메시지를 기록하기 위해 CustomStringConvertible
및 LocalizedError
의 채택 여부를 체크할 것입니다.
에러 기록 작업을 커스터마이징 하기를 원한다면 에러 타입이 DebuggableError
을 준수하게 만들면 됩니다. 이 프로토콜은 고유 식별자, 소스 위치, 스택 추적과 같은 유용한 프로퍼티들을 포함하고 있습니다. 대부분의 프로퍼티들은 준수를 용이하게 만들기 위해 선택적으로 사용할 수 있도록 설정되어 있습니다.
DebuggableError
를 잘 준수하면
DebuggableError
를 가장 잘 준수하려면 에러 타입이 구조체여야 하며 필요한 경우에는 소스 및 스택 추적 정보를 저장할 수 있어야 합니다. 아래는 구조체 및 에러 소스 정보를 캡처하고 사용하기 위해 업데이트된 MyError
열거 타입의 예시입니다.
import Vapor
struct MyError: DebuggableError {
enum Value {
case userNotLoggedIn
case invalidEmail(String)
}
var identifier: String {
switch self.value {
case .userNotLoggedIn:
return "userNotLoggedIn"
case .invalidEmail:
return "invalidEmail"
}
}
var reason: String {
switch self.value {
case .userNotLoggedIn:
return "User is not logged in."
case .invalidEmail(let email):
return "Email address is not valid: \(email)."
}
}
var value: Value
var source: ErrorSource?
init(
_ value: Value,
file: String = #file,
function: String = #function,
line: UInt = #line,
column: UInt = #column
) {
self.value = value
self.source = .init(
file: file,
function: function,
line: line,
column: column
)
}
}
DebuggableError
는 발생한 에러의 디버그 용이성을 향상시켜줄 수 있는 possibleCauses
및 suggestedFixes
와 같은 몇 가지 다른 프로퍼티들을 가지고 있습니다. 더 많은 정보는 프로토콜 자체에 대해 살펴보시면 됩니다.
Vapor에는 정상적인 Swift 오류와 충돌에 대한 스택 추적를 볼 수 있는 기능이 포함되어 있습니다.
Vapor는 SwiftBacktrace 라이브러리를 사용하여 Linux에서 치명적인 에러 또는 assertion이 발생한 후 스택 추적을 제공합니다. 이 기능이 작동하려면 아래와 같이 컴파일 중에 앱에 디버그 기호가 포함되어 있어야 합니다.
swift build -c release -Xswiftc -g
기본적으로 Abort
는 이니셜라이즈 되었을 때 현재 스택 추적을 캡처합니다. 사용자 지정 에러 타입들은 DebuggableError
을 채택하고 StackTrace.capture()
를 가지게 함으로써 이 작업을 수행할 수 있습니다.
import Vapor
struct MyError: DebuggableError {
var identifier: String
var reason: String
var stackTrace: StackTrace?
init(
identifier: String,
reason: String,
stackTrace: StackTrace? = .capture()
) {
self.identifier = identifier
self.reason = reason
self.stackTrace = stackTrace
}
}
애플리케이션의 log level이 .debug
이하로 설정되어 잇는 경우 에러 스택 추적은 로그 결과에 포함될 것입니다.
스택 추적은 log level이 .debug
이상이라면 캡처되지 않습니다. 이 행동을 재정의하려면 configure
에서 StackTrace.isCaptureEnabled
를 수동으로 설정해주세요.
// Always capture stack traces, regardless of log level.
StackTrace.isCaptureEnabled = true
ErrorMiddleware
는 기본적으로 애플리케이션에 추가된 유일한 미들웨어입니다. 이 미들웨어는 루트 핸들러가 던지거나 반환한 Swift 에러들을 HTTP 응답으로 변환해줍니다. 이 미들웨어가 없으면 에러가 발생하여도 응답 없이 연결이 끊어질 것입니다.
AbortError
과 DebuggableError
가 제공하는 에러처리를 커스터마이징하고 싶다면 ErrorMiddleware
를 자체적인 에러 처리 로직으로 교체하면 됩니다. 이렇게 하려면 먼저 app.middleware
를 빈 구성으로 설정함으로써 기본 에러 미들웨어를 제거하고, 자체 에러 처리 미들웨어를 애플리케이션의 첫 번째 미들웨어로 추가하면 됩니다.
// Remove all existing middleware.
app.middleware = .init()
// Add custom error handling middleware first.
app.middleware.use(MyErrorMiddleware())
스킵 구간 끝입니다.
저는 아래와 같이 크게 헤더의 Content-Type 검사와 검증 실패 시 에러 unwrap(or:)
메서드에서 nil
을 발견하였을 때 에러를 정의하여 적용하였습니다.
import Vapor
enum HTTPError {
case progressNotFoundInURL
case invalidProgressInURL
case invalidContentType
case validationFailedWhileCreating
case validationFailedWhileUpdating
case invalidID
}
extension HTTPError: AbortError {
var reason: String {
switch self {
case .progressNotFoundInURL:
return "This path requires progress as path parameter. Please enter \"/todo\", \"/doing\" or \"/done\" additively."
case .invalidProgressInURL:
return "Progresses are classified as todo, doing and done. Please check entered URL."
case .invalidContentType:
return "The Content-Type of the request is not application/json."
case .validationFailedWhileCreating:
return "Validations are failed while creating data. Please check if the content is not exceed length of 1000, progress is in todo, doing and done."
case .validationFailedWhileUpdating:
return "Validations are failed while updating data. Please check if the content has id, not to exceed length of 1000, and the progress is in todo, doing and done."
case .invalidID:
return "You have entered invalid projectItem ID. The database does not have such item."
}
}
var status: HTTPResponseStatus {
switch self {
case .progressNotFoundInURL:
return .notFound
case .invalidProgressInURL:
return .notFound
case .invalidContentType:
return .badRequest
case .validationFailedWhileCreating:
return .badRequest
case .validationFailedWhileUpdating:
return .badRequest
case .invalidID:
return .notFound
}
}
}
func create(req: Request) throws -> EventLoopFuture<ProjectItem> {
// ⭐️ Header의 Content-Type이 application/json일 것
guard req.headers.contentType == .json else {
throw HTTPError.invalidContentType
}
do {
try PostProjectItem.validate(content: req)
} catch {
// 검증에 대한 커스텀 에러
throw HTTPError.validationFailedWhileCreating
}
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> {
guard req.headers.contentType == .json else {
throw HTTPError.invalidContentType
}
do {
try PatchProjectItem.validate(content: req)
} catch {
throw HTTPError.validationFailedWhileUpdating
}
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 }
}
}
func delete(req: Request) throws -> EventLoopFuture<HTTPStatus> {
guard req.headers.contentType == .json else {
throw HTTPError.invalidContentType
}
let exist = try req.content.decode(DeleteProjectItem.self)
return ProjectItem.find(exist.id, on: req.db)
.unwrap(or: HTTPError.invalidID)
.flatMap { $0.delete(on: req.db) }
.transform(to: .ok)
}