PromiseQ

1.7.0

Javascript style promises with async/await, suspend/resume and cancel features for Swift.
ikhvorost/PromiseQ

What's New

1.7.0

2021-09-22T13:17:40Z

Added:

  • Promise initializer can throw errors
  • Promise initializer can return a promise
  • isCancelled property
  • await global function

Fixed:

  • Promise's timeout and cancellation race conditions

PromiseQ

Language: Swift Platform: iOS 8+/macOS10.11 SPM compatible build & test codecov swift doc coverage

PromiseQ: Promises with async/await, suspend/resume and cancel features for Swift.

Fast, powerful and lightweight implementation of Promises for Swift.

Features

High-performance

Promises closures are called synchronously one by one if they are on the same queue and asynchronous otherwise.

Lightweight

Whole implementation consists on several hundred lines of code.

Memory safe

PromiseQ is based on struct and a stack of callbacks that removes many problems of memory management such as reference cycles etc.

Standard API

Based on JavaScript Promises/A+ spec, supports async/await and it also includes standard methods: Promise.all, Promise.race, Promise.any, Promise.resolve/reject.

Suspension

It is an additional useful feature to suspend the execution of promises and resume them later. Suspension does not affect the execution of a promise that has already begun it stops execution of next promises.

Cancelation

It is possible to cancel all queued promises at all in case to stop an asynchronous logic. Cancellation does not affect the execution of a promise that has already begun it cancels execution of the next promises.

Basic Usage

Promise

Promise is a generic type that represents an asynchronous operation and you can create it in a simple way with a closure e.g.:

Promise {
    try String(contentsOfFile: file)
}

The provided closure is called asynchronously after the promise is created. By default the closure runs on the global default queue DispatchQueue.global() but you can also specify a needed queue to run:

Promise(.main) {
    self.label.text = try String(contentsOfFile: file) // Runs on the main queue
}

The promise can be resolved when the closure returns a value or rejected when the closure throws an error.

Also the closure can settle the promise with resolve/reject callbacks for asynchronous tasks:

Promise { resolve, reject in
    // Will be resolved after 2 secs
    DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
        resolve("done")
    }
}

then

It takes a provided closure and returns new promise. The closure runs when the current promise is resolved, and receives the result.

Promise {
    try String(contentsOfFile: "file.txt")
}
.then { text in
    print(text)
}

In this way we can pass results through the chain of promises:

Promise {
    try String(contentsOfFile: "file.txt")
}
.then { text in
    return text.count
}
.then { count in
    print(count)
}

Also the closure can return a promise and it will be injected in the promise chain:

Promise {
    return 200
}
.then { value in
    Promise {
        value / 10
    }
}
.then { value in
    print(value)
}
// Prints "20"

catch

It takes a closure and return a new promise. The closure runs when the promise is rejected, and receives the error.

Promise {
    try String(contentsOfFile: "nofile.txt") // Jumps to catch
}
.then { text in
    print(text) // Doesn't run
}
.catch { error in
    print(error.localizedDescription)
}
// Prints "The file `nofile.txt` couldn’t be opened because there is no such file."

finally

This always runs when the promise is settled: be it resolve or reject so it is a good handler for performing cleanup etc.

Promise {
    try String(contentsOfFile: "file.txt")
}
.finally {
    print("Finish reading") // Always runs
}
.then { text in
    print(text)
}
.catch { error in
    print(error.localizedDescription)
}
.finally {
    print("The end") // Always runs
}

Promise.resolve/reject

These are used for compatibility e.g. when it's simple needed to return a resolved or rejected promise.

Promise.resolve creates a resolved promise with a given value:

Promise {
    return 200
}

// Same as above
Promise.resolve(200)

Promise.reject creates a rejected promise with a given error:

Promise {
    throw error
}

// Same as above
Promise<Void>.reject(error)

Promise.all

It returns a promise that resolves when all listed promises from the provided list are resolved, and the array of their results becomes its result. If any of the promises is rejected, the promise returned by Promise.all immediately rejects with that error:

Promise.all(
    Promise {
        return "Hello"
    },
    Promise { resolve, reject in
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            resolve("World")
        }
    }
)
.then { results in
    print(results)
}
// Prints ["Hello", "World"]

