Erent
Erent

Reputation: 621

How to execute a synchronous api call after an asynchronous api call

I have two services that are working perfectly independently one is a synchronous call to get shopping-lists and another is an asynchronous call to add shopping-lists. The problem comes when i try to get a shopping-lists just after the add-Shopping-lists call has successfully completed.

The function to get shopping-lists never returns it just hangs after i call it in the closure of the add-Shopping-lists function. What is the best way to make these two calls without promises.

Create ShoppingList

    func createURLRequest(with endpoint: String, data: ShoppingList? = nil, httpMethod method: String) -> URLRequest {

        guard let accessToken = UserSessionInfo.accessToken else {
            fatalError("Nil access token")
        }

        let urlString = endpoint.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)

        guard let requestUrl = URLComponents(string: urlString!)?.url else {
            fatalError("Nil url")
        }

        var request = URLRequest(url:requestUrl)
        request.httpMethod = method
        request.httpBody = try! data?.jsonString()?.data(using: .utf8)
        request.addValue("application/json", forHTTPHeaderField: "Accept")
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")

        return request
    }

    func createShoppingList(with shoppingList: ShoppingList, completion: @escaping (Bool, Error?) -> Void) {

        let serviceURL = environment + Endpoint.createList.rawValue
        let request = createURLRequest(with: serviceURL, data: shoppingList, httpMethod: HttpBody.post.rawValue)
        let session = URLSession.shared

        let task = session.dataTask(with: request, completionHandler: { data, response, error -> Void in

            guard let _ = data,
                let response = response as? HTTPURLResponse,
                (200 ..< 300) ~= response.statusCode,
                error == nil else {
                    completion(false, error)
                    return
            }

            completion(true, nil)
        })

        task.resume()
    }

Get shoppingLists

    func fetchShoppingLists(with customerId: String) throws -> [ShoppingList]? {

        var serviceResponse: [ShoppingList]?
        var serviceError: Error?

        let serviceURL = environment + Endpoint.getLists.rawValue + customerId
        let request = createURLRequest(with: serviceURL, httpMethod: HttpBody.get.rawValue)
        let semaphore = DispatchSemaphore(value: 0)
        let session = URLSession.shared

        let task = session.dataTask(with: request, completionHandler: { data, response, error -> Void in

            defer { semaphore.signal() }

            guard let data = data,                            // is there data
                let response = response as? HTTPURLResponse,  // is there HTTP response
                (200 ..< 300) ~= response.statusCode,         // is statusCode 2XX
                error == nil else {                           // was there no error, otherwise ...
                     serviceError = error
                    return
            }

            do {
                let decoder = JSONDecoder()
                decoder.keyDecodingStrategy = .convertFromSnakeCase
                let shoppingList = try decoder.decode([ShoppingList].self, from: data)
                 serviceResponse = shoppingList
            } catch let error {
                 serviceError = error
            }

            })

        task.resume()

        semaphore.wait()

        if let error = serviceError {
            throw error
        }

        return serviceResponse

    }

Usage of function

    func addShoppingList(customerId: String, shoppingList: ShoppingList, completion: @escaping (Bool, Error?) -> Void) {

        shoppingListService.createShoppingList(with: shoppingList, completion: { (success, error) in
            if success {

                self.shoppingListCache.clearCache()

                let serviceResponse =  try? self.fetchShoppingLists(with: customerId)

                if let _ = serviceResponse {
                    completion(true, nil)
                } else {
                    let fetchListError =  NSError().error(description: "Unable to fetch shoppingLists")
                    completion(false, fetchListError)
                }

            } else {
                completion(false, error)
            }
        })

    }

I would like to call the fetchShoppingLists which is a synchronous call and get new data then call the completion block with success.

Upvotes: 0

Views: 254

Answers (2)

Rob
Rob

Reputation: 438437

This question is predicated on a flawed assumption, that you need this synchronous request.

You suggested that you needed this for testing. This is not true: One uses “expectations” to test asynchronous processes; we don’t suboptimize code for testing purposes.

You also suggested that you want to “stop all processes” until the request is done. Again, this is not true and offers horrible UX and subjects your app to possibly be killed by watchdog process if you do this at the wrong time while on slow network. If, in fact, the UI needs to be blocked while the request is in progress, we usually just throw up a UIActivityIndicatorView (a.k.a. a “spinner”), perhaps on top of a dimming/blurring view over the whole UI to prevent users from interacting with the visible controls, if any.

But, bottom line, I know that synchronous requests feel so intuitive and logical, but it’s invariably the wrong approach.

Anyway, I’d make fetchShoppingLists asynchronous:

func fetchShoppingLists(with customerId: String, completion: @escaping (Result<[ShoppingList], Error>) -> Void) {
    var serviceResponse: [ShoppingList]?

    let serviceURL = environment + Endpoint.getLists.rawValue + customerId
    let request = createURLRequest(with: serviceURL, httpMethod: .get)
    let session = URLSession.shared

    let task = session.dataTask(with: request) { data, response, error in
        guard let data = data,                            // is there data
            let response = response as? HTTPURLResponse,  // is there HTTP response
            200 ..< 300 ~= response.statusCode,         // is statusCode 2XX
            error == nil else {                           // was there no error, otherwise ...
                completion(.failure(error ?? ShoppingError.unknownError))
                return
        }

        do {
            let decoder = JSONDecoder()
            decoder.keyDecodingStrategy = .convertFromSnakeCase
            let shoppingList = try decoder.decode([ShoppingList].self, from: data)
            completion(.success(shoppingList))
        } catch let jsonError {
            completion(.failure(jsonError))
        }
    }

    task.resume()
}

