Skip to content

Brightify/Fetcher

Repository files navigation

Fetcher

CI Status Version License Platform Slack Status

Introduction

Fetcher is a small HTTP networking library for Swift. Its main goal is to simplify common tasks like sending REST requests. Networking is a very complex subject and our goal is not to cover everything that can be done. But we provide API that allows you to implement what you need or to customize behavior of Fetcher (this is handy if your server for some reason does not obey any standard).

Main features:

  • Easy to use API
  • Compile time safety (as much as possible)
  • Abstraction of data encoding by using DataMapper
  • Almost everything is customizable
  • Data mapping on background thread
  • Designed with DRY principle in mind
  • Support for RxSwift

Changelog

List of all changes and new features can be found here.

Requirements

  • Swift 3
  • iOS 8+

Installation

CocoaPods

Fetcher is available through CocoaPods. To install it, simply add the following line to your test target in your Podfile:

pod "Fetcher"

This will automatically include "Core" and "AlamofireRequestPerformer" subspecs.

If you want only Fetcher without RequestPerformers (you have your own implementation), use:

pod "Fetcher/Core"

Fetcher comes with support for RxSwift:

pod "Fetcher/RxFetcher"

Note: "RxFetcher" subspec does not depend on "AlamofireRequestPerformer". So if you want to use it, you need to add it extra by:

pod "Fetcher"

or:

pod "Fetcher/AlamofireRequestPerformer"

Usage

Below is a complete list of all features this library offers and how to use them. Some examples of usage can be found in tests. This documentation presumes that you are already familiar with DataMapper. Also you should know some basics about HTTP.

Quick overview

Below is shown a simple use case. It does not explain all of the concepts used. Refer to corresponding chapters for explanations.

Lets say we want to send GET request which will retrieve some data from our server. Our data looks like this:

struct ExampleObject {

    let id: String?
    let text: String?
}

To simulate our server we will use httpbin.org/get. It returns everything you send to it. For example look at the response for httpbin.org/get?id=1&text=a.

let fetcher = Fetcher(requestPerformer: AlamofireRequestPerformer())

    fetcher.request(GET<Void, SupportedType>("https://httpbin.org/get", modifiers: URLQueryItem(name: "id", value: "1"), URLQueryItem(name: "text", value: "a")), callback: { response in
    switch response.result {
    case .success(let value):
        let object = ExampleObject(id: value.dictionary?["args"]?.dictionary?["id"]?.string,
                                   text: value.dictionary?["args"]?.dictionary?["text"]?.string)

        print(object) // ExampleObject(id: Optional("1"), text: Optional("a"))
    case .failure(let error):
        // Handle error
        break
    }
})

This is probably the simplest code you can write (and simultaneously the ugliest). Also there is almost no difference between this and Alamofire code. However, we can improve it a bit:

let fetcher = Fetcher(requestPerformer: AlamofireRequestPerformer())

fetcher.request(Endpoints.get(id: "1", text: "a"), callback: { response in
    switch response.result {
    case .success(let value):
        // value was already transformed by DataMapper.
        print(value)
    case .failure(let error):
        // Handle error
        break
    }
})

struct Endpoints: EndpointProvider {

    static func get(id: String, text: String) -> GET<Void, ExampleObject> {
        return create("https://httpbin.org/get", modifiers: URLQueryItem(name: "id", value: "\(id)"), URLQueryItem(name: "text", value: "\(text)"))
    }
}

struct ExampleObject: Deserializable {

    let id: String?
    let text: String?

    init(_ data: DeserializableData) throws {
        id = data["args"]["id"].get()
        text = data["args"]["text"].get()
    }
}

Here DataMapper is used to remove mapping from the request and endpoint is moved to extra struct, so that it can be easily reused. There are still some problems that left that will become worse as we further extend our code. For example if we want to add new data objects, then data["args"] needs to be written everywhere because of the server API. Also if we add new endpoints, then the base URL will be on multiple places.

let fetcher = Fetcher(requestPerformer: AlamofireRequestPerformer())
fetcher.register(requestEnhancers: HttpBinResponseTranslation())
fetcher.register(requestModifiers: BaseUrl(baseUrl: "https://httpbin.org"))