You can set settled=true param to make a promise that resolves when all listed promises are settled regardless of their results:

Promise.all(settled: true,
    Promise<Any> { resolve, reject in
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            reject(error)
        }
    },
    Promise {
        return 200
    }
)
.then { results in
    print(results)
}
// Prints [error, 200]

If there are no promises or array of promises is empty the promise resolves with empty array.

Promise.race

It makes a promise that waits only for the first settled promise from the given list and gets its result or error.

Promise.race(
    Promise { resolve, reject in
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) { // Wait 2 secs
            reject("Error")
        }
    },
    Promise { resolve, reject in
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) { // Wait 1 sec
            resolve(200)
        }
    }
)
.then {
    print($0)
}
// Prints "200"

If there are no promises or array of promises is empty the promise rejects with PromiseError.empty error.

Promise.any

It's similar to Promise.race, but waits only for the first fulfilled promise and gets its result.

Promise.any(
    Promise { resolve, reject in
        reject("Error")
    },
    Promise { resolve, reject in
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) { // Waits 1 sec
            resolve(200)
        }
    }
)
.then {
    print($0)
}
// Prints "200"

If there are no promises or array of promises is empty the promise rejects with PromiseError.empty error.

If all of the given promises are rejected, then the returned promise is rejected with PromiseError.aggregate – a special error that stores all promise errors.

Promise.any(
    Promise { resolve, reject in
        reject("Error")
    },
    Promise { resolve, reject in
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) { // Waits 1 sec
            reject("Fail")
        }
    }
)
.catch { error in
    if case let PromiseError.aggregate(errors) = error {
        print(errors, "-", error.localizedDescription)
    }
}
// Prints: '["Error", "Fail"]' - All Promises rejected.

Advanced Usage

timeout

timeout parameter allows to wait for a promise for a time interval and reject it with PromiseError.timedOut error, if it doesn't resolve within the given time.

Promise(timeout: 10) { // Wait 10 secs for data
    try loadData()
}
.then(timeout: 1) { data in //  Wait 1 sec for parsed data
    try parse(data)
}
.catch(timeout: 1) { error in // Wait 1 sec to handle errors
    if case PromiseError.timedOut = error {
        print(error.localizedDescription)
    }
    else {
        handleError(error)
    }
}

retry

retry parameter provides the ability to reattempt a task if the promise is rejected. By default, there is a single attempt to resolve the promise but you can increase the number of attempts with this parameter:

Promise(retry: 3) { // Makes 3 attempts to load data after the rejection
    try loadData()
}
.then { data in
    parse(data)
    ...
}
.catch { error in
    print(error.localizedDescription) // Calls if the `loadData` fails 4 times (1 + 3 retries)
}

async/await

It's a special notation to work with promises in a more comfortable way and it’s easy to understand and use.

async is a alias for Promise so you can use it to create a promise as well:

// Returns a promise with `String` type
func readFile(_ file: String) -> async<String> {
    return async {
        try String(contentsOfFile: file)
    }
}

await() is a function that waits synchronously for a result of the promise or throws an error otherwise.

let text = try readFile("file.txt").await()
// OR
let text = try `await` { readFile("file.txt") }

To avoid blocking the current queue (such as main UI queue) we can pass await() inside the other promise (async block) and use catch to handle errors as usual:

async {
    let text = try readFile("file.txt").await()
    print(text)
}
.catch { error in
    print(error.localizedDescription)
}

suspend/resume

suspend() temporarily suspends a promise. Suspension does not affect the execution of the current promise that has already begun it stops execution of next promises in the chain. The promise can continue executing at a later time with resume().

let promise = Promise {
    String(contentsOfFile: file)
}
promise.suspend()
...
// Later
promise.resume()

cancel

Cancels execution of the promise and reject it with PromiseError.cancelled error. Cancelation does not affect the execution of the promise that has already begun it rejects the promise and stops execution of next promises in the chain.

let promise = Promise {
    return "Text" // Never runs
}
.then { text in
    print(text) // Never runs
}
.catch { error in
    if case PromiseError.cancelled = error {
        print(error.localizedDescription)
    }
}

