Building a network layer using Combine iOS, with a structure similar to Moya
Here’s an example of how to build a network layer using Combine, with a structure similar to Moya.
When building iOS or macOS apps that communicate with a REST API, it’s often useful to have a generic API client that can handle requests to different endpoints and return the response as a generic type. This can help reduce code duplication and make it easier to add new endpoints to the app.
In this article, we’ll explore how to implement a generic API client in Swift using protocol-oriented programming and URLSession.
API Endpoint Protocol
The first step in building a generic API client is to define a protocol that represents an API endpoint. An API endpoint typically includes a base URL, a path, an HTTP method, and any additional headers or parameters required for the request. Here’s an example implementation:
protocol APIEndpoint {
var baseURL: URL { get }
var path: String { get }
var method: HTTPMethod { get }
var headers: [String: String]? { get }
var parameters: [String: Any]? { get }
}
extension APIEndpoint {
var headers: [String: String]? {
return nil
}
var parameters: [String: Any]? {
return nil
}
}
enum APIError: Error {
case invalidResponse
case invalidData
}
HTTP Method Enum
Next, we’ll define an enum that represents HTTP methods. This enum will be used by the APIEndpoint
protocol to specify which HTTP method to use for a particular request. Here's an example implementation:
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case patch = "PATCH"
case delete = "DELETE"
}
APIClient Protocol
The APIClient
protocol represents a generic API client that can make requests using an APIEndpoint
. Here's an example implementation:
protocol APIClient {
associatedtype EndpointType: APIEndpoint
func request<T: Decodable>(_ endpoint: EndpointType) -> AnyPublisher<T, Error>
}
The APIClient
protocol includes an associated type EndpointType
, which represents the APIEndpoint
type that the client will use. The request
method takes an EndpointType
as an argument and returns a AnyPublisher<T, Error>
.
URLSessionAPIClient Class
The URLSessionAPIClient
class is an implementation of the APIClient
protocol that uses Combine
to make requests and handle responses. Here's an example implementation:
class URLSessionAPIClient<EndpointType: APIEndpoint>: APIClient {
func request<T: Decodable>(_ endpoint: EndpointType) -> AnyPublisher<T, Error> {
let url = endpoint.baseURL.appendingPathComponent(endpoint.path)
var request = URLRequest(url: url)
request.httpMethod = endpoint.method.rawValue
// Set up any request headers or parameters here
endpoint.headers?.forEach { request.addValue($0.value, forHTTPHeaderField: $0.key) }
return URLSession.shared.dataTaskPublisher(for: request)
.subscribe(on: DispatchQueue.global(qos: .background))
.tryMap { data, response -> Data in
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw APIError.invalidResponse
}
return data
}
.decode(type: T.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
}
The URLSessionAPIClient
class is generic over an EndpointType
that conforms to the APIEndpoint
protocol. The request
method takes an EndpointType
and returns a AnyPublisher<T, Error>
. The method uses URLSession.shared.dataTaskPublisher(for:)
to create a Publisher
that emits a tuple of the response data and URL response, and then uses Combine
operators to transform and decode the data into the specified generic type.
Example Usage
To use the generic API client, you’ll need to define an implementation of the APIEndpoint
protocol that represents the endpoint you want to use. Here's an example implementation:
enum UserEndpoint: APIEndpoint {
case getUsers
case getUser(id: Int)
case updateUser(id: Int, name: String, email: String)
var baseURL: URL {
return URL(string: "https://example.com/api")! // Set your base URL here
}
var path: String {
switch self {
case .getUsers:
return "/users"
case .getUser(let id):
return "/users/\(id)"
case .updateUser(let id, _, _):
return "/users/\(id)"
}
}
var method: HTTPMethod {
switch self {
case .getUsers, .getUser:
return .get
case .updateUser:
return .put
}
}
var headers: [String: String]? {
switch self {
case .updateUser:
return ["Content-Type": "application/json"]
default:
return nil
}
}
var parameters: [String: Any]? {
switch self {
case .updateUser(_, let name, let email):
return ["name": name, "email": email]
default:
return nil
}
}
}
In this example, the UserEndpoint
enum defines three endpoints - getUsers
, getUser(id:)
, and updateUser(id:name:email:)
. The baseURL
property specifies the base URL for the API, and the path
property specifies the path for the endpoint. The method
property specifies the HTTP method to use for the request, and the headers
and parameters
properties specify any additional headers or parameters required for the request.
To use the generic API client to make a request, you can create an instance of the URLSessionAPIClient
with the desired EndpointType
and call the request
method with an instance of the desired APIEndpoint
:
let apiClient = URLSessionAPIClient<UserEndpoint>()
apiClient.request(UserEndpoint.getUsers)
.sink(
receiveCompletion: { completion in
// Handle completion
},
receiveValue: { users in
// Handle users
}
)
.store(in: &cancellables)
In this example, we’re creating an instance of URLSessionAPIClient<UserEndpoint>
and using it to make a request to the getUsers
endpoint. The sink
operator is used to handle the response. The store(in:)
method is used to store the AnyCancellable
returned by sink
, which will be used to cancel the request if necessary.
Conclusion
Using Combine
to build a generic API client in Swift can simplify network code and reduce duplication across an application. By defining protocols for API endpoints and clients, and using Combine
to handle requests and responses, we can create a flexible and reusable networking layer.