00:06 Over the last few episodes, we've been building a SwiftUI-style
backend library, but we haven't talked about routing yet. Although we can
specify expected path components to evoke certain responses, we don't have an
abstraction of our server's routes or a way to generate links to those routes.
00:42 We covered routing in an early
episode, Point-Free did a
series
on it, and many other people have solved it in many other ways. Traditionally,
combinators are used to define routes, and they let us specify both a route's
parser and its pretty-printer in one go. But they aren't exhaustive. If, on the
other hand, we use an enum to represent our routes, we get exhaustivity, but we
have to remember to update our parser when we add a new case. There's no easy
way to statically guarantee all routes are parsed and all routes have a
corresponding pretty-printer.
Codable Routes
01:51 Today, we want to explore using Codeable
to generate parsers and
pretty-printers; we only define our routes with an enum, and we use an encoder
to turn the enum into a path and a decoder for the reverse. Thus, the enum's
structure will determine the URL structure.
02:33 We can start by writing a simple test describing how this should
work. We write an enum, Route
, and we make it conform to Codable
. We also
conform it to Hashable
so that we'll be able to compare values. The enum has
cases for the home page, a user profile page (with a user ID as an associated
value), and a nested route that holds an enum of subroutes:
enum Route: Codable, Hashable {
case home
case profile(Int)
case nested(NestedRoute?)
}
enum NestedRoute: Codable, Hashable {
case foo
}
03:15 In a test function, we write assertions about how each route gets
encoded as a path. And by making the associated value of the .nested
case
optional, we can let a nil
value represent the index route:
final class CodableRoutingTests: XCTestCase {
func testExample() throws {
XCTAssertEqual(try encode(Route.home), "/home")
XCTAssertEqual(try encode(Route.profile(5)), "/profile/5")
XCTAssertEqual(try encode(Route.nested(.foo)), "/nested/foo")
XCTAssertEqual(try encode(Route.nested(nil)), "/nested")
}
}
04:10 Of course, we'll need the corresponding decoding as well, but we
can focus on the encoding first. And perhaps one thing to note about this
strategy is that it probably works best in a situation where we're designing our
own API, and we therefore have full control over the URL structure.
RouteEncoder
04:54 We write a public function, encode
. This function takes an
Encodable
value, R
, that can throw errors, and it returns a string:
public func encode<R: Encodable>(_ value: R) throws -> String {
}
05:18 The only thing we know about value
is that we can call encode
on it and pass in an encoder. So we'll have to write an encoder, i.e. a struct
called RouteEncoder
:
public func encode<R: Encodable>(_ value: R) throws -> String {
let encoder = RouteEncoder()
try value.encode(to: encoder)
}
struct RouteEncoder: Encoder {
}
06:25 To return a value from our function, we need to take something out
of the encoder, because the encode
method on value
doesn't return anything
itself. So, we add a property to store an array of path components. After the
encoding, we join the array's values with slashes, and we add a leading slash:
public func encode<R: Encodable>(_ value: R) throws -> String {
let encoder = RouteEncoder()
try value.encode(to: encoder)
let path = encoder.components.joined(separator: "/")
return "/\(path)"
}
struct RouteEncoder: Encoder {
var components: [String] = []
}
07:24 The compiler reminds us that RouteEncoder
doesn't yet conform.
When we let it add the protocol stubs, a couple of properties and some methods
are added to the struct. We don't know if we need to do anything with
codingPath
and userInfo
, but we can give them empty values for now:
struct RouteEncoder: Encoder {
var components: [String] = []
var codingPath: [CodingKey] = []
var userInfo: [CodingUserInfoKey : Any] = [:]
}
07:46 Then, we have methods that generate different kinds of encoding
containers. The container(keyedBy:)
method is called for everything
key-value-based, unkeyedContainer
is called for arrays and such, and
singleValueContainer
is called for single values. What we need here depends,
in our case, on how Swift encodes enums. We probably won't need
unkeyedContainer
, because our route enum isn't an array structure. We also
won't need singleValueContainer
because our enum has multiple cases. For now,
we put fatal errors in these methods, but we'll need to implement them to avoid
eventually crashing:
struct RouteEncoder: Encoder {
var components: [String] = []
var codingPath: [CodingKey] = []
var userInfo: [CodingUserInfoKey : Any] = [:]
func container<Key>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key> where Key : CodingKey {
fatalError()
}
func unkeyedContainer() -> UnkeyedEncodingContainer {
fatalError()
}
func singleValueContainer() -> SingleValueEncodingContainer {
fatalError()
}
}
08:49 When we run our test, we hit the fatal error thrown from
container(keyedBy:)
. So this is the method we'll implement next.
Keyed Encoding Container
09:24 The container(keyedBy:)
method needs to return a
KeyedEncodingContainer
, which is a struct provided by Foundation. We must pass
something conforming to KeyedEncodingContainerProtocol
into it, for which we
write a new struct. This struct needs a generic parameter, Key
, to receive the
coding keys from the container:
struct RouteEncoder: Encoder {
var components: Box<[String]>
func container<Key>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key> where Key : CodingKey {
KeyedEncodingContainer(RouteKEC())
}
}
struct RouteKEC<Key: CodingKey>: KeyedEncodingContainerProtocol {
}
10:23 Next, the compiler can add the protocol stubs to RouteKEC
. This
is a long list of methods to encode all kinds of primitives, plus some extra
things. Again, we start by putting fatal errors in the methods, and then we'll
see what we need to implement next:
struct RouteKEC<Key: CodingKey>: KeyedEncodingContainerProtocol {
var codingPath: [CodingKey] = []
mutating func encodeNil(forKey key: Key) throws {
fatalError()
}
mutating func encode(_ value: Bool, forKey key: Key) throws {
fatalError()
}
mutating func encode(_ value: String, forKey key: Key) throws {
fatalError()
}
mutating func encode(_ value: Double, forKey key: Key) throws {
fatalError()
}
mutating func encode(_ value: Float, forKey key: Key) throws {
fatalError()
}
mutating func encode(_ value: Int, forKey key: Key) throws {
fatalError()
}
mutating func encode(_ value: Int8, forKey key: Key) throws {
fatalError()
}
mutating func encode(_ value: Int16, forKey key: Key) throws {
fatalError()
}
mutating func encode(_ value: Int32, forKey key: Key) throws {
fatalError()
}
mutating func encode(_ value: Int64, forKey key: Key) throws {
fatalError()
}
mutating func encode(_ value: UInt, forKey key: Key) throws {
fatalError()
}
mutating func encode(_ value: UInt8, forKey key: Key) throws {
fatalError()
}
mutating func encode(_ value: UInt16, forKey key: Key) throws {
fatalError()
}
mutating func encode(_ value: UInt32, forKey key: Key) throws {
fatalError()
}
mutating func encode(_ value: UInt64, forKey key: Key) throws {
fatalError()
}
mutating func encode<T>(_ value: T, forKey key: Key) throws where T : Encodable {
fatalError()
}
mutating func nestedContainer<NestedKey>(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer<NestedKey> where NestedKey : CodingKey {
fatalError()
}
mutating func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer {
fatalError()
}
mutating func superEncoder() -> Encoder {
fatalError()
}
mutating func superEncoder(forKey key: Key) -> Encoder {
fatalError()
}
}
11:00 This fatal-error-driven development helps us get to a working
prototype quickly, but ultimately, all of these methods need to have a proper
implementation, because we don't want our server to crash when somebody sends a
bad request.
11:20 But now, when we run our test again, we crash somewhere else: in
the nestedContainer
method of RouteKEC
. This method needs to return a nested
KeyedEncodingContainer
for a specific key. Let's try printing the passed-in
key:
struct RouteKEC<Key: CodingKey>: KeyedEncodingContainerProtocol {
mutating func nestedContainer<NestedKey>(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer<NestedKey> where NestedKey : CodingKey {
print(key)
fatalError()
}
}
11:55 This prints CodingKeys(stringValue: "home", intValue: nil)
to
the console. So, this is the place where we can actually append something to our
components array.
Collecting Components
12:18 To build up our array of components, we need some sort of mutable
result. Since we're in a struct, we can't just append to the components
array.
By holding on to a class instance instead of an array, we have something mutable
that can be passed around. So, we write a Box
class:
final class Box<Value> {
var value: Value
init(_ value: Value) {
self.value = value
}
}
13:00 We convert the components
property to be a box with an array of
strings, and we pass this value in from the outside:
public func encode<R: Encodable>(_ value: R) throws -> String {
let encoder = RouteEncoder(components: Box([]))
try value.encode(to: encoder)
let path = encoder.components.value.joined(separator: "/")
return "/\(path)"
}
struct RouteEncoder: Encoder {
var components: Box<[String]>
}
13:34 Now we can pass the box on to the RouteKEC
container, so we give
it a components
property as well. In the nestedContainer
method, we append
the key's stringValue
to the array of components:
struct RouteEncoder: Encoder {
func container<Key>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key> where Key : CodingKey {
KeyedEncodingContainer(RouteKEC(components: components))
}
}
struct RouteKEC<Key: CodingKey>: KeyedEncodingContainerProtocol {
var components: Box<[String]>
mutating func nestedContainer<NestedKey>(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer<NestedKey> where NestedKey : CodingKey {
components.value.append(key.stringValue)
fatalError()
}
}
14:28 This nestedContainer
method is called any time a non-primitive
Codable
value needs to be encoded. This might also be an empty value — and we
might be trying to encode Void
, since the .home
case has no associated
values. What we do know is we need to create another KeyedEncodingContainer
,
passing on the components
of the current container. By default, a newly
created RouteKEC
uses the same generic Key
parameter as the current context,
but we need to use the generic type, NestedKey
, which is passed into this
method as a parameter:
struct RouteKEC<Key: CodingKey>: KeyedEncodingContainerProtocol {
mutating func nestedContainer<NestedKey>(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer<NestedKey> where NestedKey : CodingKey {
components.value.append(key.stringValue)
return KeyedEncodingContainer(RouteKEC<NestedKey>(components: components))
}
}
15:45 Now, when we comment out the assertions after the first one, our
test succeeds:
final class CodableRoutingTests: XCTestCase {
func testExample() throws {
XCTAssertEqual(try encode(Route.home), "/home")
}
}
Encoding Primitives
15:59 When we reenable the second assertion, we get stuck in the fatal
error in the method that encodes an integer. This one should be easy to
implement — we just append the value as a string. And with this change, the
second assertion also passes:
struct RouteKEC<Key: CodingKey>: KeyedEncodingContainerProtocol {
mutating func encode(_ value: Int, forKey key: Key) throws {
components.value.append("\(value)")
}
}
Encoding Codable Values
16:07 Next, let's try to get the .nested
case to work:
final class CodableRoutingTests: XCTestCase {
func testExample() throws {
XCTAssertEqual(try encode(Route.home), "/home")
XCTAssertEqual(try encode(Route.profile(5)), "/profile/5")
XCTAssertEqual(try encode(Route.nested(.foo)), "/nested/foo")
}
}
16:22 We're now trying to encode the .nested
route, whose associated
value is an optional NestedRoute?
, and we actually specify the value .foo
.
The fatal error we encounter next is in the method in our keyed encoding
container that's called for anything that's not a primitive value. It's up to us
to encode this non-primitive value. And the only thing we can do is use our
RouteEncoder
to encode the value and to somehow append the results to our
components array.
17:13 We can also look at the key
parameter again. When we print it
to the console, we get the following print:
NestedCodingKeys(stringValue: "_0", intValue: nil)
17:32 If the enum we're encoding has an associated value without a
label, the system uses numbered keys with underscores instead, almost as though
we had defined the case as .nested(_0: NestedRoute?)
. In this case, we can
ignore these kinds of keys.
18:07 So we can move on to creating a nested RouteEncoder
, and we ask
the value to encode itself with this encoder. Since this nested encoder receives
a reference to the same box of components, it appends its components to the same
array:
struct RouteKEC<Key: CodingKey>: KeyedEncodingContainerProtocol {
var components: Box<[String]>
mutating func encode<T>(_ value: T, forKey key: Key) throws where T : Encodable {
let encoder = RouteEncoder(components: components)
try value.encode(to: encoder)
}
}
18:46 The test now passes completely, including the assertion about the
nil
case of the nested route.
Next: Decoding
19:01 As we mentioned, we should take care of encoding primitives other
than integers. We could also add tests for labeled associated values, but for
now, we can just skip those. It all depends on how we decide to model our routes
enum.
19:29 The next step would be basically to do this the other way around:
decoding a route from a string. We can do this next time and take a very similar
approach: we'll let the decoder guide us.