promise.cancel()

// Prints: The Promise cancelled.

Canceling an promise does not actively stop long term processing code from executing. You can check cancellation of your promise with isCancelled method and stop your code if the method returns true.

let promise = Promise { 10_000 }

promise.then { count in
    for i in 0..<count {
        guard !promise.isCancelled else {
            return
        }
        ...
    }
}

You can also break the promise chain for some conditions to call cancel inside any closure of your promise chanin e.g.:

let promise = Promise {
    return getStatusCode()
}

promise.then { statusCode in
    guard statusCode == 200 else {
        promise.cancel() // Breaks the promise chain
        return
    }
    ...
}
.then {
    ... // Never runs in case of cancel
}

Asyncable

Asyncable protocol represents an asynchronous task type that can be suspended, resumed and canceled:

public protocol Asyncable {
    func suspend() // Temporarily suspends a task.
    func resume() // Resumes the task, if it is suspended.
    func cancel() // Cancels the task.
}

Promise can manage an asynchronous task when it wraps one. For instance it's useful for network requests:

// The wrapped asynchronous task must be conformed to `Asyncable` protocol.
extension URLSessionDataTask: Asyncable {
}

let promise = Promise<Data> { resolve, reject, task in // `task` is in-out parameter
    task = URLSession.shared.dataTask(with: request) { data, response, error in
        guard error == nil else {
            reject(error!)
            return
        }
        resolve(data)
    }
    task.resume()
}

// The promise and the data task will be suspended after 2 secs and won't produce any network activity.
// but they can be resumed later.
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
    promise.suspend()
}

You can also make your custom asynchronous task that can be managed by a promise:

class TimeOutTask : Asyncable {
    let timeOut: TimeInterval
    var work: DispatchWorkItem?
    let fire: () -> Void

    init(timeOut: TimeInterval, _ fire: @escaping () -> Void) {
        self.timeOut = timeOut
        self.fire = fire
    }

    // MARK: Asyncable

    func suspend() {
        cancel()
    }

    func resume() {
        work = DispatchWorkItem(block: self.fire)
        DispatchQueue.global().asyncAfter(deadline: .now() + timeOut, execute: work!)
    }

    func cancel() {
        work?.cancel()
        work = nil
    }
}

// Promise
let promise = Promise<String> { resolve, reject, task in // `task` is in-out parameter
    task = TimeOutTask(timeOut: 3) {
        resolve("timed out") // Won't be called
    }
    task.resume()
}
.then { text in
    print(text) // Won't be called
}

// Both the promise and the timed out task will be canceled after 1 sec
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
    promise.cancel()
}

Network requests

fetch

You can send network requests to the server and load new information whenever it’s needed with asynchronous fetch utility function. It starts the request right away and returns a promise that the calling code should use to get the result. The promise, returned by fetch, resolves with HTTPResponse object as soon as the server responds and you can use it to access to the response properties:

  • ok: Bool - true if the HTTP status code is 200-299.
  • statusCodeDescription: String – HTTP status code with description.
  • response: HTTPURLResponse - Response metadata object, such as HTTP headers and status code.
  • data: Data? - The data returned by the server.
  • text: String? - text from data.
  • json: Any? - json from data.

For example:

fetch("https://api.github.com/users/technoweenie") // Get github user's info
.then { response in
    guard response.ok else { // Check for the HTTP status code
        throw response.statusCodeDescription
    }

    guard let json = response.json as? [String: Any] else { // Get json from the returned data
        throw "No JSON"
    }

    if let name = json["name"] as? String { // Get name of the user
        print(name)
    }
}
.catch {error in
    print(error.localizedDescription)
}

// Prints: risk danger olson

By default fetch does GET request with default headers and empty body data but you can change that with optional parameters:

  • method: HTTPMethod - HTTP-method, e.g. .GET, .POST etc.
  • headers: [String : String]? - a dictionary containing all of the HTTP header fields for a request.
  • body: Data? - the request body.
  • retry: Int - The max number of retry attempts to resolve the promise after rejection.
