(Swift) Save the Data to our Custom Model

Introduction

In the previous article (Swift) Create And Execute A Network Request , we can successfully fetch the data and decode it from the API service, but how could we use those data and present it on our application? Today we will explore how to use it in the SwiftUI.


Recap

In last article, we use below code to fetch, decode and print out the result of data.

let url = URL(string: "https://goquotes-api.herokuapp.com/api/v1/random?count=1")!

let task = URLSession.shared.dataTask(with: url) { (data, response, error) in

    if let response = response {

        if let data = data {
            do {
                print("End API Fetch")
                let apiResponse = try JSONDecoder().decode(ApiResponse.self, from: data)

                print(apiResponse)
            } catch {
                print("JSON Decoding Error: \(error.localizedDescription)")
            }
        }
    } else {
        print(error ?? "Unknown error")
    }
}
task.resume()

So could we put this block of the code into the function and return an ApiResponse object? Let take a look at the result when we put it into the function.

Screen Shot 2021-05-09 at 5.09.01 PM.png

The result said: error: unexpected non-void return value in a void function

Why does it happen?

Because the network request is an asynchronous method which means it runs on a background queue. It is not like the synchronous method, will run each line of the code then wait for the previous line of code to complete.

In addition, the URLSession instance executes the code in the completion handler, which happens after the network request is complete.

In this situation we can not return the object before the code in the completion handler is executed.

So how could we deal with this problem?

Write A Completion Handler

func fetchQuote(completion: @escaping (ApiResponse) -> Void) {
    let url = URL(string: "https://goquotes-api.herokuapp.com/api/v1/random?count=1")!

    let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
        if let data = data {
            do {
                print("End API Fetch")
                let apiResponse = try JSONDecoder().decode(ApiResponse.self, from: data)
                completion(apiResponse)

            } catch {
                print("JSON Decoding Error: \(error.localizedDescription)")
            }
        }

    }
    task.resume()
}

fetchQuote { (apiResponse) in
    print(apiResponse)
}

Screen Shot 2021-05-09 at 5.42.01 PM.png

At here we add a completion handler to the shared URLSession instance. When the network request is completed. the URLSession instance executes that block of the code.

The completion parameter is a closure, which can be passed into the function. The @escaping keyword let the compiler know that the code in the closure will be executed after the function has finished executing all the code. So in this way, after we add a completion closure, we can give the closure to access our apiResponse instance.

For more information about the closure example or tutorial, I highly recommend watching Stewart Lynch's Youtube channel - Introductions to Closures in Swift (https://www.youtube.com/watch?v=4-EvBzIT5Y0)

Handling the Error and Organize the Code

In our completion handler for the data task that we try to unwrap the optional data. If all the values are successfully unwrapped, the completion handler will be called. But we can not promise that the data that we receive has all the fields we need. Or what if the JSON can't be decoded?

In this scenario, we will use the Result type and the Generic to solve these possible issues.

Firstly, let us take a look at the Result type. The 'Result' is an enum with two cases:

  1. . success(Success)
  2. . failure(Failure)

Then modify our code into:

func fetchQuote(completion: @escaping (Result<ApiResponse,Error>) -> Void) {
    let url = URL(string: "https://goquotes-api.herokuapp.com/api/v1/random?count=1")!

    let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
        if let data = data {
            do {
                let apiResponse = try JSONDecoder().decode(ApiResponse.self, from: data)
                completion(.success(apiResponse))
            } catch {
                completion(.failure(error))
            }
        }

    }
    task.resume()
}

fetchQuote { (result) in
    switch result {
    case .success(let apiResponse):
        print("Successfully fetched quote: \(apiResponse)")
    case .failure(let error):
        print("Fetch Quote Error with Error: \(error)")
    }
}

Now our code can use the do and catch to catch any errors send by JSONDecoder().decode method. Meanwhile, if it successfully decodes the data apiResponse pass through the completion.

We can also use Generics to let our code be more flexible.

The code we have written it only can deal with ApiResponse type, but what if we have multiple different custom type need to decode in our app.

Take a look at the below code. It can help us to approach the same result but with a more flexible code structure and usage.

public class APIService {
    public static let shared = APIService()

    public enum APIError: Error {
        case error(_ errorString: String)
    }

    public func getJSON<T: Decodable>(urlString: String,
                                      completion: @escaping (Result<T,APIError>) -> Void) {
        guard let url = URL(string: urlString) else {
            completion(.failure(.error(NSLocalizedString("Error: Invalid URL", comment: ""))))
            return
        }
        let request = URLRequest(url: url)
        URLSession.shared.dataTask(with: request) { (data, response, error) in
            if let error = error {
                completion(.failure(.error("Error: \(error.localizedDescription)")))
                return
            }

            guard let data = data else {
                completion(.failure(.error(NSLocalizedString("Error: Data are corrupt.", comment: ""))))
                return
            }
            let decoder = JSONDecoder()

            do {
                let decodedData = try decoder.decode(T.self, from: data)
                completion(.success(decodedData))
                return
            } catch let decodingError {
                completion(.failure(APIError.error("Error: \(decodingError.localizedDescription)")))
                return
            }

        }.resume()
    }
}

let apiService = APIService()

apiService.getJSON(urlString: "https://goquotes-api.herokuapp.com/api/v1/random?count=1") { (result: Result<ApiResponse,APIService.APIError>) in
    switch result {
    case .success(let quote):
            print(quote)
    case .failure(let apiError):
        switch apiError {
        case .error(let errorString):
            print(errorString)
        }
    }
}

Within our application, we don't want to create multiple APIService because it is expensive. So we can create a singleton and use it within the whole application. That is the reason why we create the other APIService class to contain the fetching data functionality.

public func getJSON<T: Decodable>(urlString: String,
                   completion: @escaping (Result<T,APIError>) -> Void) {
.....
}

For our completion, we replace the original 'ApiResponse' into T (Generics). It let us have the ability to use it with the custom type we want to put into the closure.

In this example, we need to change the:

  1. URL
  2. Result type that we want to decode

Then we can get the exact same result of the output.