3

I'm implementing an API Client that will call my backend API, and return the appropriate object, or an error.

This is what I have so far:

public typealias JSON = [String: Any]
public typealias HTTPHeaders = [String: String]

public enum RequestMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case delete = "DELETE"
}

public class APIClient {
    public func sendRequest(_ url: String,
                             method: RequestMethod,
                             headers: HTTPHeaders? = nil,
                             body: JSON? = nil,
                             completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) {
        let url = URL(string: url)!
        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = method.rawValue

        if let headers = headers {
            urlRequest.allHTTPHeaderFields = headers
            urlRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
        }

        if let body = body {
            urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: body)
        }

        let session = URLSession(configuration: .default)
        let task = session.dataTask(with: urlRequest) { data, response, error in
            completionHandler(data, response, error)
        }

        task.resume()
    }
}

Ok, so what I want to be able to do is something like this:

apiClient.sendRequest("http://example.com/users/1", ".get") { response in
    switch response {
    case .success(let user):
         print("\(user.firstName)")
    case .failure(let error):
         print(error)
    }
}

apiClient.sendRequest("http://example.com/courses", ".get") { response in
    switch response {
    case .success(let courses):
        for course in courses {
            print("\(course.description")
        }
    case .failure(let error):
         print(error)
    }
}

So, the apiClient.sendRequest() method has to decode the response json into the desired swift object, and return either that object or an error object.

I have these structs:

struct User: Codable {
    var id: Int
    var firstName: String
    var lastName: String
    var email: String
    var picture: String
}

struct Course: Codable {
    var id: Int
    var name: String
    var description: String
    var price: Double
}

I have this Result enum defined as well:

public enum Result<Value> {
    case success(Value)
    case failure(Error)
}

Where I am stuck is, I am not sure how to tweak my completionHandler in sendRequest() so that I can use it with a User object or a Course object or any other custom object. I know I have to use generics somehow to make this happen, and I've used generics in C#, but I'm not quite comfortable yet in Swift 4, so any help is appreciated.

EDIT: Also, I'd like to know how sendRequest()'s response can be bubbled back up one level to the calling code in the ViewController, so that the ViewController has access to the success and failure results (in an async fashion).

Prabhu
  • 12,995
  • 33
  • 127
  • 210

1 Answers1

7

Here's a method that you can use, that forwards the actual HTTP work to the existing method, and handles only the json decoding:

public func sendRequest<T: Decodable>(for: T.Type = T.self,
                                      url: String,
                                      method: RequestMethod,
                                      headers: HTTPHeaders? = nil,
                                      body: JSON? = nil,
                                      completion: @escaping (Result<T>) -> Void) {

    return sendRequest(url, method: method, headers: headers, body:body) { data, response, error in
        guard let data = data else {
            return completion(.failure(error ?? NSError(domain: "SomeDomain", code: -1, userInfo: nil)))
        }
        do {
            let decoder = JSONDecoder()
            try completion(.success(decoder.decode(T.self, from: data)))
        } catch let decodingError {
            completion(.failure(decodingError))
        }
    }
}

, which can be called like this:

apiClient.sendRequest(for: User.self,
                      url: "https://someserver.com",
                      method: .get,
                      completion: { userResult in
                        print("Result: ", userResult)
})

, or like this:

apiClient.sendRequest(url: "https://someserver.com",
                      method: .get,
                      completion: { (userResult: Result<User>) -> Void in
                        print("Result: ", userResult)
})

, by specifying the completion signature and omitting the first argument. Either way, we let the compiler infer the type for the other stuff, if we provide it enough information to do so.

Splitting the responsibilities between multiple methods makes them more reusable, easier to maintain and understand.

Assuming you wrap the api client into another class that exposes some more general methods, that hide the api client complexity, and allow to be called from controllers by passing only the relevant information, you could end up with some methods like this:

func getUserDetails(userId: Int, completion: @escaping (Result<User>) -> Void) {
    apiClient.sendRequest(for: User.self,
                          url: "http://example.com/users/1",
                          method: .get,
                          completion: completion)
}

