Introduction: Why a Proper HTTP Client Matters for Your Swift App
Modern iOS and macOS apps rarely function without interacting with remote servers. Whether it's loading data from a REST API, sending analytics, or working with cloud services — an HTTP client in Swift is the foundation of network communication. However, many developers, especially beginners, make typical mistakes: ignoring error handling, not using modern language features (async/await), or creating "spaghetti code" from network calls. In this article, we'll break down how to write a reliable and performant HTTP client in Swift, following best practices.
1. URLSession Basics: The Heart of Network Requests
In the Swift standard library, the Foundation framework and the URLSession class handle network communication. This is a powerful tool that supports session configuration, caching, background requests, and much more.
1.1. Creating a Basic HTTP Client
Let's start with a simple structure that will perform GET requests. We'll use an enum to describe possible errors and a protocol for flexibility:
import Foundation
enum HTTPClientError: Error { case invalidURL case noData case decodingError(Error) case networkError(Error) case statusCode(Int)}
protocol HTTPClientProtocol { func get<T: Decodable>(from urlString: String, responseType: T.Type) async throws -> T}
final class HTTPClient: HTTPClientProtocol { private let session: URLSession private let decoder: JSONDecoder init(session: URLSession = .shared, decoder: JSONDecoder = JSONDecoder()) { self.session = session self.decoder = decoder } func get<T: Decodable>(from urlString: String, responseType: T.Type) async throws -> T { guard let url = URL(string: urlString) else { throw HTTPClientError.invalidURL } var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw HTTPClientError.networkError(NSError(domain: "Invalid response", code: -1)) } guard (200...299).contains(httpResponse.statusCode) else { throw HTTPClientError.statusCode(httpResponse.statusCode) } do { return try decoder.decode(T.self, from: data) } catch { throw HTTPClientError.decodingError(error) } }}1.2. Why async/await?
Before Swift 5.5, developers used closures (completion handlers) or third-party libraries (Alamofire). Async/await makes code linear, clear, and easy to read. Errors are handled through do-catch instead of nested closures.
2. POST, PUT, DELETE: Working with Different HTTP Methods
A real HTTP client should support all major methods. Let's add methods to the protocol for sending data and updating resources.
2.1. Extending the Protocol for POST and PUT
extension HTTPClientProtocol { func post<T: Decodable, U: Encodable>( to urlString: String, body: U, responseType: T.Type ) async throws -> T { guard let url = URL(string: urlString) else { throw HTTPClientError.invalidURL } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") request.httpBody = try JSONEncoder().encode(body) return try a