/Syrup

Asynchronous data flow in Swift using enums

Primary LanguageSwiftMIT LicenseMIT

Syrup

Overview

Syrup makes data flow simple in Swift. Each step is represented by its own case in an enum.

Associated values are used to keep state for each step. This makes it very easy to implement — just write what it takes to get from one step to the next, and so on.

Grand Central Dispatch is then used to run the steps asynchronously on a queue.

Having explicit enum cases for each step makes it easy to test from any point in the data flow.

Installation

Carthage

github "RoyalIcing/Syrup"

Usage

A real world example for loading and saving JSON in my app Lantern can be seen here: https://github.com/RoyalIcing/Lantern/blob/9e5e8aa95e967b07a9968efaef22e8c10ea3358f/LanternModel/ModelManager.swift#L41


The example below scopes access to a security scoped file.

struct FileAccessProgression : Progression {
	let fileURL: URL
	private var startAccess: Bool
	private var done: Bool
	
	init(fileURL: URL) {
		self.fileURL = fileURL
		startAccess = true
		done = false
	}
	
	enum ErrorKind : Error {
		case cannotAccess(fileURL: URL)
	}
	
	mutating func updateOrDeferNext() throws -> Deferred<FileAccessProgression>? {
		if startAccess {
			let accessSucceeded = fileURL.startAccessingSecurityScopedResource()
			if !accessSucceeded {
				throw ErrorKind.cannotAccess(fileURL: fileURL)
			}
		}
		else {
			fileURL.stopAccessingSecurityScopedResource()
		}
		
		done = true
		
		// Mutated, so no need to return future
		return nil
	}
	
	typealias Result = FileAccessProgression
	var result: FileAccessProgression? {
		guard done else { return nil }
		
		var copy = self
		if startAccess {
			copy.startAccess = false
			copy.done = false
		}
		return copy
	}
}

Each step updates to or returns its next step. Asynchronous steps can return a Deferred which resolves to the next step.

Syrup runs each step on a Grand Central Dispatch queue.

To run, create a progression and divide it by the quality of service to run on. Then bind >>= a callback to start the progression and receive the result.

Your callback is passed a throwing function useResult — call it to get the result. Errors thrown in any of the steps will bubble up, so use Swift error handling to catch them all here in the one place.

FileAccessProgression(fileURL: fileURL) / .utility >>= { useResult in
	do {
		let stopAccessing = try useResult()
		// Use stopAccessing.fileURL

		// Run when done accessing
		stopAccessing / .utility >>= { _ in
		}
		catch {
			// Handle `error` here
		}
	}
}

Using existing asynchronous libraries

Syrup can create tasks for existing asychronous libraries, such as NSURLSession. Use the .future task, and resolve the value, or resolve throwing an error.

enum HTTPRequestProgression : Progression {
	typealias Result = (response: HTTPURLResponse, body: Data?)
	
	case get(url: URL)
	case post(url: URL, body: Data)
	
	case success(Result)
	
	mutating func updateOrDeferNext() -> Deferred<HTTPRequestProgression>? {
		switch self {
		case let .get(url):
			return Deferred.future{ resolve in
				let session = URLSession.shared
				let task = session.dataTask(with: url, completionHandler: { data, response, error in
					if let error = error {
						resolve{ throw error }
					}
					else {
						resolve{ .success((response: response as! HTTPURLResponse, body: data)) }
					}
				}) 
				task.resume()
			}
		case let .post(url, body):
			return Deferred.future{ resolve in
				let session = URLSession.shared
				var request = URLRequest(url: url)
				request.httpBody = body
				let task = session.dataTask(with: request, completionHandler: { (data, response, error) in
					if let error = error {
						resolve { throw error }
					}
					else {
						resolve { .success((response: response as! HTTPURLResponse, body: data)) }
					}
				}) 
				task.resume()
			}
		case .success:
			return nil
		}
	}
	
	var result: Result? {
		guard case let .success(result) = self else { return nil }
		return result
	}
}

Motivations

  • Captures data flow in a declarative form making it easier to understand. Your progression is a reusable recipe for what to do.
  • Associated values capture the entire state at a particular stage in the flow. There’s no external state or side effects, just work with what’s stored in each case.
  • Each step is distinct, and can produce its next step easily in either a sychronous or asychronous manner.
  • Steps are able to be stored and restored at will, as they are just enums with associated data. This allows easier unit testing, since you can resume at any step in the progression.
  • Swift’s native error handling is used.

Multiple inputs or outputs

Stages can have multiple choices of initial stages: just add multiple cases!

For multiple choice of output, use a enum for the Result associated type.

Composing stages

Progression includes .map and .flatMap (also >>=) methods, allowing progressions to be composed inside other progressions. A series of progressions can become a single progression in a combined enum, and so on.

For example, combining a file read with a web upload:

enum HTTPRequestProgression : Progression {
	typealias Result = (response: HTTPURLResponse, body: Data?)
	
	case get(url: URL)
	case post(url: URL, body: Data)
	
	case success(Result)
	
	mutating func updateOrDeferNext() -> Deferred<HTTPRequestProgression>? {
		switch self {
		case let .get(url):
			return Deferred.future{ resolve in
				let session = URLSession.shared
				let task = session.dataTask(with: url, completionHandler: { data, response, error in
					if let error = error {
						resolve{ throw error }
					}
					else {
						resolve{ .success((response: response as! HTTPURLResponse, body: data)) }
					}
				}) 
				task.resume()
			}
		case let .post(url, body):
			return Deferred.future{ resolve in
				let session = URLSession.shared
				var request = URLRequest(url: url)
				request.httpBody = body
				let task = session.dataTask(with: request, completionHandler: { (data, response, error) in
					if let error = error {
						resolve { throw error }
					}
					else {
						resolve { .success((response: response as! HTTPURLResponse, body: data)) }
					}
				}) 
				task.resume()
			}
		case .success:
			break
		}
		return nil
	}
	
	var result: Result? {
		guard case let .success(result) = self else { return nil }
		return result
	}
}