This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We build a routing system based on the Codable protocol, starting with encoding.

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")
//        XCTAssertEqual(try encode(Route.profile(5)), "/profile/5")
//        XCTAssertEqual(try encode(Route.nested(.foo)), "/nested/foo")
//        XCTAssertEqual(try encode(Route.nested(nil)), "/nested")
    }
}

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")
//        XCTAssertEqual(try encode(Route.nested(nil)), "/nested")
    }
}

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.

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes