From bd218202a6e37c386f79e01261669b351b2a3cc5 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 18 Feb 2020 22:05:32 -0500 Subject: [PATCH] Add promises --- README.md | 4 + SimpleSwiftPromises.xcodeproj/project.pbxproj | 8 + SimpleSwiftPromises/Promise.swift | 170 ++++++++++++++++++ SimpleSwiftPromisesTests/PromiseTests.swift | 126 +++++++++++++ 4 files changed, 308 insertions(+) create mode 100644 README.md create mode 100644 SimpleSwiftPromises/Promise.swift create mode 100644 SimpleSwiftPromisesTests/PromiseTests.swift diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff9f049 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# SimpleSwiftPromises + +A simple implementation of promises in Swift. Goes with [my blog post](https://shadowfacts.net/2020/simple-swift-promises/) on the subject. + diff --git a/SimpleSwiftPromises.xcodeproj/project.pbxproj b/SimpleSwiftPromises.xcodeproj/project.pbxproj index c66bbcf..354b970 100644 --- a/SimpleSwiftPromises.xcodeproj/project.pbxproj +++ b/SimpleSwiftPromises.xcodeproj/project.pbxproj @@ -12,6 +12,8 @@ D683419C23FCDCCE00D06703 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D683419B23FCDCCE00D06703 /* Assets.xcassets */; }; D683419F23FCDCCE00D06703 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D683419D23FCDCCE00D06703 /* Main.storyboard */; }; D68341AB23FCDCCE00D06703 /* SimpleSwiftPromisesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68341AA23FCDCCE00D06703 /* SimpleSwiftPromisesTests.swift */; }; + D68341B623FCDCF000D06703 /* Promise.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68341B523FCDCF000D06703 /* Promise.swift */; }; + D68341B823FCDCF800D06703 /* PromiseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68341B723FCDCF800D06703 /* PromiseTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -35,6 +37,8 @@ D68341A623FCDCCE00D06703 /* SimpleSwiftPromisesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SimpleSwiftPromisesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D68341AA23FCDCCE00D06703 /* SimpleSwiftPromisesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleSwiftPromisesTests.swift; sourceTree = ""; }; D68341AC23FCDCCE00D06703 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D68341B523FCDCF000D06703 /* Promise.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Promise.swift; sourceTree = ""; }; + D68341B723FCDCF800D06703 /* PromiseTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PromiseTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -77,6 +81,7 @@ isa = PBXGroup; children = ( D683419723FCDCCB00D06703 /* AppDelegate.swift */, + D68341B523FCDCF000D06703 /* Promise.swift */, D683419923FCDCCB00D06703 /* ViewController.swift */, D683419B23FCDCCE00D06703 /* Assets.xcassets */, D683419D23FCDCCE00D06703 /* Main.storyboard */, @@ -90,6 +95,7 @@ isa = PBXGroup; children = ( D68341AA23FCDCCE00D06703 /* SimpleSwiftPromisesTests.swift */, + D68341B723FCDCF800D06703 /* PromiseTests.swift */, D68341AC23FCDCCE00D06703 /* Info.plist */, ); path = SimpleSwiftPromisesTests; @@ -197,6 +203,7 @@ files = ( D683419A23FCDCCB00D06703 /* ViewController.swift in Sources */, D683419823FCDCCB00D06703 /* AppDelegate.swift in Sources */, + D68341B623FCDCF000D06703 /* Promise.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -205,6 +212,7 @@ buildActionMask = 2147483647; files = ( D68341AB23FCDCCE00D06703 /* SimpleSwiftPromisesTests.swift in Sources */, + D68341B823FCDCF800D06703 /* PromiseTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/SimpleSwiftPromises/Promise.swift b/SimpleSwiftPromises/Promise.swift new file mode 100644 index 0000000..1539733 --- /dev/null +++ b/SimpleSwiftPromises/Promise.swift @@ -0,0 +1,170 @@ +// +// Promise.swift +// SimpleSwiftPromises +// +// Created by Shadowfacts on 2/14/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import Foundation + +public class Promise { + private var handlers: [(Result) -> Void] = [] + private var result: Result? + private var catchers: [(Error) -> Void] = [] + private var error: Error? + + func resolve(_ result: Result) { + self.result = result + self.handlers.forEach { $0(result) } + } + + func reject(_ error: Error) { + self.error = error + self.catchers.forEach { $0(error) } + } + + func addHandler(_ handler: @escaping (Result) -> Void) { + if let result = result { + handler(result) + } else { + handlers.append(handler) + } + } + + func addCatcher(_ catcher: @escaping (Error) -> Void) { + if let error = error { + catcher(error) + } else { + catchers.append(catcher) + } + } +} + +public extension Promise { + static func resolve(_ value: Result) -> Promise { + let promise = Promise() + promise.resolve(value) + return promise + } + + static func reject(_ error: Error) -> Promise { + let promise = Promise() + promise.reject(error) + return promise + } + + static func all(_ promises: [Promise], queue: DispatchQueue = .main) -> Promise<[Result]> { + let group = DispatchGroup() + + var results = [Result?](repeating: nil, count: promises.count) + var firstError: Error? + + for (index, promise) in promises.enumerated() { + group.enter() + promise.then { (res) in + queue.async { + results[index] = res + group.leave() + } + }.catch { (err) -> Void in + if firstError == nil { + firstError = err + } + group.leave() + } + } + + return Promise<[Result]> { (resolve, reject) in + group.notify(queue: queue) { + if let firstError = firstError { + reject(firstError) + } else { + resolve(results.compactMap { $0 }) + } + } + } + } + + convenience init(resultProvider: @escaping (_ resolve: @escaping (Result) -> Void, _ reject: @escaping (Error) -> Void) -> Void) { + self.init() + resultProvider(self.resolve, self.reject) + } + + convenience init(_ resultProvider: @escaping ((Swift.Result) -> Void) -> Void) { + self.init { (resolve, reject) in + resultProvider { (result) in + switch result { + case let .success(res): + resolve(res) + case let .failure(error): + reject(error) + } + } + } + } + + @discardableResult + func then(_ func: @escaping (Result) -> Void) -> Promise { + addHandler(`func`) + return self + } + + func then(_ mapper: @escaping (Result) -> Promise) -> Promise { + let next = Promise() + addHandler { (parentResult) in + let newPromise = mapper(parentResult) + newPromise.addHandler(next.resolve) + newPromise.addCatcher(next.reject) + } + addCatcher(next.reject) + return next + } + + func then(_ mapper: @escaping (Result) -> Next) -> Promise { + let next = Promise() + addHandler { (parentResult) in + let newResult = mapper(parentResult) + next.resolve(newResult) + } + addCatcher(next.reject) + return next + } + + @discardableResult + func `catch`(_ catcher: @escaping (Error) -> Void) -> Promise { + addCatcher(catcher) + return self + } + + func `catch`(_ catcher: @escaping (Error) -> Promise) -> Promise { + let next = Promise() + addHandler(next.resolve) + addCatcher { (error) in + let newPromise = catcher(error) + newPromise.addHandler(next.resolve) + newPromise.addCatcher(next.reject) + } + return next + } + + func `catch`(_ catcher: @escaping (Error) -> Result) -> Promise { + let next = Promise() + addHandler(next.resolve) + addCatcher { (error) in + let newResult = catcher(error) + next.resolve(newResult) + } + return next + } + + func handle(on queue: DispatchQueue) -> Promise { + return self.then { (result) in + return Promise { (resolve, reject) in + queue.async { + resolve(result) + } + } + } + } +} diff --git a/SimpleSwiftPromisesTests/PromiseTests.swift b/SimpleSwiftPromisesTests/PromiseTests.swift new file mode 100644 index 0000000..47cb46b --- /dev/null +++ b/SimpleSwiftPromisesTests/PromiseTests.swift @@ -0,0 +1,126 @@ +// +// PromiseTests.swift +// SimpleSwiftPromisesTests +// +// Created by Shadowfacts on 2/14/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import XCTest +@testable import SimpleSwiftPromises + +class PromiseTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func assertResultEqual(_ promise: Promise, _ value: Result, message: String? = nil) { + let expectation = self.expectation(description: message ?? "promise result assertion") + promise.then { + XCTAssertEqual($0, value) + expectation.fulfill() + } + self.waitForExpectations(timeout: 2) { (error) in + if let error = error { + XCTFail("didn't resolve promise: \(error)") + } + } + } + + func testResolveImmediate() { + assertResultEqual(Promise.resolve("blah"), "blah") + } + + func testResolveImmediateMapped() { + let promise = Promise.resolve("foo").then { + "test \($0)" + }.then { + Promise.resolve("\($0) bar") + } + assertResultEqual(promise, "test foo bar") + } + + func testContinueAfterReject() { + let promise = Promise.reject(TestError()).then { (res) in + XCTFail("then on rejected promise is unreachable") + }.catch { (error) -> String in + XCTAssertTrue(error is TestError) + return "caught" + }.then { + "\($0) error" + } + assertResultEqual(promise, "caught error") + } + + func testResolveDelayed() { + let promise = Promise { (resolve, reject) in + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + resolve("blah") + } + } + assertResultEqual(promise, "blah") + } + + func testResolveMappedDelayed() { + let promise = Promise { (resolve, reject) in + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + resolve("foo") + } + }.then { + "\($0) bar" + }.then { (result) in + Promise { (resolve, reject) in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + resolve("\(result) baz") + } + } + } + assertResultEqual(promise, "foo bar baz") + } + + func testResolveAll() { + let promise = Promise<[String]>.all([ + Promise.resolve("a"), + Promise.resolve("b"), + Promise.resolve("c"), + ]) + assertResultEqual(promise, ["a", "b", "c"]) + } + + func testIntermediateReject() { + let promise = Promise.resolve("foo").then { (_) -> Promise in + Promise.reject(TestError()) + }.catch { (error) -> String in + XCTAssertTrue(error is TestError) + return "caught" + }.then { (result) -> String in + "\(result) error" + } + assertResultEqual(promise, "caught error") + } + + func testResultHelper() { + let success = Promise { (handler) in + handler(Result.success("asdf")) + } + assertResultEqual(success, "asdf") + let failure = Promise { (handler) in + handler(Result.failure(TestError())) + }.catch { (error) -> String in + "blah" + } + assertResultEqual(failure, "blah") + } + +} + +struct TestError: Error { + var localizedDescription: String { + "test error" + } +}