Without going into too much detail here, I wanted to highlight an approach I’ve taken to managing parsing of data that comes back from a webservice. It leverages best practices, such as using Codable to parse JSON, but also allows for some flexibility to adapt to your backend implementation.
As they say “Adapt Early” when it comes to dealing with data that comes from outside your app, or even from a 3rd party framework.
As such, I came up with an approach that keeps a lot of business logic associated with an endpoint and less with the API client, and uses Swift generics, and associatedtype on protocols.
I have a hard time explaining this, so you can just copy-paste this into a playground and have a look for yourself:
/// What your request types need to support to work with the APIClient
public protocol APIRequest {
var baseURL: URL { get }
var path: String { get }
// other properties here, such as parameters: [String: Any]
func request(authToken: String?) -> URLRequest
}
// then provide a default implementation
extension APIRequest {
func request(authToken: String?) -> URLRequest {
let url = baseURL.appendingPathComponent(path)
return URLRequest(url: url)
}
}
/// we can group such enums by service. Consider LoginEndpoint, UserManagementEndpoint, etc.
enum Endpoint {
case home
}
/// ... then conform separately if you need to
extension Endpoint: APIRequest {
var baseURL: URL {
return URL(string: "http://www.google.com")! // make sure this is a real URL for your backend.
}
var path: String {
switch self {
case .home:
return "/home"
}
}
// etc.
}
typealias APIResult<T> = Result<T, APIClientError>
enum APIClientError: Error {
case decodingError(error: Error)
case noDataToDecode
case httpError(error: Error)
}
/// A protocol for being able to transform incoming json data before converting it via Codable
/// and finally allows you to pass it, or another type completely back via the result() function.
/// You might have a struct called UsersResponse, with a property .users: [User].
/// this JSONParsing instance could have a ResultType of [User], even though
/// if's the UsersResponse type that is decoded JSON.
public protocol JSONParsing: Codable {
associatedtype ResultType
/// This is where you return a value as a result of the parsing. Sometimes it could be the instance itself,
/// or sometimes some derived property from this oftentimes intermediate data model.
/// it's also in this method that you could do some data operations or fire off some notifications
func result() -> ResultType
/// if JSON returned from a webservice should be altered somehow before decoding begins
/// if `true` the method `transform(_ responseJSON: [String: Any], from request: APIRequest?) throws -> [String: Any]` will be invoked.
/// The return value will then be serialized to data, then deserialized via the `Codable` protocol.
static var requiresInputTransformation: Bool { get }
/// basically you can massage this incoming data, or if it comes in an unexpected format,
/// you can throw an error
static func transform(_ responseJSON: [String: Any], from request: APIRequest?) throws -> [String: Any]
}
// Provide some default implementation for conformance that ultimately results in it working as before
extension JSONParsing {
public static var requiresInputTransformation: Bool { return false }
public static func transform(_ responseJSON: [String: Any], from request: APIRequest? = nil) throws -> [String: Any] {
return responseJSON
}
}
/// parse data that was returned from the given request. First it checks if Decoder needs transformation, and transforms it.
/// Then attempts to use the Codable support of the Decoder type, then convert that decoded value to the give result type
/// via the result(from decoded) method
func parseData<Decoder: JSONParsing>(_ data: Data?,
from request: APIRequest?,
decodeJSONWith decoder: Decoder.Type) -> APIResult<Decoder.ResultType> {
do {
var dataToDecode = data
if decoder.requiresInputTransformation {
// then create json from the data if possible.
// if no data, empty dict. otherwise make json out of it, if you can't... empty dict.
let json: [String: Any] = (data == nil) ? [:] : try JSONSerialization.jsonObject(with: data!, options: .allowFragments) as? [String: Any] ?? [:]
// transform the payload
let transformed = try decoder.transform(json, from: request)
// re-serialize
dataToDecode = try JSONSerialization.data(withJSONObject: transformed, options: [.prettyPrinted])
}
if let jsonData = dataToDecode {
let decoded = try JSONDecoder().decode(decoder, from: jsonData)
return .success(decoded.result())
} else {
throw APIClientError.noDataToDecode
}
} catch let e as APIClientError {
return .failure(e)
} catch {
return .failure(.decodingError(error: error))
}
}
// Example Implementation
struct NamesResponse: JSONParsing {
typealias ResultType = [String] // in most cases your conformance requires you to declare a return type, then implement result()
let names: [String]
func result() -> ResultType {
return names
}
}
func print<T>(result: APIResult<T>) {
switch result {
case .success(let name):
print("✅: \(name)")
case .failure(let error):
print("❌: \(String(describing: error))")
}
}
let response = NamesResponse(names: ["Dave", "Steve"])
let responseData = try! JSONEncoder().encode(response)
let request = Endpoint.home
let result = parseData(responseData, from: request, decodeJSONWith: NamesResponse.self)
print(result: result)
print("eof")
I’m not certain many people read this blog, and that’s fine too; I put this here for my memory’s sake. 😉 Hit me up if you have questions.