async {
    let response = try fetch(url,
        method: .POST,
        headers: ["Accept-Encoding" : "br, gzip, deflate"],
        body: data
    ).await()

    ...
}
.catch {error in
    print(error.localizedDescription)
}

fetch uses URLSession.default to make requests by default but you also can call it on your session instance and tune various aspects of the session’s behavior, including the cache policy, timeout interval etc.:

let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 120
config.httpAdditionalHeaders = ["Accept-Encoding" : "br, gzip, deflate"]
let session = URLSession(configuration: config)

async {
    let response = try session.fetch(url).await()
    ...
}
.catch {error in
    print(error.localizedDescription)
}

download

download works similar to fetch but saves data to a file and informs about the downloading progress, for instance:

async {
    let response = try download("http://speedtest.tele2.net/1MB.zip") { task, written, total in
        let percent = Double(written) / Double(total)
        print(percent)
    }.await()

    guard response.ok else {
        throw response.statusCodeDescription
    }

    guard let location = response.location else {
        throw "No location"
    }

    print(location)
}
.catch { error in
    print(error.localizedDescription)
}

// Prints
0.038433074951171875
0.10195541381835938
...
0.9263648986816406
1.0
file:///var/folders/nt/mrsc3jhd13j8zhrhxy4x23y40000gp/T/pq_CFNetworkDownload_t94Pig.tmp

upload

download works similar to fetch but can upload data or a file to a server and informs about the uploading progress, for instance:

async {
    let response = try upload(url, data: data) { task, sent, total in
        let percent = Double(sent) / Double(total)
        print(percent)
    }.await()

    guard response.ok else {
        throw response.statusCodeDescription
    }

    print("Uploaded")
}
.catch { error in
    print(error.localizedDescription)
}

// Prints
0.03125
0.0625
...
0.96875
1.0
Uploaded

Samples

There are two variants of code to fetch avatars of first 30 GitHub users.

Using then:

struct User : Codable {
    let login: String
    let avatar_url: String
}

fetch("https://api.github.com/users") // Load json with users
.then { response -> [User] in
    guard response.ok else {
        throw response.statusCodeDescription
    }

    guard let data = response.data else {
        throw "No data"
    }

    return try JSONDecoder().decode([User].self, from: data) // Parse json
}
.then { users -> Promise<Array<HTTPResponse>> in
    return Promise.all(
        users
        .map { $0.avatar_url }
        .map { fetch($0) }
    )
}
.then { responses in
    responses
        .compactMap { $0.data }
        .compactMap { UIImage(data: $0)} // Create array of images
}
.then(.main) { images in
    print(images.count) // Print a count of images on the main queue
}
.catch { error in
    print(error.localizedDescription)
}

Using async/await:

async {
    let response = try `await` { fetch("https://api.github.com/users") }

    guard response.ok else {
        throw response.statusCodeDescription
    }

    guard let data = response.data else {
        throw "No data"
    }

    let users = try JSONDecoder().decode([User].self, from: data)

    let images = try `await` {
        Promise.all(
            users.map { fetch($0.avatar_url) }
        )
    }
    .compactMap { $0.data }
    .compactMap { UIImage(data: $0) }

    // Switch to the main queue
    async(.main) {
        print(images.count)
    }
}
.catch { error in
    print(error.localizedDescription)
}

For more samples see PromiseQTests.swift.

Installation

XCode project

  1. Select Xcode > File > Swift Packages > Add Package Dependency...
  2. Add package repository: https://github.com/ikhvorost/PromiseQ.git
  3. Import the package in your source files: import PromiseQ

Swift Package

Add PromiseQ package dependency to your Package.swift file:

let package = Package(
    ...
    dependencies: [
        .package(url: "https://github.com/ikhvorost/PromiseQ.git", from: "1.0.0")
    ],
    targets: [
        .target(name: "YourPackage",
            dependencies: [
                .product(name: "PromiseQ", package: "PromiseQ")
            ]
        ),
        ...
    ...
)

License

PromiseQ is available under the MIT license. See the LICENSE file for more info.

Description

  • Swift Tools 5.3.0
View More Packages from this Author

Dependencies

  • None
Last updated: Sat Oct 19 2024 20:07:30 GMT-0900 (Hawaii-Aleutian Daylight Time)