Swiftpack.co - Package - tailoredmedia/Endpoints

Build Status codecov CocoaPods Compatible Carthage compatible Swift Package Manager compatible Platform

Endpoints

Endpoints makes it easy to write a type-safe network abstraction layer for any Web-API.

It requires Swift 5, makes heavy use of generics (and generalized existentials) and protocols (and protocol extensions). It also encourages a clean separation of concerns and the use of value types (i.e. structs).

Usage

Basics

Here's how to load a random image from Giphy.

// A client is responsible for encoding and parsing all calls for a given Web-API.
let client = AnyClient(baseURL: URL(string: "https://api.giphy.com/v1/")!)

// A call encapsulates the request that is sent to the server and the type that is expected in the response.
let call = AnyCall<Data>(Request(.get, "gifs/random", query: [ "tag": "cat", "api_key": "dc6zaTOxFJmzC" ]))

// A session wraps `URLSession` and allows you to start the request for the call and get the parsed response object (or an error) in a completion block.
let session = Session(with: client)

// enable debug-mode to log network traffic
session.debug = true

// start call
session.start(call: call) { result in
    result.onSuccess { value in
        //value is an object of the type specified in `Call`
    }.onError { error in
        //something went wrong
    }
}

Response Parsing

A call is supposed to know exactly what response to expect from its request. It delegates the parsing of the response to a ResponseParser.

Some built-in types already adopt the ResponseParser protocol (using protocol extensions), so you can for example turn any response into a JSON array or dictionary:

// Replace `Data` with any `ResponseParser` implementation
let call = AnyCall<[String: Any]>(Request(.get, "gifs/random", query: [ "tag": "cat", "api_key": "dc6zaTOxFJmzC" ]))
...
session.start(call: call) { result in
    result.onSuccess { value in
        //value is now a JSON dictionary 🎉
    }
}

Codable Integration

Endpoints has a built in Codable support.

Decoding

The parser responsible for handling decodable types is the DecodableParser. As the different parsers do not have a common interface, it is just a layer that removes the encoding as a function requirement, as no Decoder needs it. JSON responses can be used by adopting the JSONDecodableParser protocol. A JSONDecodableParser needs to provide a jsonDecoder which is used to turn the data into an object.
For convenience, it is recommended to define a default decoder via a protocol extension and customize it by providing a different one in a specific JSONDecodableParser.
If a type acts as output as well as parser, then simply adopting JSONSelfDecodable is enough.

// default decoder
extension JSONDecodableParser {
    public static var jsonDecoder: JSONDecoder {
        return JSONDecoder()
    }
}

struct Person: JSONSelfDecodable {
    let name: String
    let birthday: Date
}

// specific decoder
extension Person {
    public static var jsonDecoder: JSONDecoder {
        // return a customized decoder
    }
}

All arrays, whose elements conform to Decodable are JSONDecodableParsers. Although due to static / dynamic dispatch limitations, it is not possible to overwrite the jsonDecoder in specific arrays. If you need to use a different decoder, then a dedicated JSONDecodableParser is the way to go.

public struct CustomArrayParser<Element>: ResponseParser {

    public typealias OutputType = [Element]

    public static func parse(data: Data, encoding: String.Encoding) throws -> OutputType {
        // ...
    }
}

public struct CustomCall: Call {
    public typealias ResponseType = CustomArrayParser<String>

    public var request: URLRequestEncodable {
        // ...
    }
}
Encoding

Every encodable is able to provide a JSONEncoder() to encode itself via the toJSON() method.

Dedicated Calls

AnyCall is the default implementation of the Call protocol, which you can use as-is. But if you want to make your networking layer really type-safe you'll want to create a dedicated Call type for each operation of your Web-API:

struct GetRandomImage: Call {
    typealias ResponseType = [String: Any]
    
    var tag: String
    
    var request: URLRequestEncodable {
        return Request(.get, "gifs/random", query: [ "tag": tag, "api_key": "dc6zaTOxFJmzC" ])
    }
}

// `GetRandomImage` is much safer and easier to use than `AnyCall`
let call = GetRandomImage(tag: "cat")

Dedicated Clients

A client is responsible for handling things that are common for all operations of a given Web-API. Typically this includes appending API tokens or authentication tokens to a request or validating responses and handling errors.

AnyClient is the default implementation of the Client protocol and can be used as-is or as a starting point for your own dedicated client.

