Migrating to Vapor 4
In this article we are going to see how we can migrate a web application, developed in Vapor 3 to the newest version, Vapor 4.
Short recap
We saw together in this article how we can develop a basic REST API in Vapor 3.
The server side app structure in Vapor 3:
├── Sources
│ ├── App
│ │ ├── Controllers
│ │ ├── Models
│ │ ├── boot.swift
│ │ ├── configure.swift
│ │ └── routes.swift
│ └── Run
│ └── main.swift
├── Tests
│ └── AppTests
└── Package.swift
File | Description |
---|---|
Package.swift | This is the manifest of the project and defines all dependencies and targets of our app. The project is pointing to Vapor 3.3.0: .package(url: "https://github.com/vapor/vapor.git", from: "3.3.0")
|
Public | All resources you want to be public, such as images. |
Source | Here you can see two separate modules: App and Run. You usually have to put all of your developed code inside App. The Run folder contains the main.swift file. |
Models | Add here your Fluent models. In our app, the models are: User, Player, Gather. |
Controllers | The controller is where you write the logic of your REST API, such as CRUD operations. These are similar to iOS ViewControllers, but instead they handle the requests and manage the models. |
routes.swift | Used to find the appropriate response for an incoming request. |
configure.swift | Called before app is initialised. Register router, middlewares, database and model migrations. |
When the server app runs it goes to:
main.swift >>> app.swift
(to create an instance of the app)
>>> configure.swift
(called before the app initializes) >>> routes.swift
(checks the registered routes) >>> boot.swift
(called after the app is initialized).
Migration
Package
A lot of things changed from Vapor 3 and migrating to Vapor 4 is not as straight forward as you might think.
Next, we present the differences of Package.swift
. You can check it also on GitHub.
Updating our Models
Vapor 4 uses the true power of Swift 5.2 and has property wrappers implemented in its core.
The Model protocol now replaces every SQLiteTypeModel that we had to extend in v3.
We no longer have to implement Migration in our Model classes.
We remove the SQLiteUUIDPivot protocol implementation and make the PlayerGatherPivot implement the default Model protocol.
Foreign/Private keys of the table are specified using the @Parent property wrapper.
- Gather model transformation - GitHub Link
- Player model transformation - GitHub Link
- PlayerGatherPivot model transformation - GitHub Link
- Token model transformation - GitHub Link
- User model transformation - GitHub Link
Example of an implemented model, PlayerGatherPivot:
import Vapor
import FluentSQLiteDriver
// MARK: - Model
final class PlayerGatherPivot: Model {
static let schema = "player_gather"
@ID(key: .id)
var id: UUID?
@Parent(key: "player_id")
var player: Player
@Parent(key: "gather_id")
var gather: Gather
@Field(key: "team")
var team: String
init() {}
init(playerID: Player.IDValue,
gatherID: Gather.IDValue,
team: String) {
self.$player.id = playerID
self.$gather.id = gatherID
self.team = team
}
}
// MARK: - Migration
extension PlayerGatherPivot: Migration {
func prepare(on database: Database) -> EventLoopFuture<Void> {
database.schema(PlayerGatherPivot.schema)
.field("id", .uuid, .identifier(auto: true))
.field("player_id", .int, .required)
.field("gather_id", .uuid, .required)
.field("team", .string, .required)
.create()
}
func revert(on database: Database) -> EventLoopFuture<Void> {
database.schema(PlayerGatherPivot.schema).delete()
}
}
Updating our Controllers
Route collections use the same function boot, but the function's parameter type has been changed from Router to RoutesBuilder.
We don’t use Model.parameter anymore. Instead, we use the special mark :id.
Fetching the authenticated user has been replaced from (Vapor 3):
let user = try req.requireAuthenticated(User.self)
return try user.gathers.query(on: req).all()
To (in Vapor 4):
let user = try req.auth.require(User.self)
Speaking about authentication, the middlewares are now updated to:
let tokenAuthMiddleware = Token.authenticator()
let guardMiddleware = User.guardMiddleware() // this is the same as in Vapor 3
let tokenAuthGroup = gatherRoute.grouped(tokenAuthMiddleware, guardMiddleware)
The token auth middleware is now implementing a new protocol, called ModelTokenAuthenticatable:
extension Token: ModelTokenAuthenticatable {
static let valueKey = \Token.$token
static let userKey = \Token.$user
var isValid: Bool { true }
}
You can check all Vapor 4 controllers in GitHub:
- GatherController transformation - GitHub Link
- PlayerController transformation - GitHub Link
- UserController transformation - GitHub Link
The CRUD operations have suffered a lot of transformation:
GET all gathers
func getGathersHandler(_ req: Request) throws -> EventLoopFuture<[GatherResponseData]> {
let user = try req.auth.require(User.self)
return user.$gathers.query(on: req.db)
.all()
.flatMapEachThrowing {
try GatherResponseData(
id: $0.requireID(),
userId: user.requireID(),
score: $0.score,
winnerTeam: $0.winnerTeam
)}
}
flatMapEachThrowing is used to call a closure on each element in the sequence that is wrapped by an EventLoopFuture.
CREATE a gather
func createHandler(_ req: Request) throws -> EventLoopFuture<Response> {
let user = try req.auth.require(User.self)
let gather = try Gather(userID: user.requireID())
return gather.save(on: req.db).map {
let response = Response()
response.status = .created
if let gatherID = gather.id?.description {
let location = req.url.path + "/" + gatherID
response.headers.replaceOrAdd(name: "Location", value: location)
}
}
}
DELETE a gather
func deleteHandler(_ req: Request) throws -> EventLoopFuture<HTTPStatus> {
let user = try req.auth.require(User.self)
guard let id = req.parameters.get("id", as: UUID.self) else {
throw Abort(.badRequest)
}
return user.$gathers.get(on: req.db).flatMap { gathers in
if let gather = gathers.first(where: { $0.id == id }) {
return gather.delete(on: req.db).transform(to: HTTPStatus.noContent)
}
return req.eventLoop.makeFailedFuture(Abort(.notFound))
}
}
UPDATE a gather
func updateHandler(_ req: Request) throws -> EventLoopFuture<HTTPStatus> {
let user = try req.auth.require(User.self)
let gatherUpdateDate = try req.content.decode(GatherUpdateData.self)
guard let id = req.parameters.get("id", as: UUID.self) else {
throw Abort(.badRequest)
}
return user.$gathers.get(on: req.db).flatMap { gathers in
guard let gather = gathers.first(where: { $0.id == id }) else {
return req.eventLoop.makeFailedFuture(Abort(.notFound))
}
gather.score = gatherUpdateDate.score
gather.winnerTeam = gatherUpdateDate.winnerTeam
return gather.save(on: req.db).transform(to: HTTPStatus.noContent)
}
}
GET players of a specified gather
func getPlayersHandler(_ req: Request) throws -> EventLoopFuture<[PlayerResponseData]> {
let user = try req.auth.require(User.self)
guard let id = req.parameters.get("id", as: UUID.self) else {
throw Abort(.badRequest)
}
return user.$gathers.get(on: req.db).flatMap { gathers in
guard let gather = gathers.first(where: { $0.id == id }) else {
return req.eventLoop.makeFailedFuture(Abort(.notFound))
}
return gather.$players.query(on: req.db)
.all()
.flatMapEachThrowing {
try PlayerResponseData(
id: $0.requireID(),
name: $0.name,
age: $0.age,
skill: $0.skill,
preferredPosition: $0.preferredPosition,
favouriteTeam: $0.favouriteTeam
)}
}
}
POST player to a specified gather
func addPlayerHandler(_ req: Request) throws -> EventLoopFuture<HTTPStatus> {
let user = try req.auth.require(User.self)
let playerGatherData = try req.content.decode(PlayerGatherData.self)
guard let gatherID = req.parameters.get("gatherID", as: UUID.self) else {
throw Abort(.badRequest)
}
guard let playerID = req.parameters.get("playerID", as: Int.self) else {
throw Abort(.badRequest)
}
let gather = user.$gathers.query(on: req.db)
.filter(\.$id == gatherID)
.first()
let player = user.$players.query(on: req.db)
.filter(\.$id == playerID)
.first()
return gather.and(player).flatMap { _ in
let pivot = PlayerGatherPivot(playerID: playerID,
gatherID: gatherID,
team: playerGatherData.team)
return pivot.save(on: req.db).transform(to: HTTPStatus.ok)
}
}
Models in Vapor 4
The app has the following registered models: User, Gather, Player, PlayerGatherPivot and Token.
User Model
User has three parameters:
- id: the primary key as UUID
- username: a unique name created at registration
- password: the hashed password of the user
We have an inner class - User.Public - to define what public details (just the username) we expose to methods such as GET.
The 1:M relationships with gathers (one user can create multiple gathers) and with players (one user can create multiple players) are implemented with the Fluent @Children property wrapper:
final class User: Model {
static let schema = "users"
@ID(key: .id)
var id: UUID?
@Field(key: "username")
var username: String
@Field(key: "password")
var password: String
@Children(for: \.$user)
var gathers: [Gather]
@Children(for: \.$user)
var players: [Player]
init() {}
init(id: UUID? = nil,
username: String,
password: String) {
self.id = id
self.username = username
self.password = password
}
}
We use extensions to wrap our functions for transforming a normal User to User.Public and vice-versa:
// MARK: - Public User
extension User {
final class Public {
var id: UUID?
var username: String
init(id: UUID?, username: String) {
self.id = id
self.username = username
}
}
}
extension User.Public: Content {}
extension User {
func toPublicUser() -> User.Public {
return User.Public(id: id, username: username)
}
}
Token Model
With Token model, we define a mapping between a generated 16 byte data, base64 encoded (the actual token) and the user ID.
For generating the token, we use CryptoRandom().generateData function that at core uses OpenSSL RAND_bytes to generate random data of specified length.
The authentication pattern for the server app is Bearer token auth.
Vapor is really cool and to use it, we just need to implement BearerAuthenticatable protocol and specify the key path of the token key: static let tokenKey: TokenKey = \Token.token
.
final class Token: Model {
static let schema = "tokens"
@ID(key: .id)
var id: UUID?
@Field(key: "token")
var token: String
@Parent(key: "user_id")
var user: User
init() {}
init(id: UUID? = nil,
token: String,
userID: User.IDValue) {
self.id = id
self.token = token
self.$user.id = userID
}
}
// MARK: - Authenticable
extension Token: ModelTokenAuthenticatable {
static let valueKey = \Token.$token
static let userKey = \Token.$user
var isValid: Bool { true }
}
Gather Model
Gather is the model for our football matches.
It has a parent identifier, the user ID and two optional string parameters, the score and the winner team.
final class Gather: Model {
static let schema = "gathers"
@ID(key: "id")
var id: UUID?
@Parent(key: "user_id")
var user: User
@OptionalField(key: "score")
var score: String?
@OptionalField(key: "winner_team")
var winnerTeam: String?
@Siblings(through: PlayerGatherPivot.self, from: \.$gather, to: \.$player)
var players: [Player]
init() {}
init(id: UUID? = nil,
userID: User.IDValue,
score: String? = nil,
winnerTeam: String? = nil) {
self.id = id
self.$user.id = userID
self.score = score
self.winnerTeam = winnerTeam
}
}
Player Model
Player model is defined similar with Gather:
- userID: the ID of the user that created this player (the parent)
- name: combines the first and last names
- age: an optional integer that you can use to store the age of the player
- skill: is an enum that specifies what skill the player has (beginner, amateur or professional)
- preferredPosition: represents the position in the field the player prefers
- favouriteTeam: an optional string parameter to record the favourite team of the player
final class Player: Model {
static let schema = "players"
@ID(custom: .id)
var id: Int?
@Parent(key: "user_id")
var user: User
@Field(key: "name")
var name: String
@OptionalField(key: "age")
var age: Int?
@OptionalField(key: "skill")
var skill: Skill?
@OptionalField(key: "position")
var preferredPosition: Position?
@OptionalField(key: "favourite_team")
var favouriteTeam: String?
@Siblings(through: PlayerGatherPivot.self, from: \.$player, to: \.$gather)
public var gathers: [Gather]
convenience init() {
self.init(userID: UUID(), name: "")
}
init(id: Int? = nil,
userID: User.IDValue,
name: String,
age: Int? = nil,
skill: Skill? = nil,
preferredPosition: Position? = nil,
favouriteTeam: String? = nil) {
self.id = id
self.$user.id = userID
self.name = name
self.age = age
self.skill = skill
self.preferredPosition = preferredPosition
self.favouriteTeam = favouriteTeam
}
}
Relationships
Vapor 3
The M:M to relationship between gathers and players (one gather can have multiple players and one player can be in multiple gathers) is implemented with Fluent pivots.
Basically, we create a new model class that extends SQLiteUUIDPivot (SQLite is the database we are currently using) and we specify the key paths of the tables:
static var leftIDKey: LeftIDKey = \PlayerGatherPivot.playerId
static var rightIDKey: RightIDKey = \PlayerGatherPivot.gatherId
In our model classes we can create convenient methods to access the gathers that our player has been part of and, respectively, the players that were in the given gather:
extension Player {
var gathers: Siblings<Player, Gather, PlayerGatherPivot> {
return siblings()
}
}
extension Gather {
var players: Siblings<Gather, Player, PlayerGatherPivot> {
return siblings()
}
}
Migrating to Vapor 4
final class PlayerGatherPivot: Model {
static let schema = "player_gather"
@ID(key: .id)
var id: UUID?
@Parent(key: "player_id")
var player: Player
@Parent(key: "gather_id")
var gather: Gather
@Field(key: "team")
var team: String
init() {}
init(playerID: Player.IDValue,
gatherID: Gather.IDValue,
team: String) {
self.$player.id = playerID
self.$gather.id = gatherID
self.team = team
}
}
// MARK: - Migration
extension Gather: Migration {
func prepare(on database: Database) -> EventLoopFuture<Void> {
database.schema(Gather.schema)
.field("id", .uuid, .identifier(auto: true))
.field("user_id", .uuid, .required, .references("users", "id"))
.foreignKey("user_id", references: "users", "id", onDelete: .cascade)
.field("score", .string)
.field("winner_team", .string)
.create()
}
}
extension Player: Migration {
func prepare(on database: Database) -> EventLoopFuture<Void> {
database.schema(Player.schema)
.field("id", .int, .identifier(auto: true))
.field("user_id", .uuid, .required, .references("users", "id"))
.foreignKey("user_id", references: "users", "id", onDelete: .cascade)
.field("name", .string, .required)
.field("age", .int)
.field("skill", .string)
.field("position", .string)
.field("favourite_team", .string)
.create()
}
}
Controllers
The services logic and routing is done with RouteCollections.
In the boot function, we define the service paths and what methods to use for the incoming requests.
For all collections, we define the path of the resources to be: api/{resource}. For example: api/users or api/gathers or api/players.
So, for example, if a GET request is made to https://foo.net/api/{resource}, Vapor will search for the route and method. Don’t forget to register it in routes.swift.
UserController
- POST /api/users/login — Login functionality for users
- POST /api/users — Registers a new user
- GET /api/users — Get the list of users
- GET /api/users/{userId} — Get user by its id
- DELETE /api/users/{user_id} — Deletes a user by a given ID
Code snippet:
extension UserController {
func createHandler(_ req: Request) throws -> EventLoopFuture<Response> {
let user = try req.content.decode(User.self)
user.password = try Bcrypt.hash(user.password)
return user.save(on: req.db).map {
let response = Response()
response.status = .created
if let userID = user.id?.description {
let location = req.url.path + "/" + userID
response.headers.replaceOrAdd(name: "Location", value: location)
}
return response
}
}
}
Before saving the user in the database, we hash the password with BCryptDigest.
After the user is saved, we retrieve the ID and return it as part of the Location response header. We stick to the RESTful API practices of returning status codes, instead of the actual resource.
The newly created resource can be associated with the unique ID that is given by the Location header field. (https://restfulapi.net/http-status-codes/).
Full implementation can be found on GitHub.
GatherController
In GatherController you can find the following methods:
- GET /api/gathers — Gets all gathers for the authenticated user
- POST /api/gathers — Creates a new gather for the authenticated user.
- The model for create data is a subset of the actual Gather model, containing the score and the winner team, both parameters being optional.
- This is similar with User’s create method, if successful we return the gather ID as part of the Location header.
- DELETE /api/gathers/{gather_id} — Deletes the gather via its ID
- PUT /api/gathers/{gather_id} — Updates the gather associated with the given ID.
- We look in all saved gathers trying to match the given ID with one of the existing ones.
- If success, we update the score and winner team of the gather.
- In the end, we return 204.
- M:M relationship
- We search in all gathers for a gather that has the given ID, same as for the update method
- If we find a gather, we retrieve it and create a new pivot
- In the end, we save the pivot model in the database and return 200
- Similar with update and adding a player to a gather methods, we search for the gather that has the given ID
- If found, we return all players associated with that gather
Code snippet:
func addPlayerHandler(_ req: Request) throws -> EventLoopFuture<HTTPStatus> {
let user = try req.auth.require(User.self)
let playerGatherData = try req.content.decode(PlayerGatherData.self)
guard let gatherID = req.parameters.get("gatherID", as: UUID.self) else {
throw Abort(.badRequest)
}
guard let playerID = req.parameters.get("playerID", as: Int.self) else {
throw Abort(.badRequest)
}
let gather = user.$gathers.query(on: req.db)
.filter(\.$id == gatherID)
.first()
let player = user.$players.query(on: req.db)
.filter(\.$id == playerID)
.first()
return gather.and(player).flatMap { _ in
let pivot = PlayerGatherPivot(playerID: playerID,
gatherID: gatherID,
team: playerGatherData.team)
return pivot.save(on: req.db).transform(to: HTTPStatus.ok)
}
}
func getPlayersHandler(_ req: Request) throws -> EventLoopFuture<[PlayerResponseData]> {
let user = try req.auth.require(User.self)
guard let id = req.parameters.get("id", as: UUID.self) else {
throw Abort(.badRequest)
}
return user.$gathers.get(on: req.db).flatMap { gathers in
guard let gather = gathers.first(where: { $0.id == id }) else {
return req.eventLoop.makeFailedFuture(Abort(.notFound))
}
return gather.$players.query(on: req.db)
.all()
.flatMapEachThrowing {
try PlayerResponseData(
id: $0.requireID(),
name: $0.name,
age: $0.age,
skill: $0.skill,
preferredPosition: $0.preferredPosition,
favouriteTeam: $0.favouriteTeam
)}
}
}
PlayerController
The methods defined in PlayerController can be found below:
- GET /api/players — Retrieves all players for the authenticated user
- POST /api/players — Creates a new player for the authenticated user
- DELETE /api/players/{player_id} — Deletes the player if is found by the given ID
- PUT /api/players/{player_id} — Updates a player
- Similar with Gather and User collection, we look into all players for a match of the ID
- If successful, we update all player parameters (age, name, preferred position, favourite team, skill)
- In the end we return 204, NO_CONTENT.
- GET /api/players/{player_id}/gathers — Returns all gathers for the player matching the given ID.
- Follows the same pattern as we have for gathers where we can return the players for the given gather ID
Code snippet:
func getGathersHandler(_ req: Request) throws -> EventLoopFuture<[GatherResponseData]> {
let user = try req.auth.require(User.self)
guard let id = req.parameters.get("id", as: Int.self) else {
throw Abort(.badRequest)
}
return user.$players.get(on: req.db).flatMap { players in
guard let player = players.first(where: { $0.id == id }) else {
return req.eventLoop.makeFailedFuture(Abort(.notFound))
}
return player.$gathers.query(on: req.db)
.all()
.flatMapEachThrowing {
try GatherResponseData(
id: $0.requireID(),
userId: user.requireID(),
score: $0.score,
winnerTeam: $0.winnerTeam
)}
}
}
Conclusion
Vapor is a great framework for server side programming, offering rich APIs for your web app. More important, is built on top of Swift 5.2, having the newly addons of the language integrated in its core. This offers you the true power of Swift, such as property wrappers.
In this article, we saw a practical example how to migrate an older application written in Vapor 3, to the newest version, Vapor 4.
The migration wasn't as straight forward as we would expected. A lot of things changed between these versions.
The most notable change are the Fluent Models. Fluent got its own package in Vapor 4.
For all changes, you can check this particular commit on GitHub.
Useful Links
- My repo with the example we used in this article
- Vapor GitHub Repo
- Fluent ORM Repo
- Upgrading to Vapor 4
- The Swift Dev Blog Articles
- Article raywenderlich - Getting started with Server Side Swift
- Vapor 4 Book
- Very interesting talks from Tim Condon (member of Vapor's Core Team)
- Vapor's Discord Server