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 lay the foundation for a backend library inspired by SwiftUI.

00:06 Today we'll start a new project in which we'll build a helper library for our backend. A few years ago, we rewrote the Swift Talk backend in Swift. Nearly all the code in there is continuation-based, in order to thread the environment through everywhere and to have support for asynchrony. And it works really well: we haven't had any issues with the backend. But the code is a bit of a pain to write, and it's especially a pain to read a few years later.

00:49 One of SwiftUI's key features is the ease of passing values down the environment, which is enabled by the view builder syntax. This gives SwiftUI its concise syntax. And this is precisely what's lacking in our backend. Manually passing around global values — e.g. the database connection, the user's session, and so on — is a lot of work, and it makes for an ugly syntax.

01:52 What if we could write our backend rules in a way that's similar to how we construct views in SwiftUI? Instead of Views, we'd work with Rule structs. And instead of a ViewBuilder body, the body of a Rule could be a RuleBuilder. We'd then only have to pass the environment in when we finally execute a rule. This enables a very short syntax when declaring the rule.

Proposed Syntax

02:24 Let's write down an example to see what this would look like. We create a struct to represent the root endpoint. It conforms to the Rule protocol — we aren't sure if that's actually the right name for it, but we'll just stick with it for now — and it has a body property, which, in this case, is called rules:

struct Root: Rule {
    var rules: some Rule {
        "Index"
    }
}

03:11 We can have a second rule for a Users endpoint, which represents an index page of users:

struct Users: Rule {
    var rules: some Rule {
        "User Index"
    }
}

04:04 Based on the requested path, we want to switch over to the Users rule. We can think of the rules property as a list of potential sub-rules, and the top-most rule that matches the request "wins":

struct Root: Rule {
    var rules: some Rule {
        Users().path("users")
        "Index"
    }
}

04:14 Let's add one more rule for a specific user's profile. This rule expects a user ID to be passed in:

struct Profile: Rule {
    var id: UUID
    var rules: some Rule {
        "User Profile \(id)"
    }
}

04:43 In the Users rule, we might use a PathReader sub-rule to read a user ID component from the requested path:

struct Users: Rule {
    var rules: some Rule {
        PathReader { comp in
            Profile(id: comp)
        }
        "User Index"
    }
}

05:19 Inside the PathReader's closure, we should also check if the read path component can be converted into a valid user ID. If this fails, we need to respond with a "Not found" error page:

struct Users: Rule {
    var rules: some Rule {
        PathReader { comp in
            if let id = UUID(uuidString: comp) {
                Profile(id: id)
            } else {
                "Not found"
            }
        }
        "User Index"
    }
}

06:27 If none of the rules are a match, we'll also need to return an error.

This example doesn't feature the environment, but we'll get to that later, and it'll be very similar to SwiftUI's environment.

The Rule Protocol

06:46 We comment out some parts so that we can start working with a simpler example, and we try to make it compile:

struct Root: Rule {
    var rules: some Rule {
//        Users().path("users")
        "Index"
    }
}

07:16 In a new file, we write the Rule protocol. This has a rules property that will be of some type R that conforms to Rule:

public protocol Rule {
    associatedtype R: Rule
    var rules: R { get }
}

07:41 We'll also need a Response struct that we can ultimately return. This holds a status code and the response data:

public struct Response: Hashable, Codable {
    public init(statusCode: Int = 200, body: Data) {
        self.statusCode = statusCode
        self.body = body
    }

    public var statusCode: Int = 200
    public var body: Data
}

08:04 Another thing we'll need is a protocol to represent a built-in rule, i.e. a type that can actually return a response:

protocol BuiltinRule {
    func execute() -> Response?
}

09:01 As users of SwiftUI, we can't see the view equivalent of this protocol because it's internal, but we know it must be there, because certain base views like Text or Image have to actually draw something onscreen instead of returning another view.

We also won't expose BuiltinRule to the outside, but we can conform Response to it by making it return self from the execute method:

extension Response: Rule, BuiltinRule {
    func execute() -> Response? {
        return self
    }
}

09:55 Any BuiltinRule also conforms to Rule. When executing a rule, we check whether or not it's a built-in one, and we call its execute method. If it's not a built-in rule, we forward the run call to its body rules.

This means we'll never read the rules property of a BuiltinRule, so we can provide a default implementation that returns Never. Since the Never type has no possible values, we can implement the property with a fatal error:

extension BuiltinRule {
    public var rules: Never {
        fatalError()
    }
}

10:29 For this property to satisfy the Rule protocol, we have to conform Never itself to Rule:

extension Never: Rule {
    public var rules: some Rule {
        fatalError()
    }
}

11:01 The compiler suggests String should conform to Rule because we're trying to return a string from the Root rule. But instead, we should define a protocol for types that can be turned into Data, such as String, an HTML builder, and Codable types. A result builder can use this to create a Response with the data.

