iOS Networking with URLSession: Building Robust API Clients

15 minute read

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:

  1. Proper Configuration: Configure URLSession for optimal performance and reliability
  2. Error Handling: Implement comprehensive error handling and retry logic
  3. Concurrent Operations: Use async/await for efficient concurrent network operations
  4. File Transfer: Handle file uploads and downloads with progress tracking
  5. Caching: Implement intelligent caching for offline support and performance
  6. 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.