fetcher.request(Endpoints.post(), input: ExampleObject(id: "1", text: "a")) {
    if let error = $0.result.error {
        // Handle error
    }
}

struct Endpoints: EndpointProvider {

    static func get(id: String, text: String) -> GET<Void, ExampleObject> {
        return create("get", modifiers: URLQueryItem(name: "id", value: "\(id)"), URLQueryItem(name: "text", value: "\(text)"))
    }

    static func post() -> POST<ExampleObject, Void> {
        return create("post")
    }

    static func put() -> PUT<ExampleObject, Void> {
        return create("put")
    }

    static func delete(id: String) -> DELETE<Void, Void> {
        return create("delete", modifiers: URLQueryItem(name: "id", value: "\(id)"))
    }
}

struct HttpBinResponseTranslation: RequestEnhancer {

    func deenhance(response: inout Response<SupportedType>) {
        response = response.map { $0.dictionary?["args"] ?? .null }
    }
}

struct ExampleObject: Mappable {

    var id: String?
    var text: String?

    init(id: String?, text: String?) {
        self.id = id
        self.text = text
    }

    init(_ data: DeserializableData) throws {
        try mapping(data)
    }

    mutating func mapping(_ data: inout MappableData) throws {
        data["id"].map(&id)
        data["text"].map(&text)
    }
}

Problem with data["args"] is solved using HttpBinResponseTranslation and thanks to BaseUrl there is no longer need to write the base URL everywhere (see RequestEnhancer if you want to know how all of this works). Another important feature of this example is the fetcher.request(Endpoints.post(), input: ExampleObject(id: "1", text: "a")) which demonstrates how some data can be send to the server.

Fetcher

Fetcher is responsible for processing requests and responses. It uses RequestPerformer (see RequestPerformer) to do the actual data transfer.

In init you can pass several more arguments:

  • objectMapperPolymorph: Polymorph - Polymorph used for mapping objects passed to Fetcher (see DataMapper)
  • errorHandler: ErrorHandler - see ErrorHandler
  • callQueue: DispatchQueue - Queue where almost all of Fetcher's logic is done (including object mapping). Default is the background queue.
  • callbackQueue: DispatchQueue - Queue on which the callback passed in request is called. Default is the main queue.

Fetcher can have additional configuration done by register methods:

func register(requestEnhancers: [RequestEnhancer])

func register(requestEnhancers: RequestEnhancer...)

func register(requestModifiers: [RequestModifier])

func register(requestModifiers: RequestModifier...)

These are used to add RequestEnhacer and RequestModifier which are used in every request (see RequestEnhancer).

By its nature Fetcher will usually be created only at one place in your code. But sometimes you may want to have more of them (for example if you want to two servers with different base URL). In this case it may be handy to copy settings from one instance of Fetcher to another. This can be done via:

init(copy fetcher: Fetcher)

Requests

request support all kinds of data. The main difference between them is whether there is input data and if DataMapper is used. Example of request overload:

func request<IN: Serializable, OUT: Deserializable>(_ endpoint: Endpoint<IN, OUT>, input: IN, callback: @escaping (Response<OUT>) -> Void) -> Cancellable

The first generic type of Endpoint (see Endpoint) says what kind of data will be send to the server and the second one is what will the server send back. Void stands for no data, NSData means that DataMapper won't be used and SupportedType lets you manually do the transformation. If Endpoint has Void as its input type, then the request does not have input parameter.

request supports everything that DataMapper does (plus exceptions mentioned above). Even though it is possible to create Endpoint with any input and output types, you won't be able to use them in request.

Cancellable returned from request can be used to cancel the request if it is no longer necessary.

let cancellable = fetcher.request(Endpoints.endpoint()) { response in
    // Do some stuff
}

cancellable.cancel()

Retry

extension Request {

    func retry(max: Int = Int.max, delay: DispatchTimeInterval = .seconds(0), failCallback: () -> Void = {})
}

Sometimes repeating a request may result in a completely different behavior. For example your request failed because there is no internet connection. In that case you may want to try the request later. Exactly for this scenario is there retry method of Request (see Request).

