iOS Networking with URLSession: Building Robust API Clients
Published:
Modern iOS apps rely heavily on network communication to fetch data, upload content, and interact with backend services. URLSession is Apple’s powerful networking framework that provides a robust foundation for building reliable API clients. In this comprehensive guide, we’ll explore advanced URLSession techniques with real, working code examples that will help you build scalable, maintainable networking layers for your iOS applications.
1. Advanced URLSession Configuration
A well-configured URLSession is the foundation for reliable networking. Let’s create a robust networking manager with advanced configuration options.
Custom URLSession Manager
import Foundation
import Network
class NetworkManager {
static let shared = NetworkManager()
private let session: URLSession
private let monitor = NWPathMonitor()
private let monitorQueue = DispatchQueue(label: "NetworkMonitor")
private init() {
// Configure URLSession for optimal performance
let configuration = URLSessionConfiguration.default
// Connection settings
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 300
configuration.waitsForConnectivity = true
// Caching settings
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.urlCache = URLCache(
memoryCapacity: 50 * 1024 * 1024, // 50 MB
diskCapacity: 100 * 1024 * 1024, // 100 MB
diskPath: "network_cache"
)
// HTTP settings
configuration.httpMaximumConnectionsPerHost = 6
configuration.httpShouldUsePipelining = true
// Security settings
configuration.tlsMinimumSupportedProtocolVersion = .TLSv12
configuration.tlsMaximumSupportedProtocolVersion = .TLSv13
// Create session with custom delegate
session = URLSession(
configuration: configuration,
delegate: NetworkSessionDelegate(),
delegateQueue: nil
)
setupNetworkMonitoring()
}
private func setupNetworkMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
DispatchQueue.main.async {
self?.handleNetworkChange(path)
}
}
monitor.start(queue: monitorQueue)
}
private func handleNetworkChange(_ path: NWPath) {
switch path.status {
case .satisfied:
print("Network connection available")
case .unsatisfied:
print("Network connection unavailable")
case .requiresConnection:
print("Network connection requires connection")
@unknown default:
print("Unknown network status")
}
}
}
// MARK: - URLSession Delegate
class NetworkSessionDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate, URLSessionDataDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
let progress = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
print("Upload progress: \(progress * 100)%")
}
func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
// Handle SSL certificate challenges
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust {
if let serverTrust = challenge.protectionSpace.serverTrust {
let credential = URLCredential(trust: serverTrust)
completionHandler(.useCredential, credential)
return
}
}
completionHandler(.performDefaultHandling, nil)
}
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
// Handle redirects
completionHandler(request)
}
}
API Client with Retry Logic
class APIClient {
private let session: URLSession
private let baseURL: URL
private let retryCount: Int
private let retryDelay: TimeInterval
init(baseURL: URL, retryCount: Int = 3, retryDelay: TimeInterval = 1.0) {
self.session = NetworkManager.shared.session
self.baseURL = baseURL
self.retryCount = retryCount
self.retryDelay = retryDelay
}
// MARK: - Generic Request Method
func request<T: Codable>(
endpoint: APIEndpoint,
responseType: T.Type,
retryAttempt: Int = 0
) async throws -> T {
let request = try buildRequest(for: endpoint)
do {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
// Handle different HTTP status codes
switch httpResponse.statusCode {
case 200...299:
return try JSONDecoder().decode(T.self, from: data)
case 401:
throw NetworkError.unauthorized
case 403:
throw NetworkError.forbidden
case 404:
throw NetworkError.notFound
case 500...599:
throw NetworkError.serverError(httpResponse.statusCode)
default:
throw NetworkError.httpError(httpResponse.statusCode)
}
} catch {
// Retry logic for network errors
if retryAttempt < retryCount && shouldRetry(error: error) {
try await Task.sleep(nanoseconds: UInt64(retryDelay * 1_000_000_000))
return try await request(
endpoint: endpoint,
responseType: responseType,
retryAttempt: retryAttempt + 1
)
}
throw error
}
}
private func buildRequest(for endpoint: APIEndpoint) throws -> URLRequest {
let url = baseURL.appendingPathComponent(endpoint.path)
var request = URLRequest(url: url)
request.httpMethod = endpoint.method.rawValue
request.allHTTPHeaderFields = endpoint.headers
if let body = endpoint.body {
request.httpBody = try JSONSerialization.data(withJSONObject: body)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
return request
}
private func shouldRetry(error: Error) -> Bool {
// Retry on network connectivity issues
if let urlError = error as? URLError {
switch urlError.code {
case .networkConnectionLost, .notConnectedToInternet, .timedOut:
return true
default:
return false
}
}
return false
}
}
// MARK: - API Endpoint Protocol
protocol APIEndpoint {
var path: String { get }
var method: HTTPMethod { get }
var headers: [String: String] { get }
var body: [String: Any]? { get }
}
enum HTTPMethod: String {
case GET = "GET"
case POST = "POST"
case PUT = "PUT"
case DELETE = "DELETE"
case PATCH = "PATCH"
}
enum NetworkError: Error, LocalizedError {
case invalidResponse
case unauthorized
case forbidden
case notFound
case serverError(Int)
case httpError(Int)
case decodingError
case encodingError
var errorDescription: String? {
switch self {
case .invalidResponse:
return "Invalid response from server"
case .unauthorized:
return "Unauthorized access"
case .forbidden:
return "Access forbidden"
case .notFound:
return "Resource not found"
case .serverError(let code):
return "Server error: \(code)"
case .httpError(let code):
return "HTTP error: \(code)"
case .decodingError:
return "Failed to decode response"
case .encodingError:
return "Failed to encode request"
}
}
}
2. Advanced Data Models and Codable
Proper data modeling is crucial for robust API communication.
API Response Models
// MARK: - Base Response Models
struct APIResponse<T: Codable>: Codable {
let success: Bool
let data: T?
let message: String?
let error: APIError?
}
struct APIError: Codable {
let code: String
let message: String
let details: [String: String]?
}
// MARK: - User Models
struct User: Codable, Identifiable {
let id: UUID
let email: String
let name: String
let avatar: URL?
let createdAt: Date
let updatedAt: Date
enum CodingKeys: String, CodingKey {
case id, email, name, avatar
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
struct CreateUserRequest: Codable {
let email: String
let name: String
let password: String
}
struct UpdateUserRequest: Codable {
let name: String?
let avatar: URL?
}
// MARK: - Post Models
struct Post: Codable, Identifiable {
let id: UUID
let title: String
let content: String
let author: User
let tags: [String]
let likes: Int
let comments: Int
let createdAt: Date
let updatedAt: Date
enum CodingKeys: String, CodingKey {
case id, title, content, author, tags, likes, comments
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
struct CreatePostRequest: Codable {
let title: String
let content: String
let tags: [String]
}
// MARK: - Pagination Models
struct PaginatedResponse<T: Codable>: Codable {
let data: [T]
let pagination: Pagination
}
struct Pagination: Codable {
let page: Int
let perPage: Int
let total: Int
let totalPages: Int
enum CodingKeys: String, CodingKey {
case page
case perPage = "per_page"
case total
case totalPages = "total_pages"
}
var hasNextPage: Bool {
return page < totalPages
}
var hasPreviousPage: Bool {
return page > 1
}
}
3. Concurrent Network Operations
Modern iOS apps often need to perform multiple network operations concurrently.
Concurrent API Operations
class ConcurrentAPIClient {
private let apiClient: APIClient
init(baseURL: URL) {
self.apiClient = APIClient(baseURL: baseURL)
}
// MARK: - Concurrent Data Fetching
func fetchDashboardData() async throws -> DashboardData {
async let users = fetchUsers(limit: 10)
async let posts = fetchPosts(limit: 20)
async let statistics = fetchStatistics()
let (usersResult, postsResult, statisticsResult) = try await (users, posts, statistics)
return DashboardData(
recentUsers: usersResult,
recentPosts: postsResult,
statistics: statisticsResult
)
}
func fetchUserProfile(with userId: UUID) async throws -> UserProfile {
async let user = fetchUser(id: userId)
async let posts = fetchUserPosts(userId: userId, limit: 10)
async let followers = fetchUserFollowers(userId: userId, limit: 20)
async let following = fetchUserFollowing(userId: userId, limit: 20)
let (userResult, postsResult, followersResult, followingResult) = try await (user, posts, followers, following)
return UserProfile(
user: userResult,
posts: postsResult,
followers: followersResult,
following: followingResult
)
}
// MARK: - Batch Operations
func batchUpdateUsers(_ updates: [UserUpdate]) async throws -> [User] {
return try await withThrowingTaskGroup(of: User.self) { group in
for update in updates {
group.addTask {
return try await self.updateUser(id: update.id, request: update.request)
}
}
var results: [User] = []
for try await result in group {
results.append(result)
}
return results.sorted { $0.id.uuidString < $1.id.uuidString }
}
}
// MARK: - Individual API Methods
private func fetchUsers(limit: Int) async throws -> [User] {
let endpoint = UsersEndpoint.getUsers(limit: limit)
let response: APIResponse<[User]> = try await apiClient.request(
endpoint: endpoint,
responseType: APIResponse<[User]>.self
)
return response.data ?? []
}
private func fetchPosts(limit: Int) async throws -> [Post] {
let endpoint = PostsEndpoint.getPosts(limit: limit)
let response: APIResponse<[Post]> = try await apiClient.request(
endpoint: endpoint,
responseType: APIResponse<[Post]>.self
)
return response.data ?? []
}
private func fetchStatistics() async throws -> Statistics {
let endpoint = StatisticsEndpoint.getStatistics()
let response: APIResponse<Statistics> = try await apiClient.request(
endpoint: endpoint,
responseType: APIResponse<Statistics>.self
)
return response.data ?? Statistics()
}
private func fetchUser(id: UUID) async throws -> User {
let endpoint = UsersEndpoint.getUser(id: id)
let response: APIResponse<User> = try await apiClient.request(
endpoint: endpoint,
responseType: APIResponse<User>.self
)
return response.data ?? User(id: UUID(), email: "", name: "", avatar: nil, createdAt: Date(), updatedAt: Date())
}
private func fetchUserPosts(userId: UUID, limit: Int) async throws -> [Post] {
let endpoint = PostsEndpoint.getUserPosts(userId: userId, limit: limit)
let response: APIResponse<[Post]> = try await apiClient.request(
endpoint: endpoint,
responseType: APIResponse<[Post]>.self
)
return response.data ?? []
}
private func fetchUserFollowers(userId: UUID, limit: Int) async throws -> [User] {
let endpoint = UsersEndpoint.getUserFollowers(userId: userId, limit: limit)
let response: APIResponse<[User]> = try await apiClient.request(
endpoint: endpoint,
responseType: APIResponse<[User]>.self
)
return response.data ?? []
}
private func fetchUserFollowing(userId: UUID, limit: Int) async throws -> [User] {
let endpoint = UsersEndpoint.getUserFollowing(userId: userId, limit: limit)
let response: APIResponse<[User]> = try await apiClient.request(
endpoint: endpoint,
responseType: APIResponse<[User]>.self
)
return response.data ?? []
}
private func updateUser(id: UUID, request: UpdateUserRequest) async throws -> User {
let endpoint = UsersEndpoint.updateUser(id: id, request: request)
let response: APIResponse<User> = try await apiClient.request(
endpoint: endpoint,
responseType: APIResponse<User>.self
)
return response.data ?? User(id: UUID(), email: "", name: "", avatar: nil, createdAt: Date(), updatedAt: Date())
}
}
// MARK: - Data Models
struct DashboardData {
let recentUsers: [User]
let recentPosts: [Post]
let statistics: Statistics
}
struct UserProfile {
let user: User
let posts: [Post]
let followers: [User]
let following: [User]
}
struct Statistics: Codable {
let totalUsers: Int
let totalPosts: Int
let totalLikes: Int
let totalComments: Int
enum CodingKeys: String, CodingKey {
case totalUsers = "total_users"
case totalPosts = "total_posts"
case totalLikes = "total_likes"
case totalComments = "total_comments"
}
init() {
self.totalUsers = 0
self.totalPosts = 0
self.totalLikes = 0
self.totalComments = 0
}
}
struct UserUpdate {
let id: UUID
let request: UpdateUserRequest
}
4. File Upload and Download
Handling file uploads and downloads efficiently is crucial for many iOS apps.
File Transfer Manager
class FileTransferManager {
private let session: URLSession
init() {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForResource = 600 // 10 minutes for large files
configuration.waitsForConnectivity = true
self.session = URLSession(configuration: configuration)
}
// MARK: - File Upload
func uploadFile(
url: URL,
fileURL: URL,
progressHandler: @escaping (Double) -> Void,
completionHandler: @escaping (Result<UploadResponse, Error>) -> Void
) {
var request = URLRequest(url: url)
request.httpMethod = "POST"
let boundary = UUID().uuidString
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var body = Data()
// Add file data
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileURL.lastPathComponent)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!)
if let fileData = try? Data(contentsOf: fileURL) {
body.append(fileData)
}
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
let task = session.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
if let error = error {
completionHandler(.failure(error))
return
}
guard let data = data else {
completionHandler(.failure(NetworkError.invalidResponse))
return
}
do {
let uploadResponse = try JSONDecoder().decode(UploadResponse.self, from: data)
completionHandler(.success(uploadResponse))
} catch {
completionHandler(.failure(NetworkError.decodingError))
}
}
}
// Monitor upload progress
let observation = task.progress.observe(\.fractionCompleted) { progress, _ in
DispatchQueue.main.async {
progressHandler(progress.fractionCompleted)
}
}
task.resume()
}
// MARK: - File Download
func downloadFile(
url: URL,
destinationURL: URL,
progressHandler: @escaping (Double) -> Void,
completionHandler: @escaping (Result<URL, Error>) -> Void
) {
let task = session.downloadTask(with: url) { tempURL, response, error in
DispatchQueue.main.async {
if let error = error {
completionHandler(.failure(error))
return
}
guard let tempURL = tempURL else {
completionHandler(.failure(NetworkError.invalidResponse))
return
}
do {
// Move file to destination
if FileManager.default.fileExists(atPath: destinationURL.path) {
try FileManager.default.removeItem(at: destinationURL)
}
try FileManager.default.moveItem(at: tempURL, to: destinationURL)
completionHandler(.success(destinationURL))
} catch {
completionHandler(.failure(error))
}
}
}
// Monitor download progress
let observation = task.progress.observe(\.fractionCompleted) { progress, _ in
DispatchQueue.main.async {
progressHandler(progress.fractionCompleted)
}
}
task.resume()
}
// MARK: - Background Download
func startBackgroundDownload(
url: URL,
identifier: String
) -> URLSessionDownloadTask {
let task = session.downloadTask(with: url)
task.taskDescription = identifier
task.resume()
return task
}
}
struct UploadResponse: Codable {
let success: Bool
let fileURL: URL?
let message: String?
enum CodingKeys: String, CodingKey {
case success
case fileURL = "file_url"
case message
}
}
5. Caching and Offline Support
Implementing intelligent caching and offline support improves user experience.
Network Cache Manager
class NetworkCacheManager {
private let cache = NSCache<NSString, CachedData>()
private let fileManager = FileManager.default
private let cacheDirectory: URL
init() {
let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
cacheDirectory = documentsPath.appendingPathComponent("NetworkCache")
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
// Configure cache limits
cache.countLimit = 100
cache.totalCostLimit = 50 * 1024 * 1024 // 50 MB
}
// MARK: - Memory Cache
func cacheData(_ data: Data, for key: String, expirationInterval: TimeInterval = 3600) {
let cachedData = CachedData(data: data, timestamp: Date(), expirationInterval: expirationInterval)
cache.setObject(cachedData, forKey: key as NSString)
// Also save to disk for persistence
saveToDisk(data: data, for: key)
}
func getCachedData(for key: String) -> Data? {
// Check memory cache first
if let cachedData = cache.object(forKey: key as NSString), !cachedData.isExpired {
return cachedData.data
}
// Check disk cache
if let diskData = loadFromDisk(for: key) {
// Update memory cache
let cachedData = CachedData(data: diskData, timestamp: Date(), expirationInterval: 3600)
cache.setObject(cachedData, forKey: key as NSString)
return diskData
}
return nil
}
// MARK: - Disk Cache
private func saveToDisk(data: Data, for key: String) {
let fileURL = cacheDirectory.appendingPathComponent(key.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? key)
do {
try data.write(to: fileURL)
} catch {
print("Failed to save cache to disk: \(error)")
}
}
private func loadFromDisk(for key: String) -> Data? {
let fileURL = cacheDirectory.appendingPathComponent(key.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? key)
do {
return try Data(contentsOf: fileURL)
} catch {
return nil
}
}
// MARK: - Cache Management
func clearCache() {
cache.removeAllObjects()
do {
let contents = try fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil)
for fileURL in contents {
try fileManager.removeItem(at: fileURL)
}
} catch {
print("Failed to clear disk cache: \(error)")
}
}
func getCacheSize() -> Int {
do {
let contents = try fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: [.fileSizeKey])
return contents.reduce(0) { total, url in
let fileSize = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
return total + fileSize
}
} catch {
return 0
}
}
}
class CachedData {
let data: Data
let timestamp: Date
let expirationInterval: TimeInterval
var isExpired: Bool {
return Date().timeIntervalSince(timestamp) > expirationInterval
}
init(data: Data, timestamp: Date, expirationInterval: TimeInterval) {
self.data = data
self.timestamp = timestamp
self.expirationInterval = expirationInterval
}
}
Summary
Building robust networking layers with URLSession requires:
- Proper Configuration: Configure URLSession for optimal performance and reliability
- Error Handling: Implement comprehensive error handling and retry logic
- Concurrent Operations: Use async/await for efficient concurrent network operations
- File Transfer: Handle file uploads and downloads with progress tracking
- Caching: Implement intelligent caching for offline support and performance
- Monitoring: Monitor network connectivity and performance
By implementing these techniques, you can create networking layers that are reliable, performant, and provide excellent user experience. Remember to handle edge cases, implement proper error handling, and test your networking code thoroughly.