11:44 We call the ToData protocol, and its only requirement is a property that returns Data. Other names for this protocol could be DataConvertible or Serializable:

public protocol ToData {
    var toData: Data { get }
}

12:18 Then we conform String to it:

extension String: ToData {
    public var toData: Data {
        data(using: .utf8)!
    }
}

Rule Builder

13:12 Next, let's actually write the RuleBuilder result builder. Auto-completion only gives us the buildBlock method, but we prefer to implement the newer buildPartialBlock methods, which enable the builder to accept any number of components, so that we don't have to write separate variants to accommodate two, three, four, or five elements. The buildPartialBlock approach requires just two methods: one that takes a first element, and one that can combine the accumulated result with a next element.

The first method to implement takes a single element of a type conforming to Rule, and it returns the element without doing anything else:

@resultBuilder
public struct RuleBuilder {
    public static func buildPartialBlock<R: Rule>(first: R) -> some Rule {
        first
    }
}

15:11 The other method takes two elements and combines them into a RulePair, which, in turn, also conforms to Rule:

@resultBuilder
public struct RuleBuilder {
    public static func buildPartialBlock<R: Rule>(first: R) -> some Rule {
        first
    }

    public static func buildPartialBlock<R0: Rule, R1: Rule>(accumulated: R0, next: R1) -> some Rule {
        RulePair(r0: accumulated, r1: next)
    }
}

15:56 The RulePair wrapper is a BuiltinRule. We temporarily throw a fatal error in its execute method to make the code compile:

struct RulePair<R0: Rule, R1: Rule>: BuiltinRule, Rule {
    var r0: R0
    var r1: R1
    
    func execute() -> Response? {
        fatalError()
    }
}

17:54 Back in the rule builder, we also need overloads of buildPartialBlock that accept ToData types. At first, we only need the method that takes a single argument:

@resultBuilder
public struct RuleBuilder {
    public static func buildPartialBlock<R: Rule>(first: R) -> some Rule {
        first
    }

    public static func buildPartialBlock<D: ToData>(first: D) -> some Rule {
        Response(body: first.toData)
    }

    // ...
}

19:44 By marking the Rule.rules property with @RuleBuilder, we implicitly turn every conforming type's implementation of the property into a RuleBuilder. SwiftUI's View protocol does the same thing by marking the body property of the View protocol with @ViewBuilder. After doing this, our simple example of including a string in the rule builder compiles:

public protocol Rule {
    associatedtype R: Rule
    @RuleBuilder var rules: R { get }
}

21:08 Next, we want to be able to include a second sub-rule:

struct Users: Rule {
    var rules: some Rule {
//        PathReader { comp in
//            if let id = UUID(uuidString: comp) {
//                Profile(id: id)
//            } else {
//                "Not found"
//            }
//        }
        "User Index"
    }
}

struct Root: Rule {
    var rules: some Rule {
        Users() //.path("users")
        "Index"
    }
}

21:35 To make this compile, RuleBuilder needs a method that combines a Rule and a ToData:

@resultBuilder
public struct RuleBuilder {
    public static func buildPartialBlock<R: Rule>(first: R) -> some Rule {
        first
    }

    public static func buildPartialBlock<D: ToData>(first: D) -> some Rule {
        Response(body: first.toData)
    }

    public static func buildPartialBlock<R0: Rule, R1: Rule>(accumulated: R0, next: R1) -> some Rule {
        RulePair(r0: accumulated, r1: next)
    }

    public static func buildPartialBlock<R0: Rule, R1: ToData>(accumulated: R0, next: R1) -> some Rule {
        RulePair(r0: accumulated, r1: Response(body: next.toData))
    }
}

22:28 Phew, that's a lot of code to make these two simple examples compile. We want to go one step further and comment the path modifier back in. Our goal is just to make the code compile, but it doesn't have to actually work yet. So, in an extension of Rule, we write a path method that takes a path component and returns a Rule. We'll just return self for now, ignoring the path component:

extension Rule {
    public func path(_ component: String) -> some Rule {
        fatalError("TODO")
        return self
    }
}

Running

23:32 We write a run method to execute a rule. In this method, we have to check if the rule is a built-in one, so we can call execute on it. If it's not a built-in rule, we recursively call run on the rule's body:

extension Rule {
    public func run() -> Response? {
        if let b = self as? BuiltinRule {
            return b.execute()
        } else {
            return rules.run()
        }
    }
}

24:43 We should now be able to run the simplified Users rule, and it should give us a Response containing the data of the "User Index" string:

final class BackendTests: XCTestCase {
    func testUsers() throws {
        XCTAssertEqual(Users().run(), Response(body: "User Index".toData))
    }
}

25:31 The assert function needs Response to be equatable so that it can compare the outcome to the expected result. We immediately make Response conform to Hashable and Codable while we're at it, because we'll probably need these capabilities in the future. And, after doing so, the test succeeds. We'll pick up from here in the next episode.

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