I'm putting together a networking layer for an iOS app. I'm planning on using URLSession and models conforming to Codable so I'll be avoiding 3rd party dependencies where I can.
The application will be consuming a number of endpoints across several API's, handling various request types, response types and payloads, so I'd like to find something that scales well but at the same time offers a simple abstraction within my services.
It's important it is testable too.
( Not asking for much I know )
I'm thinking I need some sort of generic API client I can conform too, a protocol for an endpoint and some enums I can use to provide the shape of each endpoint.
I imagine the call from a service looking something like this
apiClient(service: .profile, endpoint: .fetchByUserId(userId: "123")) { response in
switch response {
case .success(let profile):
// do something
case .failure(let error):
// do something
}
}
I'd love to hear of any resources or advice for building such an abstraction if anyone can help please?
Generally I don’t like designs where a request does something directly. I like when a request is a value that can be inspected, transformed or loaded to yield a response, not an action that is executed immediately. Something along this:
struct Request<T> {
let encode: (Void) throws -> URLRequest
let decode: (Data) throws -> T
// Plus other properties like path, content type, etc.
}
And then there’s a thin extension on URLSession to load requests:
extension URLSession {
// Async version
func load<T>(_ request: Request<T>, completion: @escaping (Result<T, Error>) -> Void) { … }
// Sync version for testing or playgrounds
func load<T>(_ request: Request<T>) throws -> T { … }
}
Then I have a multitude of convenience initializers on Request to support various scenarios such as Codable requests and responses, different HTTP methods and so on. An API can then be modelled like this:
struct MyAPI {
func createAccount(name: String) -> Request<Account> {
// Local payload type
struct Payload: Codable {
let name: String
}
// T is inferred to be Account, will be automatically decoded from JSON
return Request(path: "/user", json: Payload(name: name))
}
func deleteAccount(id: Int) -> Request<Void> { … }
}
So the final usage looks like this:
// Methods on MyAPI can be static or you can use the MyAPI instance to store credentials or server URLs
let service = MyAPI()
let accountCreationRequest = service.createAccount(name: "Bimbo")
let account = try? URLSession.shared.loadSync(accountCreationRequest)
And testing is trivial:
let service = MyAPI()
let accountCreationRequest = service.createAccount(name: "Bimbo")
let request = try? accountCreationRequest.encode()
XCTAssert(…)