JSON API Client

Episode #203 | 32 minutes | published on January 7, 2016 | Uses swift-2.2, argo-2.2.0
Subscribers Only
In this episode I start creating a reusable api client that will make it a lot easier to consume a JSON api and convert the response into model objects. It uses Argo for the JSON parsing, and leverages Swift features to provide a rich callback for the API calls.

Episode Links

Initial structure


import Foundation
import Argo

class ApiClient {

    let configuration: NSURLSessionConfiguration

    lazy var session: NSURLSession = {
        return NSURLSession(configuration: self.configuration)
    }()

    var currentTasks: Set<NSURLSessionDataTask> = []

    init(configuration: NSURLSessionConfiguration) {
        self.configuration = configuration
    }
}

This allows us to initialize with whichever configuration we want, as well as keep track of all requests that are in flight, which will be useful when we want to cancel all requests later on.

Wrapping the default callback

Next we start with a method to wrap the standard NSURLSessionDataTask callback. This one will assume HTTP responses and convert the response to JSON (an Argo type).


    func jsonTaskWithRequest(request: NSURLRequest, completion: JsonTaskCompletionHandler) -> NSURLSessionDataTask {
        var task: NSURLSessionDataTask?
        task = session.dataTaskWithRequest(request, completionHandler: { (data, response, error) in
            self.currentTasks.remove(task!)
            let http = response as! NSHTTPURLResponse
            if let e = error {
                completion(nil, http, e)
            } else {
                if let data = data {
                    do {
                        let jsonObject = try NSJSONSerialization.JSONObjectWithData(data, options: [])
                        let json = JSON.parse(jsonObject)
                        completion(json, http, nil)
                    } catch {
                        completion(nil, http, NSError(domain: "com.nsscreencast.jsonerror", code: 10, userInfo: nil))
                    }
                } else {
                    completion(nil, http, NSError(domain: "com.nsscreencast.emptyresponse", code: 11, userInfo: nil))
                }
            }
        })
        currentTasks.insert(task!)
        return task!
    }
}

And the callback alias is defined as:

typealias JsonTaskCompletionHandler = (JSON?, NSHTTPURLResponse?, NSError?) -> Void

Defining a Response Enumeration

We need to be able to fetch the JSON and convert the response into one of many cases we could have. We'll use a Swift enum for this:

import Foundation
import Argo

public enum ApiClientResult<T> {
    case Success(T)
    case Error(NSError)
    case NotFound
    case ServerError(Int)
    case ClientError(Int)
    case UnexpectedResponse(JSON)
}

This will model all the various results we are interested in. We'll pass this back to callers (likely a view controller) and that will allow it to present the right error messages, etc that are appropriate for a given scenario.

Mapping HTTP responses to ApiClientResult

Next we'll need to map our responses off to this enum. We're going to ignore the JSON parsing of this for now, as the details of this will be wrapped in a block that will be passed to this method.

func fetch<T>(request: NSURLRequest, parseBlock: JSON -> T?, completion: ApiClientResult<T> -> Void) {
        let task = jsonTaskWithRequest(request) { (json, response, error) in
            dispatch_async(dispatch_get_main_queue()) {
                if let e = error {
                    completion(.Error(e))
                } else {
                    switch response!.statusCode {
                    case 200:
                        if let resource = parseBlock(json!) {
                            completion(.Success(resource))
                        } else {
                            print("WARNING: Couldn't parse the following JSON as a \(T.self)")
                            print(json!)
                            completion(.UnexpectedResponse(json!))
                        }

                    case 404: completion(.NotFound)
                    case 400...499: completion(.ClientError(response!.statusCode))
                    case 500...599: completion(.ServerError(response!.statusCode))
                    default:
                        print("Received HTTP \(response!.statusCode), which was not handled")
                    }
                }
            }
        }

        task.resume()
    }

Next we need to implement the fetchResource and fetchCollection methods which will define the actual models we're requesting, the request, and the parsing logic.

Parsing the response into models


    func fetchResource<T : Decodable where T.DecodedType == T>(request: NSURLRequest, rootKey: String? = nil, completion: ApiClientResult<T> -> Void) {
        fetch(request, parseBlock: { (json) -> T? in

            let j: JSON
            if let root = rootKey {
                let rootJSON: Decoded<JSON> = (json <| root) <|> pure(json)
                j = rootJSON.value ?? .Null
            } else {
                j = json
            }

            return T.decode(j).value

            }, completion: completion)
    }

    func fetchCollection<T: Decodable where T.DecodedType == T>(request: NSURLRequest, rootKey: String? = nil, completion: ApiClientResult<[T]> -> Void) {
        fetch(request, parseBlock: { (json) in

            let j: JSON
            if let root = rootKey {
                let rootJSON: Decoded<JSON> = (json <| root) <|> pure(json)
                j = rootJSON.value ?? .Null
            } else {
                j = json
            }

            switch j {
            case .Array(let array):
                return array.map { T.decode($0).value! }

            default:
                print("Response was not an array, cannot continue")
                return nil
            }

            }, completion: completion)
    }

There is some gnarly Argo code there to deal with optional root keys.

Note: we also have a force-unwrap on the fetchCollection, which we'll probably want to deal with in a different way. For now, this helps us catch parsing issues in development quickly, but likely wouldn't want to ship with this.

blog comments powered by Disqus