Giter Club home page Giter Club logo

siestaext's Introduction

SiestaExt

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.)

Features

  • Easily use Siesta with SwiftUI
  • Combine publishers for resources and requests
  • A typed wrapper for Resource (😱 controversial!) for clearer APIs

Examples

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 :-)

Tutorial

First off, understand TypedResource

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.)

Use a Resource in SwiftUI

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...")
    }
}

Get a spinner, error reporting and a retry button with (almost) no effort

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].

But possibly you want to render progress and errors yourself

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())
        }
    }
}

Multiple resources, either all at once...

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()
    }
}

...or you can nest resource views

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()
    }
}

Fakes for Previews

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: "[email protected]"))
}

// 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(...)))
}

Load things that aren't Siesta resources!

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
}

Want more control, less magic?

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:

Published properties

TypedResource is an ObservableObject, and its state and contentvariables 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.

Combine publishers

TypedResource (and any Loadable for that matter) have publishers that output progress:

  • statePublisher() outputs ResourceState<T>
  • contentPublisher() outputs content when there is some; it's convenient if you don't care about the rest of the state
  • optionalContentPublisher() is the same but outputs nil to let you know there's no content yet

Subscribing to a publisher triggers loadIfNeeded(), and retains the Resource until you unsubscribe.

Publishers for requests too

If you like Combine, Resource has request publisher methods, and there are publishers available directly on Request too.

How about UIKit?

You could use this project's publishers along with CombineCocoa. There are examples of that in this archived Siesta fork.

siestaext's People

Stargazers

Jerome Paulos avatar Ibiyemi Abiodun avatar

Watchers

Adrian avatar

siestaext's Issues

Does not work when resources are wiped while loading?

I have the following code in my application:

struct HomeView: View {
  // other variables omitted for brevity
  
  // Profile is a struct which implements Codable and Identifiable
  @State private var friends: TypedResource<[Profile]> = ApiService.main.service.resource("/friends").typed()
  
  private func buildFriendsList() -> some View {
    VStack(alignment: .leading, spacing: 16.0) {
      Text("your nearby friends")
        .font(Font.DesignSystem.displaySmBold)
      ResourceView(friends, statusDisplay: .standard) { friends in
        List {
          
          ForEach(friends) { item in
            HStack {
              VStack(alignment: .leading) {
                Text(item.fullName)
                  .font(Font.DesignSystem.textMdSemibold)
                  .listRowBackground(Color.bgSecondary)
              }
            }
            .padding(.vertical, 8.0)
          }
          .listRowBackground(Color.clear)
          .listRowInsets(EdgeInsets())
        }
        .overlay(alignment: .top) {
          if friends.isEmpty {
            ContentUnavailableView {
              Label("no friends", systemImage: "person.fill.questionmark")
            } description: {
              Text("add your friends on rdvz to invite them on adventures")
            }
          }
        }
        .listStyle(.plain)
        .scrollContentBackground(.hidden)
      }
    }
    .padding(.horizontal, 24.0)
    .padding(.vertical, 24.0)
  }
}

And my ApiService.swift looks like this:

import Foundation
import Siesta
import Siesta_Alamofire
import SiestaExt
import Alamofire
import Combine
import AlamofireNetworkActivityLogger

@MainActor
class ApiService {
  var service: Service
  
  var authToken: String? {
    didSet {
      DispatchQueue.main.schedule { [weak self] in
        self?.service.invalidateConfiguration()
        self?.service.wipeResources()
      }
    }
  }
  
  init() {
    
#if DEBUG
    SiestaLog.Category.enabled = .all
    NetworkActivityLogger.shared.level = .debug
    NetworkActivityLogger.shared.startLogging()
#endif
    
    let reqManager = Session()
    
    service = Service(
      baseURL: "http://localhost:4321/api",
      standardTransformers: [.json, .text],
      networking: reqManager
    )
    
    service.configure("**") { [weak self] in
      $0.headers["user-agent"] = "rdvz/ios"
      $0.headers["accept"] = "application/json"
      
      if let authToken = self?.authToken {
        $0.headers["authorization"] = "Bearer \(authToken)"
      }
    }
    
    Task { [weak self] in
      for await (event, session) in await supabaseClient.auth.authStateChanges {
        self?.authToken = session?.accessToken
      }
    }
  }
  
  static let main = ApiService()
}

When Supabase emits an event to point out that the user has logged in, this happens at the same time as the initial request to the backend for the friends list. The initial request is cancelled, but then the application doesn't send another request to get the data, it just does nothing and shows no data.

In the Xcode preview, where I am not logged in with Supabase, I can see the request hitting my localhost server, but in the Simulator, where I am logged in, no request ever reaches my server. I also tried making HomeView.friends a computed property, as well as inlining the call to .resource() into my view builder; neither of these approaches worked.

The logs from my app look like this:

