SwiftUI and Combine additions to Siesta – the elegant way to write iOS / macOS REST clients.
Because of when it was written, Siesta is callback-based. Now we have
Combine publishers, @ObservableObject
, and oh yes – SwiftUI.
(If you don't know Siesta, have a quick look at a couple of the examples below – and be amazed by the simplicity of SwiftUI code accessing a REST API. Then go and read up on Siesta.)
- Easily use Siesta with SwiftUI
- Combine publishers for resources and requests
- A typed wrapper for Resource (😱 controversial!) for clearer APIs
Read on, or jump straight into one of the apps in the Examples
folder:
- SiestaExtSimpleExample: a good starting point that shows you the basics
- GithubBrowser: it's the original Siesta example app rewritten in SwiftUI. Be amazed at how little code there is – it's a thing of beauty :-)
Unlike Siesta's Resource
, most things in this project are strongly typed.
Your starting point is TypedResource<T>
, where T is the content type.
If you define your API methods using TypedResource
, the rest of your app knows what types it's
getting! For example, from the GithubAPI
example app:
func repository(ownedBy login: String, named name: String) -> TypedResource<Repository> {
service
.resource("/repos")
.child(login)
.child(name)
.typed() // Create a TypedResource from a Resource. Type inference usually figures out T.
}
TypedResource
is just a wrapper, so you can refer
to someTypedResource.resource
when you need to.
(Yes, using a typed wrapper like this is certainly an opinionated choice, but
it makes a lot of things in here work better. Plus your API classes are now
more expressive. If you really don't like this, you can still base
everything around Resource
, and call typed()
when you need to.)
Just look at the brevity of this code! You need nothing more than this and the API class. I hope you're not getting paid by the line.
struct SimpleSampleView: View {
let repoName: String
let owner: String
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("\(owner)/\(repoName)")
.font(.title)
// Here's the good bit:
ResourceView(GitHubAPI.repository(ownedBy: owner, named: repoName)) { (repo: Repository) in
// This isn't rendered until content is loaded
if let starCount = repo.starCount {
Text("★ \(starCount)")
}
if let desc = repo.description {
Text(desc)
}
}
Spacer()
}
.padding()
}
}
Or, by making your data parameter optional you can render something when you don't have data yet (but read on for a fancier solution):
ResourceView(GitHubAPI.repository(ownedBy: owner, named: repoName)) { (repo: Repository?) in
if let repo {
if let starCount = repo.starCount {
Text("★ \(starCount)")
}
if let desc = repo.description {
Text(desc)
}
}
else {
Text("Waiting patiently for the internet...")
}
}
By making a tiny change you can have all of these things:
struct StatusSampleView: View {
let repoName: String
let owner: String
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("\(owner)/\(repoName)")
.font(.title)
ResourceView(GitHubAPI.repository(ownedBy: owner, named: repoName), /* Added this bit: */ displayRules: [.loading,
.error, .anyData]) { (repo:
Repository) in
if let starCount = repo.starCount {
Text("★ \(starCount)")
}
if let desc = repo.description {
Text(desc)
}
}
Spacer()
}
.padding()
}
}
This is inspired by Siesta's ResourceStatusOverlay
, and you can control
the relative priorities of loading, error and data states in much the same
way: with the array of rules you pass. For
example, to
display data, no matter how stale: displayRules: [.anyData, .loading, .error]
.
Implement your own ResourceViewStyle
, and adopt it with the view modifier resourceViewStyle()
at the appropriate place(s) in your view hierarchy.
struct GarishResourceViewStyle: ResourceViewStyle {
// You can implement either or both of these methods
func loadingView() -> some View {
Text("Waiting....")
.font(.title2)
.foregroundStyle(Color.purple)
}
func errorView(errorMessage: String, canTryAgain: Bool, tryAgain: @escaping () -> Void) -> some View {
Text(errorMessage)
.font(.title2)
.foregroundStyle(Color.mint)
if canTryAgain {
Button("Try again", action: tryAgain)
.buttonStyle(.borderedProminent)
.foregroundStyle(Color.yellow)
}
}
}
...
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
RootView()
.resourceViewStyle(GarishResourceViewStyle())
}
}
}
Your content block can use more than one resource, and will be displayed once they all have content (or sooner, depending on the variant you choose). Particularly useful if you're intertwining content from multiple resources.
struct MultipleSampleView: View {
let repoName: String
let owner: String
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("\(owner)/\(repoName)")
.font(.title)
ResourceView(
GitHubAPI.repository(ownedBy: owner, named: repoName),
GitHubAPI.activeRepositories
) { (repo: Repository, active: [Repository]) in
if let starCount = repo.starCount {
Text("★ \(starCount)")
}
if let desc = repo.description {
Text(desc)
}
Text("In unrelated news, the first active repository is called \(active.first!.name).")
}
Spacer()
}
.padding()
}
}
In this example, the post is displayed first, then the comments are loaded. You could load them both at once, but this way your user can get reading sooner.
Also, notice the loading of user details; this must be nested as it requires the userId from the post.
ResourceView(api.post(id: postId), displayRules: .standard) { (post: Post) in
VStack {
VStack(alignment: .leading, spacing: 20) {
Text(post.title).font(.title)
Text(post.body).font(.body)
ResourceView(api.user(id: post.userId)) {
Text("– \(user.name) (\(user.email))").font(.footnote)
}
}
.padding()
ResourceView(api.comments(postId: post.id), displayRules: .standard) {
List($0) { comment in
VStack(alignment: .leading) {
Text(comment.body)
Text("– \(comment.name) (\(comment.email))").font(.footnote)
}
}
}
Spacer()
}
}
Chances are you don't want to make real network requests in your SwiftUI previews. TypedResource
has built-in
support for fakes, so you can do things like this:
struct UserView: View {
let userId: Int
let fakeUser: TypedResource<User>?
...
var body: some View {
ResourceView(fakeUser ?? api.user(id: userId)) {
...
}
}
}
// With fake data
#Preview {
UserView(fakeUser: User(id: 1, name: "Persephone", email: "p@there.com"))
}
// See what the loading view looks like
#Preview("Loading") {
UserView(fakeUser: .fakeLoading())
}
// See what the error view looks like
#Preview("Failed") {
UserView(fakeUser: .fakeFailure(RequestError(...)))
}
Parts of your app might load data from places other than Siesta. It would be a
shame to lose ResourceView
and its display logic just because your data comes from a different source.
Loadable
to the rescue – it's an abstraction of the basics of Siesta's resource
loading paradigm, and ResourceView
will load anything Loadable
(TypedResource
is a Loadable
).
If you have a Publisher
you can use that – Loadable
conformance is built in – otherwise implement Loadable
yourself.
ResourceView(someLongRunningCalculationPublisher.loadable(), displayRules: .standard) { (answer: Int) in
Text("And the answer is: \(answer)") // you just know it'll be 42
}
If you want to do something more complex, or create your own building blocks, or if you're an MVVM hound and the examples above are giving you conniptions with their lack of model objects, you can step down a level:
TypedResource
is an ObservableObject
, and its state
and content
variables are
@Published
.
TypedResource.state
is a ResourceState<T>
– a snapshot of the resource's state at a point in time.
It contains all the usual fields you'll be interested in (latestError
, etc), plus
typed content.
TypedResource
(and any Loadable
for that matter) have publishers that output
progress:
statePublisher()
outputsResourceState<T>
contentPublisher()
outputs content when there is some; it's convenient if you don't care about the rest of the stateoptionalContentPublisher()
is the same but outputsnil
to let you know there's no content yet
Subscribing to a publisher triggers loadIfNeeded()
, and retains
the Resource
until you unsubscribe.
If you like Combine, Resource
has request publisher methods, and there are
publishers available directly on Request
too.
You could use this project's publishers along with CombineCocoa. There are examples of that in this archived Siesta fork.