, which can be simply called from the controller like this:

getUserDetails(userId: 1) { result in
    switch result {
    case let .success(user):
        // update the UI with the user details
    case let .failure(error):
        // inform about the error
    }
}

Update Support for decoding arrays can also be easily added by adding another overload over sendRequest(), below is a small refactored version of the code from the beginning of the answer:

private func sendRequest<T>(url: String,
                            method: RequestMethod,
                            headers: HTTPHeaders? = nil,
                            body: JSON? = nil,
                            completion: @escaping (Result<T>) -> Void,
                            decodingWith decode: @escaping (JSONDecoder, Data) throws -> T) {
    return sendRequest(url, method: method, headers: headers, body:body) { data, response, error in
        guard let data = data else {
            return completion(.failure(error ?? NSError(domain: "SomeDomain", code: -1, userInfo: nil)))
        }
        do {
            let decoder = JSONDecoder()
            // asking the custom decoding block to do the work
            try completion(.success(decode(decoder, data)))
        } catch let decodingError {
            completion(.failure(decodingError))
        }
    }
}

public func sendRequest<T: Decodable>(for: T.Type = T.self,
                                      url: String,
                                      method: RequestMethod,
                                      headers: HTTPHeaders? = nil,
                                      body: JSON? = nil,
                                      completion: @escaping (Result<T>) -> Void) {

    return sendRequest(url: url,
                       method: method,
                       headers: headers,
                       body:body,
                       completion: completion) { decoder, data in try decoder.decode(T.self, from: data) }
}

public func sendRequest<T: Decodable>(for: [T].Type = [T].self,
                                      url: String,
                                      method: RequestMethod,
                                      headers: HTTPHeaders? = nil,
                                      body: JSON? = nil,
                                      completion: @escaping (Result<[T]>) -> Void) {

    return sendRequest(url: url,
                       method: method,
                       headers: headers,
                       body:body,
                       completion: completion) { decoder, data in try decoder.decode([T].self, from: data) }
}

Now you can also do something like this:

func getAllCourses(completion: @escaping (Result<[Course]>) -> Void) {
    return apiClient.sendRequest(for: User.self,
                                 url: "http://example.com/courses",
                                 method: .get,
                                 completion: completion)
}

// called from controller
getAllCourses { result in
    switch result {
    case let .success(courses):
        // update the UI with the received courses
    case let .failure(error):
        // inform about the error
    }
}
Cristik
  • 30,989
  • 25
  • 91
  • 127
  • Ok, and you'd call this like this right: sendDecodableRequest(...) ? And how would this work for an array of courses? – Prabhu Feb 07 '18 at 06:45
  • Cool, this works. One more question. If I wanted to return the result from apiClient.sendRequest one more level up to my ViewController, so that the ViewController can check for success or failure (in an async fashion), do I just return userResult? Or I could just return the whole thing right: return apiClient.sendRequest(...) – Prabhu Feb 07 '18 at 07:37
  • I think I get what you're saying, but if you are able to jot down a quick example, that would greatly help! I'm almost there! – Prabhu Feb 07 '18 at 08:02
  • Edited, appreciate your help! – Prabhu Feb 07 '18 at 17:23
  • Awesome thank you! Trying to understand this line: try completion(.success(decode(decoder))). Looking for the custom method decode() but it doesn't seem to be there? – Prabhu Feb 08 '18 at 07:24
  • In line -> try decoder.decode(T.self, from: data) getting error "Use of unresolved identifier 'data'; did you mean 'Data'?" – Syed Sadrul Ullah Sahad Jan 03 '20 at 11:16
  • @SyedSadrulUllahSahad there were some missing stuff, updated the answer, should compile now – Cristik Jan 03 '20 at 14:33
  • @Cristik Hello, I have tried your code & it's working fine. Thanks for great answer. I want to use alamofire request instead of `URLRequest`. I am new to ios development. I have asked question https://stackoverflow.com/questions/60334798/ios-create-generic-alamofire-request-using-swift can you please help me ? – Ajay Feb 22 '20 at 06:02