It does the same thing as calling the request again manually. max says how many times will be the request retried. If this count is reached, then instead of repeating the request, failCallback is called. Each call of retry retries the request only once. But because this is usually done in callback (which is called for every try), retry is called in cycle (and here comes in play max). delay modifies how long should Fetcher wait before it attempts again. Cancallable obtained from the original request works for retried request too. Example:

fetcher.request(...) { response in
    guard let value = response.result.value else {
        return response.request.retry(max: 3) {
            print("error")
        }
    }

    print(value)
})

Here retry is called only if result is failure. After retry is called this callback ends. Next time it is called with a different Response and this cycle repeats (in this case max three times). If after all these retries the result is still failure, then print("error") is called and no more attempts are made.

Request

struct Request {

    var modifiers: [RequestModifier] = []

    var URLRequest: URLRequest

    var callback: (Response<Data>) -> Void

    var cancellable: Cancellable

    var retried = 0

    var retryClosure: (Request, Int, DispatchTimeInterval, () -> Void) -> Void

    func retry(max: Int = Int.max, delay: DispatchTimeInterval = .seconds(0), failCallback: () -> Void = {})
}

Request is a wrapper over NSURLRequest. It provides delegates for all methods from NSURLRequest. You can access Request in RequestEnhancer (see RequestEnhancer) and in Response. You can modify Request in RequestEnhancer as you like. cancellable is the same Cancellable returned by Fetcher.request that created this Request. retried counts the number of times retry is called, retryClosure represents the implementation of retry.

Response

struct Response<T> {

    public let result: FetcherResult<T>
    public let rawResponse: HTTPURLResponse?
    public let rawData: Data?
    public let request: Request
}

