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 View
s, 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.