Siesta:configuration  │ Added config 0 [Siesta standard JSON parsing]
Siesta:configuration  │ Added config 1 [Siesta standard text parsing]
Siesta:configuration  │ URL pattern ** compiles to regex ^http:\/\/localhost:4321\/api\/[^?]*($|\?)
Siesta:configuration  │ Added config 2 [**]
Siesta:configuration  │ Computing configuration for GET Resource(…/friends)[]
Siesta:configuration  │   ├╴Applying config 0 [Siesta standard JSON parsing]
Siesta:configuration  │   ├╴Applying config 1 [Siesta standard text parsing]
Siesta:configuration  │   ├╴Applying config 2 [**]
Siesta:configuration  │   └╴Resulting configuration 
Siesta:configuration  │       expirationTime:            30.0 sec
Siesta:configuration  │       retryTime:                 1.0 sec
Siesta:configuration  │       progressReportingInterval: 0.05 sec
Siesta:configuration  │       headers (2)
Siesta:configuration  │         user-agent: rdvz/ios
Siesta:configuration  │         accept: application/json
Siesta:configuration  │       requestDecorators: 0
Siesta:configuration  │       pipeline
Siesta:configuration  │         ║ rawData stage (no transformers)
Siesta:configuration  │         ║ decoding stage (no transformers)
Siesta:configuration  │         ║ parsing stage
Siesta:configuration  │         ╟   ⟨*/json */*+json⟩ Data → JSONConvertible  [transformErrors: true]
Siesta:configuration  │         ╟   ⟨text/*⟩ Data → String  [transformErrors: true]
Siesta:configuration  │         ║ model stage (no transformers)
Siesta:configuration  │         ║ cleanup stage (no transformers)
Siesta:network        │ Cache request for Resource(…/friends)[]
Siesta:staleness      │ Resource(…/friends)[] is not up to date: no error | no data
Siesta:network        │ Chain[Request:6000009f6080(Cache request for Resource(…/friends)[])]
Siesta:networkDetails │ Cache request for Resource(…/friends)[] already started
Siesta:staleness      │ Resource(…/friends)[L] is not up to date: no error | no data
Siesta:staleness      │ Resource(…/friends)[] is not up to date: no error | no data
Siesta:networkDetails │ Request: 
Siesta:networkDetails │     headers (2)
Siesta:networkDetails │       User-Agent: rdvz/ios
Siesta:networkDetails │       Accept: application/json
Siesta:network        │ GET http://localhost:4321/api/friends
Siesta:observers      │ Resource(…/friends)[L] sending requested event to 1 observer
Siesta:observers      │   ↳ requested → ClosureObserver(ObservableResource.swift:15)
Siesta:staleness      │ Resource(…/friends)[L] is not up to date: no error | no data
Siesta:networkDetails │ GET http://localhost:4321/api/friends already started
Siesta:configuration  │ Configurations need to be recomputed
Siesta:stateChanges   │ Resource(…/friends)[L] wiped
Siesta:network        │ Cancelled GET http://localhost:4321/api/friends
Siesta:network        │ cancel() called but request already completed: GET http://localhost:4321/api/friends
Siesta:observers      │ Resource(…/friends)[L] sending newData(wipe) event to 1 observer
Siesta:observers      │   ↳ newData(wipe) → ClosureObserver(ObservableResource.swift:15)
Siesta:configuration  │ Computing configuration for GET Resource(…/friends)[L]
Siesta:configuration  │   ├╴Applying config 0 [Siesta standard JSON parsing]
Siesta:configuration  │   ├╴Applying config 1 [Siesta standard text parsing]
Siesta:configuration  │   ├╴Applying config 2 [**]
Siesta:configuration  │   └╴Resulting configuration 
Siesta:configuration  │       expirationTime:            30.0 sec
Siesta:configuration  │       retryTime:                 1.0 sec
Siesta:configuration  │       progressReportingInterval: 0.05 sec
Siesta:configuration  │       headers (3)
Siesta:configuration  │         authorization: Bearer <JWT goes here, I removed it>
Siesta:configuration  │         user-agent: rdvz/ios
Siesta:configuration  │         accept: application/json
Siesta:configuration  │       requestDecorators: 0
Siesta:configuration  │       pipeline
Siesta:configuration  │         ║ rawData stage (no transformers)
Siesta:configuration  │         ║ decoding stage (no transformers)
Siesta:configuration  │         ║ parsing stage
Siesta:configuration  │         ╟   ⟨*/json */*+json⟩ Data → JSONConvertible  [transformErrors: true]
Siesta:configuration  │         ╟   ⟨text/*⟩ Data → String  [transformErrors: true]
Siesta:configuration  │         ║ model stage (no transformers)
Siesta:configuration  │         ║ cleanup stage (no transformers)
Siesta:staleness      │ Resource(…/friends)[L] is not up to date: no error | no data
Siesta:network        │ Cache request for Resource(…/friends)[L]
---------------------
GET 'http://localhost:4321/api/friends':
cURL:
$ curl -v \
	-X GET \
	-H "Accept-Language: en-US;q=1.0" \
	-H "Accept-Encoding: br;q=1.0, gzip;q=0.9, deflate;q=0.8" \
	-H "User-Agent: rdvz/ios" \
	-H "Accept: application/json" \
	"http://localhost:4321/api/friends"
Siesta:observers      │ Resource(…/friends)[] sending requestCancelled event to 1 observer
Siesta:observers      │   ↳ requestCancelled → ClosureObserver(ObservableResource.swift:15)
Siesta:staleness      │ Resource(…/friends)[] is not up to date: no error | no data
---------------------
[Error] GET 'http://localhost:4321/api/friends' [0.0242 s]:
Error Domain=NSURLErrorDomain Code=-999 "cancelled" UserInfo={NSErrorFailingURLStringKey=http://localhost:4321/api/friends, NSLocalizedDescription=cancelled, NSErrorFailingURLKey=http://localhost:4321/api/friends}
Siesta:network        │ Response:  explicitlyCancelled ← GET http://localhost:4321/api/friends
Siesta:networkDetails │ Raw response headers: nil
Siesta:networkDetails │ Raw response body: 0 bytes
Siesta:networkDetails │ Received response, but request was already cancelled: GET http://localhost:4321/api/friends 
Siesta:networkDetails │     New response: RequestError(userMessage: "Request explicitly cancelled.", httpStatusCode: nil, entity: nil, cause: Optional(Alamofire.AFError.explicitlyCancelled), timestamp: 732752061.707411)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.