Response represents result of Request (server's response to it). You get an instance of Response in every callback of fetcher.request. Declaration:

T is the same type as OUT in Endpoint. FetcherResult<T> is type alias to Result<T, FetcherError>.

There are also some extensions:

extension Response {

    func map<U>(_ transform: (T) -> U) -> Response<U>

    func flatMap<U>(_ transform: (T) -> FetcherResult<U>) -> Response<U>

    var rawString: String?
}

map and flatMap works the same way as in Result (and are applied only to result). rawString returns rawData decoded using the character coding specified in the response headers.

Endpoint

Endpoint is a class which defines URL (path) of request, which InputEncoding will be used, RequestModifiers that are specific to the request and HTTP method of the request. The implementation has these constructors.

init(_ path: String, modifiers: [RequestModifier])

init(_ path: String, inputEncoding: InputEncoding, modifiers: [RequestModifier])

init(_ path: String, modifiers: RequestModifier...)

init(_ path: String, inputEncoding: InputEncoding, modifiers: RequestModifier...)

Normally you won't use Endpoint directly or create subclasses of it. See Methods.

EndpointProvider

EndpointProvider is a protocol that supports pattern for creating reusable endpoints. It provides create, a static method that has the same signature as Endpoint, but creates a specific implementation based on the context. The pattern looks like this:

struct Endpoints: EndpointProvider {

    static func get(id: String, text: String) -> GET<Void, ExampleObject> {
        return create("get", modifiers: URLQueryItem(name: "id", value: "\(id)"), URLQueryItem(name: "text", value: "\(text)"))
    }

    static func post() -> POST<ExampleObject, Void> {
        return create("post")
    }
}

Notice that create could be replaced with GET and POST but we think it's better to specify the type in just one place. Another advantage of create is that you can add implicit RequestModfier (see RequestModifier) to every Endpoint. To do this, implement implicitModifiers:

struct Endpoints: EndpointProvider {

    static var implicitModifiers: [RequestModifier] = [StatusCodeResponseVerifier(code: 200)]

    static func post() -> POST<ExampleObject, Void> {
        return create("post")
    }
}

It is also possible to declare endpoints as properties:

struct Endpoints: EndpointProvider {

    static var post = POST<ExampleObject, Void>("post")
}

This can be only done if you don't pass any parameters directly to URL. In this example implicitModifiers wouldn't work. If needed, it can be fixed like so:

struct Endpoints: EndpointProvider {

    static var post: POST<ExampleObject, Void> = create("post")
}

Methods

These are predefined implementations of Endpoint which represent some HTTP method:

  • CONNECT
  • DELETE
  • GET
  • HEAD
  • OPTIONS
  • PATCH
  • POST
  • PUT
  • TRACE

RequestEnhancer

protocol RequestEnhancer {

    static var priority: RequestEnhancerPriority { get }

    var instancePriority: RequestEnhancerPriority? { get }

    func enhance(request: inout Request)

    func deenhance(response: inout Response<SupportedType>)
}

RequestEnhancer is a protocol which modifies behavior of Fetcher.

enhance is applied to each Request after the input data are encoded to it (see InputEncoding) and before it is performed by RequestPerformer (see RequestPerformer). You can modify the Request as you like. Default implementation does nothing.

deenhance does the same thing but for incoming Response. It is called after the output data is decoded (see InputEncoding) and before the callback of the request. Default implementation does nothing.

priority is used to decide in which order should be multiple instances of RequestEnhancer applied. Default value is .normal.

instancePriority solves problem that each instance of RequestEnhancer may have different priority (priority is static). Default value is nil, in which case priority is used. If instancePriority is not nil, then it is used instead of priority.

RequestEnhancer can be added to Fetcher by register methods (see Fetcher).

RequestEnhancerPriority

enum RequestEnhancerPriority {

    case low  
    case normal
    case high
    case fetcher
    case max
    case custom(value: Int)
}

RequestEnhancerPriority represents priority of RequestEnhancer. The order is from the lowest to the greatest priority. .normal is default priority. .fetcher is used for internal RequestEnhacer and shouldn't be used by other programs. .max should be used only if you need to precede internal RequestEnhancer. It is recommended to use .custom only when necessary.

extension RequestEnhancerPriority {

    var less: RequestEnhancerPriority

    var more: RequestEnhancerPriority
}

These modifiers allow you to say that one RequestEnhancer should run before another (it is a convenient way to specify the order without having to care about the order or unrelated enhancers). They are also the reason for priority being static. In this example RequestEnhancer2 will always run before RequestEnhancer1:

struct RequestEnhancer1: RequestEnhancer {

	func enhance(request: inout Request) {
		...
	}
}

struct RequestEnhancer2: RequestEnhancer {

	static let priority: RequestEnhancerPriority = RequestEnhancer1.priority.more

	func enhance(request: inout Request) {
		...
	}
}

RequestModifier

RequestEnhancer is used for all requests made by an instance of Fetcher. RequestModifier is a marker protocol that can be added to each request separately. RequestEnhancer can then look into Request.modifiers to see if there is an instance to act accordingly. For example behavior of RequestLogger is specified by RequestLogging which is RequestModifier with some additional data.

There are three ways to add RequestModifier to request:

  1. register method in Fetcher (see Fetcher). RequestModifier is applied to all requests.
  2. implicitModifiers in EndpointProvider (see EndpointProvider). RequestModifier is applied to all requests using Endpoint created by create method of the EndpointProvider.
  3. modifiers parameter in Endpoint init (see Endpoint). RequestModifier is applied only to the requests which use this specific instance of Endpoint.

Implementations

BaseUrl

BaseUrl is RequestModifier that tells BaseUrlRequestEnhancer what URL to insert before the URL specified in Endpoint. BaseUrlRequestEnhancer is registered by default. BaseUrl uses RequestEnhancerPriority to decide which one will be used if more then one is registered. baseUrl parameter of init specifies what URL to insert. If it is nil then nothing happens. There is a special instance BaseUrl.Ignore which suppresses other BaseUrl.

Example can be found in the last example in Quick overview.

RequestLogger

RequestLogger is a good tool to debug requests. By default it logs some information about the request (and the response) to the console, although it can be overridden using logFunction in init. What to log is specified using RequestLogging (RequestModifier). RequestLogging is an OptionSet so multiple options can be selected by putting them into array. If there is no instance of RequestLogging then defaultOptions from init are used. Options for RequestLogging:

  • requestUrl
  • time
  • responseCode
  • requestHeaders
  • requestBody
  • responseHeaders
  • responseBody
  • all - everything from above
  • disabled - nothing

defaultOptions consists from requestUrl, responseCode, time.

ResponseVerifier

protocol ResponseVerifier: RequestModifier {

    func verify(response: Response<SupportedType>) -> FetcherError?
}

verify is called by ResponseVerifierEnhancer (registered by default). ResponseVerifierEnhancer takes all instances of ResponseVerifier and calls verify method. The first non-nil result, is used to modify the response by Response.flatMap. It only performs an action if Response.result is success.

StatusCodeResponseVerifier is a preimplemented ResponseVerifier that compares status code of response with codes from init. If match is not found then it returns .invalidStatusCode, nil otherwise.

InputEncoding

InputEncoding is a marker protocol which says how the data should be encoded to Request and decoded from Response. Each Endpoint has default encoding based on the method it uses (GET encodes data to the URL, POST to the request body atc.) but this can be changed by passing different encoding in its init (see Endpoint).

InputEncodingWithEncoder

protocol InputEncodingWithEncoder: InputEncoding {

    func encode(input: SupportedType, to request: inout Request)
}

InputEncodingWithEncoder is an extension of InputEncoding. Normally DataEncoder takes care of encoding the input data based on InputEncoding (see DataEncoder). With this protocol the encode method is called to do the job instead.

StandardInputEncoding

enum StandardInputEncoding: InputEncoding {

    case queryString
    case httpBody
}

StandardInputEncoding represents two encodings that all DataEncoder understand.

.queryString encodes the input data into the request URL (http://url.xxx?param1=value). As shown in examples in Quick overview this may also be done manually. .queryString's disadvantage is that the input data must be a dictionary.

.httpBody simply sends the data in the request body. Encoding depends on DataEncoder implementation (may be JSON, XML, etc.).

RequestPerformer

protocol RequestPerformer {

    var dataEncoder: DataEncoder { get }

    func perform(request: Request, callback: @escaping (Response<Data>) -> Void) -> Cancellable
}

RequestPerformer's duty is to communicate with the server (perform requests). This is done in the perform method that takes Request and calls callback with Response (this call may not happen immediately). It returns Cancellable (way to cancel the request).

RequestPerformer also specifies DataEncoder, which will be used to encode and decode the data.

DataEncoder

protocol DataEncoder {

    func encodeToQueryString(input: SupportedType, to request: inout Request)

    func encodeToHttpBody(input: SupportedType, to request: inout Request)

    func encodeCustom(input: SupportedType, to request: inout Request, inputEncoding: InputEncoding)

    func decode(response: Response<Data>) -> Response<SupportedType>
}

DataEncoder is called by Fetcher to encode the input data into Request and to decode data from Response.

StandardInputEncoding specifies which of encodeToQueryString or encodeToHttpBody is used.

encodeCustom is called if a custom implementation of InputEncoding is used and it is not InputEncodingWithEncoder. By default calling this method will result in a crash (InputEncoding is unknown).

Implementations

AlamofireRequestPerformer

AlamofireRequestPerformer is an implementation on top of Alamofire. By default it uses AlamofireJsonDataEncoder (encoder for JSON) as DataEncoder but this can be changed in its init.

AlamofireJsonDataEncoder knows a special type of InputEncoding and that is FormInputEncoding which supports content type application/x-www-form-urlencoded.

Header

protocol Header: RequestModifier {

    var name: String { get }
    var value: String { get }
}

Header is a special type of RequestModifier, which represents headers of the request. Registered instances of Header are added to Request by internal RequestEnhancer.

Predefined headers

Headers gathers all predefined headers. They are represented as nested structs added by extensions. For example this is the implementation of Accept:

extension Headers {

    struct Accept: Header {

        let name = "Accept"

        let value: String

        init(value: String) {
            self.value = value
        }
    }
}

extension Headers.Accept {

    static let applicationJson = Headers.Accept(value: "application/json")
    static let textPlain = Headers.Accept(value: "text/plain")
}

Another headers can be added the same way (as well as specific values of the headers).

Currently there are these headers: Accept, ContentType, Charset.

Custom is a special type of header. It is meant to by used if some header is needed in only one place, so it is not worth creating a new struct.

FetcherError

enum FetcherError: Error {
    case requestError(Error)
    case invalidStatusCode
    case nilValue
    case custom(Error)
    case unknown
}

FetcherError is used as the type of error in Response.result.

ErrorHandler

protocol ErrorHandler {

    func canResolveError(response: Response<SupportedType>) -> Bool

    func resolveError(response: Response<SupportedType>, callback: (Response<SupportedType>) -> Void)
}

ErrorHandler tells Fetcher what to do if Response.result is a failure. It can be set in its init with parameter named errorHandler. The default one is a NoErrorHandler.

When resolving error, Fetcher first calls canResolveError. If it returns false, there is no change in behavior and callback is called with unchanged Response. If true, resolveError is called instead of the callback. However the callback is passed as a closure, which you can call with the modified Response.

BaseStatusCodeErrorHandler

BaseStatusCodeErrorHandler is an "abstract" class for implementations that resolves errors based on the status code of the response. It has similar init to StatusCodeResponseVerifier. canResolveError returns true if the status code is one of the codes from init.

CompositeErrorHandler

struct CompositeErrorHandler: ErrorHandler {

    init(handlers: [ErrorHandler])

    init(handlers: ErrorHandler...)
}

CompositeErrorHandler allows composition of multiple ErrorHandlers. Only the first ErrorHandler that can resolve the error is used to actually resolve the error.

Implementations

NoErrorHandler

NoErrorHandler does nothing (canResolveError always returns false).

NoInternetErrorHandler

final class NoInternetErrorHandler: BaseStatusCodeErrorHandler {

    init(maxRepetitions: Int = 3, delay: DispatchTimeInterval = .seconds(1))
}

NoInternetErrorHandler handles the status code 599 by calling retry (see retry). Parameters for retry can be set in init.

RequestTimeOutErrorHandler

final class RequestTimeOutErrorHandler: BaseStatusCodeErrorHandler {

    init(maxRepetitions: Int = 3, delay: DispatchTimeInterval = .seconds(0))
}

RequestTimeOutErrorHandler handles the status code 408 by calling retry (see retry). Parameters for retry can be set in init.

RxFetcher

Instead of using the callbacks, you can make your requests in a more "reactive" way by using Observable. To do that, you need an instance of RxFetcher, which can be obtained by rx property of Fetcher. RxFetcher has the same request methods, but they return Observable. Modified third example from Quick overview:

let fetcher = Fetcher(requestPerformer: AlamofireRequestPerformer())
fetcher.register(requestEnhancers: HttpBinResponseTranslation())
fetcher.register(requestModifiers: BaseUrl(baseUrl: "https://httpbin.org"))

fetcher.rx.request(Endpoints.get(id: "1", text: "a").subscribe(onNext: { response in
    switch response.result {
    case .success(let value):
        // value was already transformed with DataMapper.
        print(value)
    case .failure(let error):
        // Handle error
        break
    }
}).addDisposableTo(...)

Observable extensions

extension ObservableConvertibleType where E: ResponseProtocol {

    func retryRequest(max: Int = Int.max, delay: DispatchTimeInterval = .seconds(0)) -> Observable<E>

    func asResult() -> Observable<FetcherResult<E.T>>
}

retryRequest is a reactive variant to retry of Request (see retry).

asResult returns a mapped sequence with Response.result.

Thread safety

Fetcher by default does many things on the background thread, so it needs to be thread safe. This also applies to objects like RequestPerformer, RequestEnhancer, ErrorHandler and so on. Usually all methods from these protocols are implemented as pure functions, so they don't cause any problems. Also see DataMapper#ThreadSafety.

Versioning

This library uses semantic versioning. Until the version 1.0 API breaking changes may occur even in minor versions. We consider the version 0.1 to be prerelease, which means that API should be stable but is not tested yet in a real project. After the testing we'll make the necessary adjustments and bump the version to 1.0 (first release).

Author

Used libraries

AlamofireRequestPerformer

RxFetcher

Tests

License

Fetcher is available under the MIT License.

About

Swift HTTP networking library.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 3

  •  
  •  
  •  

Languages