Ombi

Elegant reactive networking with Combine and Swift

Build status release license documentation swift-version

Introduction

Ombi is a simple library built on top of URLSession and Combine that makes it very easy to execute asynchronous network requests using reactive programming APIs. It requires Combine to work correctly, and does not support Swift runtime environments that do not also support Combine.

Installation

Ombi uses the The Swift Package Manager for distrubition. For now, this is the only supported method of installation, but others will be added soon.

Add Ombi to your Package.swift file like so:

dependencies: [
    .package(url: "https://github.com/vsanthanam/Ombi.git", .upToNextMajor(from: "1.0.0"))
]

Documentation

You can view the documentation for the latest stable release at docs.ombi.network. For any given copy of the repository, you can generate version specific docs using jazzy and the included script:

$ cd path to repo
$ ./gen-docs.sh

Basic Usage

Making a network request with ombi is pretty straight forward. Create a Requestable, initialize a RequestManager, and invoke the makeRequest method using your Requestable You will recieve a Publisher which will allow you to handle network responses or errors as they occur.

There are two main ways to use the framework: Creating your own types that conform to Requestable, or using the provided ComposableRequest type, which already conforms to Requestable. This document will briefly cover both strategies.

Requestable Interface

The Requestable protocol defines everything the RequestManager needs to make the HTTP request.

See this example for a “POST” request to “/posts/update” that sends an AnyJSON body and expects a String response:


// Define `Requestable`
struct MyRequest: Requestable {

    typealias RequestBody = AnyJSON
    typealias ResponseBody = String
    typealias ResponseError = HTTPError

    var path: String {
        "/posts/update"
    }

    var method: RequestMethod {
        .post
    }

    var headers: RequestHeaders {
        [
            .contentType: .contentType(.json),
            .acceptType: .contentType(.json)
        ]
    }

    var body: AnyJSON {
        [
            "title" : "My New Post",
            "body" : "lorem ipsum dolor sit amit"
        ]
    }
}

// Initialize `Requestable`
let request = MyRequest()

// Initialize `RequestManager`
let requestManager = MyRequestManager(host: "https://api.myapp.com")

// Invoke `makeRequest(_:retries:sla:on:fallbackResponse:)` and observe
let cancellable = requestManager.makeRequest(request)
    .sink(receiveCompletion: { completion in
        switch completion {
        case .finished:
            break
        case .failure(let error):
            // ... handle error here ...
        }
    }, recieveValue: { response in
        // ... handle response here ...
    })

The Requestable protocol has many other fields that aren’t included in the example, but most of them are implemented for you by default in a protocol extension. The main choice you need to make is what types you need to use to express RequestBody and ResponseBody. The Requestable protocol requires you to provide BodyEncoder and BodyDecoder for the two types choose, respectively. These are provided for you by default if you use any of the following types:

If you are using a type not mentioned above, but still do not want to provide a Requestable instance specific encoder or decoder, make that type conform to AutomaticBodyCoding, and Requestable will generate the appropropriate encoder / decoder for you. A failure to use a type that doesn’t provide its own encoder / decoder, in conjunction with a failure to specify an encoder/decoder within the Requestable itself will result in a compile time error.

You can create a single type for every request you might make, or you can create parameterized, reusable types:

struct LoginRequest: HTTPRequest {

    init(username: String, password: String) {
        body = RequestBody(username: username, password: password)
    }

    struct RequestBody: Codable {
        let username: String
        let password: String
    }

    struct ResponseBody: Codable {
        let token: String
    }

    let path: String = "/login"

    let method: RequestMethod = .get

    var headers: RequestHeaders {
        [
            .acceptType: .contentType(.json),
            .contentType: .contentType(.json)
            "My-Custom-Header": "My-Value"
        ]
    }

    let body: RequestBody
}

let request = LoginRequest(username: "MyUserName", password: "MyPassword")
let manager = RequestManager(host: "https://api.myapp.com")

var token: String?

let cancellable = manager.makeRequest(request)
    .removeErrors()
    .map(\.body.token)
    .sink { token in
        self.token = token
    }

RequestManager

While most of the properties of the request are definied within the Requestable, a few of them are properties of the request manager, and a few others are additional parameters in the makeRequest method.

In addition to providing the host URL, the request manager can automatically inject headers, setup retries counts, setup a response SLA, and more.

/// Custom Log
let myLog = OSLog(subsystem: "com.developer.subsystem", category: "api.myapp.com")

let manager = RequestManager(host: "https://api.myapp.com", log: myLog)

// Inject custom headers
let manager.additionalHeaders = [
    .authorization: .authorization(type: .bearer, token: "MyToken")
] // these headers will be added to every request

let request = MyRequest() // conforms to `Requestable`
let cancellable = manager.sendRequest(request,
                                      retries: 1,
                                      sla: .seconds(60),
                                      on: DispatchQueue.main) // specify sla, retry count, and dispatch queue
    .sink(receiveCompletion: { completion in
        switch completion {
        case .finished:
            break
        case .failure(let error):
            // ... handle error ...
        }
    }, receiveValue: { response in
        // ... handle response ...
    })

Responses, Errors and Validation

The publisher returned by makeRequest uses a generic types for its Output and its Error. The output is a RequestResponse, a generic type specialized using the Requestable‘s ResponseBody constraint. The error is a RequestError specialized using the Requestable’s ResponseError containt.

RequestResponse

If the request doesnt fail, the publisher will emit a single RequestResponse value before completing. This value type contains the URL, headers, status code, and decoded body content of the request.

RequestError and Validation

The RequestError type describes a number of errors that could occur when making a request, from a broken connection, to failed encoding // decoding, to timeouts and SLA violations.

However, a request could complete and still fail. Once a RequestResponse is generated, the Requestable(s) responseValidator is used to examine the contents of the request for ResponseError(s). The default response validator allows any request to complete, regardless of its content. If you specialize your Requestable with ResponseError == HTTPError, the default response validator will perform basic validation based on the HTTP status code. If you choose to provide your own error model, remember to provide a ResponseValidator with the Requestable as well.

All RequestResponse(s), including ones provided by the RequestManager’s fallbackResponse parameter, will go through validation step. This means that even your backup response provided from disk could still result in an error, for example, if that response’s status code is 404 and the Requestable has been specialized with a ResponseError of type HTTPError.

If you don’t want to bother with validation at all, just specialize your Requestable with NoError, and every RequestResponse will be allowed to pass.

ComposableRequest

If you don’t want to create your own types that conform to Requestable, Ombi provides a reusable generic value type, ComposableRequest, that you dynamically configure at runtime. ComposableRequest has a closure-based syntax that allows your instance to capture unrelated values. The closures are not executed until the request is actually made. The first example in this document, written with a custom Requestable type, might look like this when expressed with a ComposableRequest

let cancellable = ComposableRequest<AnyJSON, String, HTTPError>()
    .path("/posts/update")
    .method(.post)
    .headers {
        // ... capture some things or do some work ...
        [
            .contentType: .contentType(.json),
            .acceptType: .contentType(.json)
        ]
    }
    .body {
        // ... capture some things or do some work ...
        [
            "title" : "My New Post",
            "body" : "lorem ipsum dolor sit amit"
        ]
    }
    .send(on: "https://api.myapp.com")
    .sink(receiveCompletion: { completion in
        switch completion {
        case .finished:
            break
        case .failure(let error):
            // ... handle error here ...
        }
    }, recieveValue: { response in
        // ... handle response here ...
    })