1편에 이어서 Vapor를 통해서 서버에 저장할 데이터들을 정의하고 마이그레이션, 데이터 검증, 오류 처리까지하는 과정에 대해서 알아보자!!
모델은 데이터베이스에서 테이블 또는 컬렉션에 저장되는 데이터 구조를 나타냅니다. 모든 모델에는 유일한 식별자가 하나씩 있으며, 이 식별자를 포함한 하나 이상의 필드가 존재한다
import Fluent
import Vapor
final class ProjectItem: Model, Content {
static let schema = "projectItems"
enum Progress: String, Codable {
case todo, doing, done
}
@ID(key: .id)
var id: UUID?
@Field(key: "title")
var title: String
@Field(key: "content")
var content: String
@Field(key: "deadlineDate")
var deadlineDate: Date
@Enum(key: "progress")
var progress: Progress
@Field(key: "index")
var index: Int
init() { }
init(id: UUID? = nil, title: String, content: String, deadlineDate: Date, progress: Progress, index: Int) {
self.id = id
self.title = title
self.content = content
self.deadlineDate = deadlineDate
self.progress = progress
self.index = index
}
init(_ postProjectItem: PostProjectItem) {
self.id = postProjectItem.id
self.title = postProjectItem.title
self.content = postProjectItem.content
self.deadlineDate = postProjectItem.deadlineDate
self.progress = postProjectItem.progress
self.index = postProjectItem.index
}
}
Model
프로토콜을 채택하면schema
타입 프로퍼티를 가지게 되는데,
이는 데이터베이스 테이블명 또는 컬렉션의 이름을 의미한다
schema
의 경우 클래스 이름의 복수형 및 소문자로 표현합니다
모델은 반드시 identifier를 가져야만 합니다. 그래야 데이터끼리의 식별이 가능하니깐요ㅎㅎ
@ID
property Wrapper를 가진다
@ID
는 보통 UUID를 사용합니다
모델은 반드시 비어있는 이니셜라이저를 가져야만 합니다. Fluent가 쿼리에 의해 모델을 생성하는데 내부적으로 사용하기 때문이라고 합니다. 물론 모든 프로퍼티를 포함하는 이니셜라이져도 정의할 수 있다
init(id: UUID? = nil, title: String, content: String, deadlineDate: Date, progress: Progress, index: Int) {
self.id = id
self.title = title
self.content = content
self.deadlineDate = deadlineDate
self.progress = progress
self.index = index
}
모델은 필드를 정의해주기 위해서 @Field
property Wrapper를 사용합니다.
final class Task: Model {
@Field(key: "title")
var title: String
}
@Field 프로퍼티는 Codable을 준수하는 타입이라면 어떤 타입이든 지정할 수 있습니다. 옵셔널 타입에는 @OptionalField를 사용합니다.
final class Task: Model {
@OptionalField(key: "comment")
var comment: String?
}
@Field
의 한 종류로, Foundation.Date 타입을 정의한다. @TimeStamp
의 경우 지정된 트리거를 토대로 Fluent에 의해 자동으로 설정되며, .create
, .update
, .delete
세가지가 있다
@Timestamp의 타입은 옵셔널이고, 이니셜라이저에서 기본값으로 nil을 할당해야 합니다.
@Enum
은 @Field
의 한 종류로, String 원시값을 갖는 열거형을 네이티브 데이터베이스 열거형으로 저장한다.
Enum으로 지정할 경우 원하는 값들만 넣을 수 있어 안전하게 저장할 수 있다
enum Progress: String, Codable {
case todo, doing, done
}
@Enum(key: "progress")
var progress: Progress
우리가 이제껏 Model 타입을 정의했는데, 정의하기만 한다고 이 Model이 Table화가 되어서 database에 적용되는 것이 아니다.
적용하는 과정인 마이그레이션을 해줘야 한다!
import Fluent
struct TaskMigration: Migration {
func prepare(on database: Database) -> EventLoopFuture {
_ = database.enum("progress")
.case("todo")
.case("doing")
.case("done")
.create()
return database.enum("progress").read().flatMap { progress in
database.schema("projectItems")
.id()
.field("title", .string, .required)
.field("content", .string, .required)
.field("deadlineDate", .datetime, .required)
.field("progress", progress, .required)
.field("index", .int, .required)
.create()
}
여기서 prepare(on:)
메서드는 데이터베이스를 변화시키는 동작을 수행합니다. 테이블, 필드, 제약 등을 추가하고 삭제하는 것과 같이 데이터베이스 스키마를 변경합니다. 또한 모델 인스턴스 생성, 필드값 업데이트 같이 데이터를 수정합니다.
revert(on:)
메서드는 이러한 변화를 되돌리는 동작을 수행합니다. 마이그레이션 실행 취소는 테스트를 용이하게 하고, 백업을 제공합니다.
마이그레이션을 사용하려면 앱을 구성하는 configure(_:)
함수에 등록한다
import Fluent
import Vapor
public func configure(_ app: Application) throws {
app.migrations.add(CreateProjectItem())
}
만약에 SecondMigration
이 FirstMigration
에 의존한다면, FirstMigration
이 먼저 추가되어야 합니다
Fluent의 Schema API를 사용하면 SQL 입력 없이 프로그래밍 방식으로 데이터베이스 스키마를 생성하고 수정할 수 있다.
이걸 안해주면 원래는 Create table projectItem...
와 같은 SQL문을 통해서 데이터베이스를 생성해줘야 했겠죠??
create()
메서드를 통해 데이터베이스에 테이블 또는 컬렉션을 생성한다. 새로운 필드와 제약을 정의하는 모든 메서드를 지원한다
database.schema(Task.schema)
.id()
.field("title", .string, .required)
.create()
enum(_:)
메서드를 통해 네이티브 데이터베이스 열거형을 생성합니다. 이걸 통해서 마이그레이션 과정 중에 등록을 해줘야 되는군요...!!!
컨트롤러는 요청에 대한 응답을 반환하는 메서드를 그룹화하기 좋은 수단이다. 프로젝트 규모가 커질수록 코드의 책임을 명확히 분리하는 것이 유지보수에 용이하다
원래 같으면 아래와 같이 routes.swift
파일에 다 때려박아서 정신 없겠지만, 컨트롤러를 이용해서 각 기능별로 분리하고 각 기능을 routes.swift
에서 등록만 해주면 되기 때문에, 매우 용이합니다
import Vapor
import Fluent
func routes(_ app: Application) throws {
app.get("foo") { req -> String in
return "bar"
}
app.get("kane") { req -> String in
return "kane"
}
}
Controller를 활용한 방법
import Fluent
import Vapor
struct ProjectItemController: RouteCollection {
func boot(routes: RoutesBuilder) throws {
let item = routes.grouped("kane")
item.get(use: read)
}
func read(req: Request) throws -> EventLoopFuture<[ProjectItem]> {
return "kane"
}
import Vapor
import Fluent
func routes(_ app: Application) throws {
try app.register(collection: ProjectItemController())
}
Vapor는 라우트를 그룹화할 수 있도록 RouteCollection
프로토콜을 제공합니다. 컨트롤러가 RouteCollection
프로토콜을 채택하면 라우트를 등록하기 위한 boot(routes:)
메서드를 구현해야 합니다. 잊지말고 꼭 해야됩니다.
마지막으로 컨트롤러를 앱에 등록합니다. routes.swift
파일의 routes(_:)
함수에 코드를 추가합니다.
vapor은 데이터를 디코딩하기 전에 요청을 검증할 수 있도록 Validation API를 제공합니다. 이를테면 이메일, 정수 범위, URL 등의 유효성을 검사할 수 있습니다.
근데 이거 안해줘도 오류 메시지를 보면 어떤게 틀리는지 알려주던데??
그럼 왜 데이터 검증을 해줘야할까??
{
"title": "Vapor 공부하기",
"status": "pending"
}
Task
타입으로 디코딩하면 다음과 같은 오류 메시지가 뜬다.
참고로 status는 enum으로 구현된 Status 타입인데, pending 값은 없다
value of type `Status` required for key `status`.
Status 타입의 값으로는 todo, doing, done만이 유효하므로 위 메시지는 틀리지 않는다. 다만, 메시지 자체로는 사용 가능한 Status 타입의 값을 유추하기가 어렵다. Validation API를 사용하면 다음과 같은 오류메시지를 확인할 수 있다
status is not todo, doing, or done
그러니깐 오류 메시지를 커스터마이징할 수 있다는 말이다잉
그리고 또!!
오류가 만약 하나의 요청에 여러 가지라면 Codable
같은 경우 첫번째 오류만 검출하고 디코딩을 중지하기 때문에 나머지 오류를 한꺼번에 찾기 힘들다!!!
하지만 Validation의 같은 경우는 전부 검출해준다!!!
요청을 검증하기 위해서는 디코딩할 타입이 Validatable
프로토콜을 채택하도록 합니다. validations(_:)
타입 메서드는 유효성을 검사할 때 호출되며, Validations에 검증을 추가해줘야 합니다
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)
}
}
Vapor는 오류를 처리하기 위해서 Swift의 Error
프로토콜을 기반으로 하는 타입을 제공합니다. 라우트 핸들러는 오류를 던지거나 실패한 EventLoopFuture
를 반환할 수 있습니다
HTTP 상태와 오류 원인을 포함하여 초기화할 수 있으며, 오류 원인은 옵셔널이다
throw Abort(.notFound)
//404
throw Abort(.unauthorized, reasons: "Invalid Credentials")
//401
오류를 던질 수 없는 경우 실패한 EventLoopFuture 객체를 반환할 수 있습니다
guard let task = task else {
req.eventLoop.makeFailedFuture(Abort(.notFound))
}
return task.save()
Swift의 Error는 기본적으로 500 Internal Server Error 응답을 반환합니다. 특정 HTTP 상태 및 오류 원인을 구성하려면 AbortError를 채택합니다.
import Vapor
enum TaskError {
case invalidStatus
case notFoundForID
}
extension TaskError: AbortError {
var reason: String {
switch self {
case .invalidStatus:
return "Status is not valid"
case .notFoundForID:
return "Cannot find task for ID"
}
}
var status: HTTPResponseStatus {
switch self {
case .invalidStatus:
return .badRequest
case .notFoundForID:
return .notFound
}
}
}
Test 진행은 raywenderlich를 참고해서 진행했습니다.
해당 글에서는 docker를 사용하여 postgres를 분리하여 test용 postgres 포트를 따로 빼서 진행하였는데
개인적으로 쉽게 데이터베이스만 따로 테스트용으로 생성해서 진행을 해도 될 것 같다는 생각이 듭니다
Test를 진행하면서 처음 docker를 사용해봤는데, 아래 링크를 참고했습니다
참고자료)
https://www.raywenderlich.com/books/server-side-swift-with-vapor/v3.0.ea1/chapters/11-testing#toc-chapter-014-anchor-001
https://www.redhat.com/ko/topics/containers/what-is-docker
public func configure(_ app: Application) throws {
app.migrations.add(CreateProjectItem())
if let databaseURL = Environment.get("DATABASE_URL"), var postgresConfig = PostgresConfiguration(url: databaseURL) {
var clientTLSConfiguration = TLSConfiguration.makeClientConfiguration()
clientTLSConfiguration.certificateVerification = .none
postgresConfig.tlsConfiguration = clientTLSConfiguration
app.databases.use(.postgres(configuration: postgresConfig), as: .psql)
} else {
let databaseName: String
let databasePort: Int
if app.environment == .testing {
databaseName = "project-manager-test"
databasePort = 5433
} else {
databaseName = "project-manager-local"
databasePort = 5432
}
app.databases.use(.postgres(
hostname: Environment.get("DATABASE_HOST") ?? "localhost",
port: databasePort,
username: Environment.get("DATABASE_USERNAME") ?? "yagom-student",
password: Environment.get("DATABASE_PASSWORD") ?? "yagom",
database: Environment.get("DATABASE_NAME") ?? databaseName
), as: .psql)
}
try app.autoMigrate().wait()
try routes(app)
}
이후 docker로 설정한 port에 맞게 test일때 다른 postgres프로세스로 들어갈 수 있게 해주자!
그리고 이제 Test 과정에 대해서 보자!!
func testProjectItemCanBeDeletedWithAPI() throws {
let projectItem = TestAssets.projectItem
let _ = try projectItem.save(on: app.db).wait()
let deleteProjectItem = DeleteProjectItem(id: try projectItem.requireID())
try app.test(.DELETE, TestAssets.projectItemURI, beforeRequest: { req in
try req.content.encode(deleteProjectItem)
}, afterResponse: { response in
XCTAssertEqual(response.status, .ok)
try app.test(.GET, TestAssets.todoProjectItemsURI, afterResponse: { secondResponse in
XCTAssertEqual(secondResponse.status, .ok)
let todoProjectItems = try secondResponse.content.decode([ProjectItem].self)
XCTAssertEqual(todoProjectItems.count, 0)
})
})
}
XCTVapor에 내장되어 있는 app.test() 메서드는 request와 response를 위 처럼 클로저를 통해서 처리해줄 수 있다.
그래서 간편하게 Test를 진행할 수 있다. 감을 익히는데 긴 시간이 들지 않아 좋았다