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 {
"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 {
"User Index"
}
}
struct Root: Rule {
var rules: some Rule {
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.