You'll usually need to create your own dedicated client that either subclasses AnyClient or delegates the encoding of requests and parsing of responses to an AnyClient instance, as done here:

class GiphyClient: Client {
    private let anyClient = AnyClient(baseURL: URL(string: "https://api.giphy.com/v1/")!)
    
    var apiKey = "dc6zaTOxFJmzC"
    
    func encode<C: Call>(call: C) -> URLRequest {
        var request = anyClient.encode(call: call)
        
        // Append the API key to every request
        request.append(query: ["api_key": apiKey]) 
        
        return request
    }
    
    public func parse<C : Call>(sessionTaskResult result: URLSessionTaskResult, for call: C) throws -> C.ResponseType.OutputType {
        do {
            // Use `AnyClient` to parse the response
            // If this fails, try to read error details from response body
            return try anyClient.parse(sessionTaskResult: result, for: call)
        } catch {
            // See if the backend sent detailed error information
            guard
                let response = result.httpResponse,
                let data = result.data,
                let errorDict = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any],
                let meta = errorDict?["meta"] as? [String: Any],
                let errorCode = meta["error_code"] as? String else {
                // no error info from backend -> rethrow default error
                throw error
            }
            
            // Propagate error that contains errorCode as reason from backend
            throw StatusCodeError.unacceptable(code: response.statusCode, reason: errorCode)
        }
    }
}

Dedicated Response Types

You usually want your networking layer to provide a dedicated response type for every supported call. In our example this could look like this:

struct RandomImage {
    var url: URL
    ...
}

struct GetRandomImage: Call {
    typealias ResponseType = RandomImage
    ...
}

In order for this to work, RandomImage must adopt the ResponseParser protocol:

extension RandomImage: ResponseParser {
    static func parse(data: Data, encoding: String.Encoding) throws -> RandomImage {
        let dict = try [String: Any].parse(data: data, encoding: encoding)
        
        guard let data = dict["data"] as? [String : Any], let url = data["image_url"] as? String else {
            throw throw ParsingError.invalidData(description: "invalid response. url not found")
        }
        
        return RandomImage(url: URL(string: url)!)
    }
}

This can of course be made a lot easier by using the Codable API, for which there is a DecodableParser and a JSONResponse type. A JSONResponse is able to supply a JSONDecoder to decode data into itself via the static decoder property.

We can therefore write:

struct RandomImage: DecodableParser, JSONResponse {
    struct Data: Decodable {
        let url: URL
        
        private enum CodingKeys: String, CodingKey {
            case url = "image_url"
        }
    }
    
    let data: Data
}

Type-Safety

With all the parts in place, users of your networking layer can now perform type-safe requests and get a type-safe response with a few lines of code:

let client = GiphyClient()
let call = GetRandomImage(tag: "cat")
let session = Session(with: client)

session.start(call: call) { result in
    result.onSuccess { value in
        print("image url: \(value.data.url)")
    }.onError { error in
        print("error: \(error)")
    }
}

Convenience

There are multiple ways to make performing a call more convenient. You could write a dedicated GiphyCall that creates the correct Client and Session for your users:

protocol GiphyCall: Call {}

extension GiphyCall {
    func start(completion: @escaping (Result<ResponseType.OutputType>)->()) {
        let client = GiphyClient()
        let session = Session(with: client)
        
        session.start(call: self, completion: completion)
    }
}

When GiphyCall is adopted by GetRandomImage instead of Call, performing a request is much simpler:

GetRandomImage(tag: "cat").start { result in ... }

To make it easer to find supported calls, you could namespace your calls using an extension of your Client:

extension GiphyClient {
    struct GetRandomImage: GiphyCall { ... }
}

Xcode can now help developers find the right Call instance:

GiphyClient.GetRandomImage(tag: "cat").start { result in ... }

Installation

CocoaPods:

pod "Endpoints"

Carthage:

github "tailoredmedia/Endpoints.git"

Swift Package Manager:

.Package(url: "https://github.com/tailoredmedia/Endpoints.git", majorVersion: 0)

Example

To compile examples you need to fetch some git submodules using git submodule update --init --recursive.

Requirements

  • Swift 5
  • iOS 8
  • tvOS 9
  • macOS 10.11
  • watchOS 2.0

Github

link
Stars: 29
Help us keep the lights on

Dependencies

Used By

Total: 1