Hitting an API is probably one of the most common things you do in Swift and when writing iOS apps. Designing a reusable and robust networking layer can help ensure errors are reduced and your apps are easy to maintain.
Endpoints
We first need a way to represent each endpoint our application will use. Every endpoint will have a few key properties so we can make a protocol. We can also make some enums to make various parts more safe to use. We’ll start with the HTTP method.
enum Method: String {
case get = "GET"
case post = "POST"
case delete = "DELTE"
case put = "PUT"
case patch = "PATCH"
}
Then we can represent the scheme.
enum Scheme: String {
case http
case https
}
And now we can make our protocol which states which properties every endpoint needs.
protocol API {
var scheme: Scheme { get }
var baseURL: String { get }
var path: String { get }
var parameters: [URLQueryItem] { get }
var method: Method { get }
}
Each endpoint needs a way to construct a URL from all their properties. Each API will use the same logic so we can make a protocol extension so that every implementation has the method to build the URL. We’ll make use of Swift’s URLComponents
to make building the URL safer.
extension API {
func buildURLComponents() -> URLComponents {
var components = URLComponents()
components.scheme = scheme.rawValue
components.host = baseURL
components.path = path
components.queryItems = parameters
return components
}
}
Creating an API
Now we can use this structure to easily make an API. I’ll represent it with an enum so each enum case could be a different endpoint for this API. And then each API could be a different enum for larger applications or use cases. I’m just going to use the JSON placeholder API.
enum PlaceholderAPI: API {
case posts
case todos
var scheme: Scheme {
.https
}
var baseURL: String {
"jsonplaceholder.typicode.com"
}
var path: String {
switch self {
case .posts:
"/posts"
case .todos:
"/todos"
}
}
var parameters: [URLQueryItem] {
switch self {
case .posts, .todos:
[]
}
}
var method: Method {
switch self {
case .posts, .todos:
.get
}
}
}
Creating the Networking Service
We’ve got an API but how do we make requests? We’ll make a service class to handle all networking requests. This will use the default URLSession.
To start we will make a protocol. This means when testing, we can easily mock the service if needed. Our networking service only really needs one method: a way to make a request. We will make it generic too so it can handle all different types as long as they are Codable
.
protocol NetworkService {
func request<T: Codable>(endpoint: API) async throws -> T
}
Next, we want a nice way to handle errors from our network service. An enum can help represent the various errors that may happen when sending network requests.
enum NetworkServiceError: Error {
case invalidURL
case invalidResponse
case clientError(Int)
case serverError(Int)
case unexpectedStatusCode(Int)
}
Now we can start building out our networking service. We will start by injecting a URL session and JSON decoder into the initialiser.
final class DefaultNetworkService: NetworkService {
private let session: URLSession
private let decoder: JSONDecoder
init(session: URLSession = .shared, decoder: JSONDecoder = .init()) {
self.session = session
self.decoder = decoder
}
func request<T: Codable>(endpoint: any API) async throws -> T {
}
}
And finally we can implement the logic to make a request. We first build the URL components, then add the http method. Next we make the request and finally we check the status code and decode the data.
func request<T: Codable>(endpoint: any API) async throws -> T {
let components = endpoint.buildURLComponents()
guard let url = components.url else { throw NetworkServiceError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = endpoint.method.value
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkServiceError.invalidResponse
}
let statusCode = httpResponse.statusCode
switch statusCode {
case 200...299:
return try decoder.decode(T.self, from: data)
case 400...499:
throw NetworkServiceError.clientError(statusCode)
case 500...599:
throw NetworkServiceError.serverError(statusCode)
default:
throw NetworkServiceError.unexpectedStatusCode(statusCode)
}
}
Using our Networking Service
We can now use the networking service. We will start by making a model for the data.
struct Post: Codable, Identifiable {
let userId: Int
let id: Int
let title: String
let body: String
}
And then we can use our service anywhere we want. I’m going to use it in a view model for this example.
@Observable
class ViewModel {
var posts: [Post] = []
private let networking: NetworkService
init(networking: NetworkService = DefaultNetworkService()) {
self.networking = networking
}
func fetchPosts() async {
do {
posts = try await networking.request(endpoint: PlaceholderAPI.posts)
} catch {
print("Error fetching posts: \(error.localizedDescription)")
}
}
}
Conclusion
Thanks for reading. Hopefully this has given you some inspiration on how to structure your application’s networking.