And then you just adopt this asynchronous pattern. Note, while I’d use the Result pattern for my completion handler, I left yours as it was to minimize integration issues:

func addShoppingList(customerId: String, shoppingList: ShoppingList, completion: @escaping (Bool, Error?) -> Void) {
    shoppingListService.createShoppingList(with: shoppingList) { success, error in
        if success {
            self.shoppingListCache.clearCache()

            self.fetchShoppingLists(with: customerId) { result in
                switch result {
                case .failure(let error):
                    completion(false, error)

                case .success:
                    completion(true, nil)
                }
            }
        } else {
            completion(false, error)
        }
    }
}

Now, for example, you suggested you wanted to make fetchShoppingLists synchronous to facilitate testing. You can easily test asynchronous methods with “expectations”:

class MyAppTests: XCTestCase {

    func testFetch() {
        let exp = expectation(description: "Fetching ShoppingLists")

        let customerId = ...

        fetchShoppingLists(with: customerId) { result in
            if case .failure(_) = result {
                XCTFail("Fetch failed")
            }
            exp.fulfill()
        }

        waitForExpectations(timeout: 10)
    }
}

FWIW, it’s debatable that you should be unit testing the server request/response at all. Often instead mock the network service, or use URLProtocol to mock it behind the scenes.

For more information about asynchronous tests, see Asynchronous Tests and Expectations.


FYI, the above uses a refactored createURLRequest, that uses the enumeration for that last parameter, not a String. The whole idea of enumerations is to make it impossible to pass invalid parameters, so let’s do the rawValue conversion here, rather than in the calling point:

enum HttpMethod: String {
    case post = "POST"
    case get = "GET"
}

func createURLRequest(with endpoint: String, data: ShoppingList? = nil, httpMethod method: HttpMethod) -> URLRequest {
    guard let accessToken = UserSessionInfo.accessToken else {
        fatalError("Nil access token")
    }

    guard
        let urlString = endpoint.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
        let requestUrl = URLComponents(string: urlString)?.url 
    else {
        fatalError("Nil url")
    }

    var request = URLRequest(url: requestUrl)
    request.httpMethod = method.rawValue
    request.httpBody = try! data?.jsonString()?.data(using: .utf8)
    request.addValue("application/json", forHTTPHeaderField: "Accept")
    request.addValue("application/json", forHTTPHeaderField: "Content-Type")
    request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")

    return request
}

Upvotes: 1

David Thorn
David Thorn

Reputation: 129

I am sure it could be alot better, but this is my 5 minute version.


import Foundation
import UIKit

struct Todo: Codable {
    let userId: Int
    let id: Int
    let title: String
    let completed: Bool
}

enum TodoError: String, Error {
    case networkError
    case invalidUrl
    case noData
    case other
    case serializationError
}

class TodoRequest {

    let todoUrl = URL(string: "https://jsonplaceholder.typicode.com/todos")

    var todos: [Todo] = []

    var responseError: TodoError?

    func loadTodos() {

        var responseData: Data?

        guard let url = todoUrl else { return }
        let group = DispatchGroup()

        let task = URLSession.shared.dataTask(with: url) { [weak self](data, response, error) in
                responseData = data
                self?.responseError = error != nil ? .noData : nil
                group.leave()
        }

        group.enter()
        task.resume()
        group.wait()

        guard responseError == nil else { return }

        guard let data = responseData else { return }

        do {
            todos = try JSONDecoder().decode([Todo].self, from: data)
        } catch {
            responseError = .serializationError
        }

    }

    func retrieveTodo(with id: Int, completion: @escaping (_ todo: Todo? , _ error: TodoError?) -> Void) {
        guard var url = todoUrl else { return }

        url.appendPathComponent("\(id)")

        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
            guard let todoData = data else { return completion(nil, .noData) }
            do {
                let todo = try JSONDecoder().decode(Todo.self, from: todoData)
                completion(todo, nil)
            } catch {
                completion(nil, .serializationError)
            }
        }

        task.resume()
    }
}

class TodoViewController: UIViewController {

    let request = TodoRequest()

    override func viewDidLoad() {
        super.viewDidLoad()

        DispatchQueue.global(qos: .background).async { [weak self] in

            self?.request.loadTodos()

            self?.request.retrieveTodo(with: 1, completion: { [weak self](todoData, error) in
                guard let strongSelf = self else { return }

                if let todoError = error {
                    return debugPrint(todoError.localizedDescription)
                }

                guard let todo = todoData else {
                    return debugPrint("No todo")
                }

                debugPrint(strongSelf.request.todos)
                debugPrint(todo)

            })

        }
    }

}

Upvotes: 0

Related Questions