This article is also available in Chinese.
When working with Swift on the server, most of the routing frameworks work by associating a route with a given closure. When we wrote Beacon, we chose the Vapor framework, which works like this. You can see this in action in the test example on their home page:
import Vapor
let droplet = try Droplet()
droplet.get("hello") { req in
return "Hello, world."
}
try droplet.run()
Once you run this code, visiting localhost:8080/hello
will display the text “Hello, world.”.
Sometimes, you also want to return a special HTTP code to signal to consumers of the API that a special action happened. Take this example endpoint:
droplet.post("devices", handler: { request in
let apnsToken: String = try request.niceJSON.fetch("apnsToken")
let user = try request.session.ensureUser()
var device = try Device(apnsToken: apnsToken, userID: user.id.unwrap())
try device.save()
return try device.makeJSON()
})
(I’ve written more about NiceJSON
here, if you’re curious about it.)
This is a perfectly fine request and is similar to code from the Beacon app. There is one problem: Vapor will assume a status code of 200 when you return objects like a string (in the first example in this blog post) or JSON (in the second example). However, this is a POST
request and a new Device
resource is being created, so it should return the HTTP status code “201 Created”. To do this, you have to create a full response object, like so:
let response = Response(status: .created)
response.json = try device.makeJSON()
return response
which is a bit annoying to have to do for every creation request.
Lastly, endpoints will often have side effects. Especially with apps written in Rails, managing and testing these is really hard, and much ink has been spilled in the Rails community about it. If signing up needs to send out a registration email, how do you stub that while still testing the rest of the logic? It’s a hard thing to do, and if everything is in one big function, it’s even harder. In Beacon’s case, we don’t have don’t have many emails to send, but we do have a lot of push notifications. Managing those side effects is important.
Generally speaking, this style of routing, where you use a closure for each route, has been used in frameworks like Flask, Sinatra, and Express. It makes for a pretty great demo, but a project in practice often has complicated endpoints, and putting everything in one big function doesn’t scale.
Going even further, the Rails style of having a giant controller which serves as a namespace for vaguely related methods for each endpoint is borderline offensive. I think we can do better than both of these. (If you want to dig into Ruby server architecture, I’ve taken a few ideas from the Trailblazer project.)
Basically, I want a better abstraction for responding to incoming requests. To this end, I’ve started using an object that I call a Command
to encapsulate the work that an endpoint needs to do.
The Command
pattern starts with a protocol:
public protocol Command {
init(request: Request, droplet: Droplet) throws
var status: Status { get }
func execute() throws -> JSON
}
extension Command: ResponseRepresentable {
public func makeResponse() throws -> Response {
let response = Response(status: self.status)
response.json = try execute()
return response
}
}
We’ll add more stuff to it as we go, but this is the basic shell of the Command
protocol. You can see see just from the basics of the protocol how this pattern is meant to be used. Let’s rewrite the “register device” endpoint with this pattern.
droplet.post("devices", handler: { request in
return RegisterDeviceCommand(request: request, droplet: droplet)
})
Because the command is ResponseRepresentable
, Vapor accepts it as a valid result from the handler block for the route. It will automatically call makeResponse()
on the Command
and return that Response
to the consumer of the API.
public final class RegisterDeviceCommand: Command {
let apnsToken: String
let user: User
public init(request: Request, droplet: Droplet) throws {
self.apnsToken = try request.niceJSON.fetch("apnsToken")
self.user = try request.session.ensureUser()
}
public let status = Status.created
public func execute() throws -> JSON {
var device = try Device(apnsToken: apnsToken, userID: user.id.unwrap())
try device.save()
return try device.makeJSON()
}
}
There are a few advantages conferred by this pattern already.
- Maybe the major appeal of using a language like Swift for the server is to take advantage of things like optionals (and more pertinently, their absence) to be able to define the absolute requirements for a request to successfully complete. Because
apnsToken
anduser
are non-optional, this file will not compile if theinit
function ends without setting all of those values. - The status code is applied in a nice declarative way.
- Initialization is separate from execution. You can write a test that checks to that the initialization of the object (e.g., the extraction of the properties from the request) that is completely separate from the test that checks that the actual
save()
works correctly. - As a side benefit, using this pattern makes it easy to put each
Command
into its own file.
There are two more important components to add to a Command
like this. First, validation. We’ll add func validate() throws
to the Command
protocol and give it a default implementation that does nothing. It’ll also be added to the makeResponse()
function, before execute()
:
public func makeResponse() throws -> Response {
let response = Response(status: self.status)
try validate()
response.json = try execute()
return response
}
A typical validate()
function might look like this (this comes from Beacon’s AttendEventCommand
):
public func validate() throws {
if attendees.contains(where: { $0.userID == user.id }) {
throw ValidationError(message: "You can't join an event you've already joined.")
}
if attendees.count >= event.attendanceLimit {
throw ValidationError(message: "This event is at capacity.")
}
if user.id == event.organizer.id {
throw ValidationError(message: "You can't join an event you're organizing.")
}
}
Easy to read, keeps all validations localized, and very testable as well. While you can construct your Request
and Droplet
objects and pass them to the prescribed initializer for the Command
, you’re not obligated to. Because each Command
is your own object, you can write an initializer that accepts fully fledged User
, Event
, etc objects and you don’t have to muck about with manually constructing Request
objects for testing unless you’re specifically testing the initialization of the Command
.
The last component that a Command needs is the ability to execute side effects. Side effects are simple:
public protocol SideEffect {
func perform() throws
}
I added a property to the Command
protocol that lists the SideEffect
-conforming objects to perform once the command’s execution is done.
var sideEffects: [SideEffect] { get }
And finally, the side effects have to be added to the makeResponse()
function:
public func makeResponse() throws -> Response {
let response = Response(status: self.status)
try validate()
response.json = try execute()
try sideEffects.forEach({ try $0.perform() })
return response
}
(In a future version of this code, side effects may end up being performed asynchronously, i.e., not blocking the response being sent back to the user, but currently they’re just performed synchronously.) The primary reason to decouple side effects from the rest of the Command
is to enable testing. You can create the Command
and execute()
it, without having to stub out the side effects, because they will never get fired.
The Command
pattern is a simple abstraction, but it enables testing and correctness, and frankly, it’s pleasant to use. You can find the complete protocol in this gist. I don’t knock Vapor for not including an abstraction like this: Vapor, like the other Swift on the server frameworks, is designed to be simple and and that simplicity allows you to bring abstractions to your own taste.
There are a few more blog posts coming on server-side Swift, as well as a few more in the Coordinator series. Beacon and WWDC have kept me busy, but rest assured! More posts are coming.