15 KiB
15 KiB
Networking
URLSession patterns, caching, authentication, and offline support.
Basic Networking Service
actor NetworkService {
private let session: URLSession
private let decoder: JSONDecoder
private let encoder: JSONEncoder
init(session: URLSession = .shared) {
self.session = session
self.decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
self.encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.keyEncodingStrategy = .convertToSnakeCase
}
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
let request = try endpoint.urlRequest()
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard 200..<300 ~= httpResponse.statusCode else {
throw NetworkError.httpError(httpResponse.statusCode, data)
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw NetworkError.decodingError(error)
}
}
func send<T: Encodable, R: Decodable>(_ body: T, to endpoint: Endpoint) async throws -> R {
var request = try endpoint.urlRequest()
request.httpBody = try encoder.encode(body)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard 200..<300 ~= httpResponse.statusCode else {
throw NetworkError.httpError(httpResponse.statusCode, data)
}
return try decoder.decode(R.self, from: data)
}
}
enum NetworkError: LocalizedError {
case invalidURL
case invalidResponse
case httpError(Int, Data)
case decodingError(Error)
case noConnection
case timeout
var errorDescription: String? {
switch self {
case .invalidURL:
return "Invalid URL"
case .invalidResponse:
return "Invalid server response"
case .httpError(let code, _):
return "Server error (\(code))"
case .decodingError:
return "Failed to parse response"
case .noConnection:
return "No internet connection"
case .timeout:
return "Request timed out"
}
}
}
Endpoint Pattern
enum Endpoint {
case items
case item(id: String)
case createItem
case updateItem(id: String)
case deleteItem(id: String)
case search(query: String, page: Int)
var baseURL: URL {
URL(string: "https://api.example.com/v1")!
}
var path: String {
switch self {
case .items, .createItem:
return "/items"
case .item(let id), .updateItem(let id), .deleteItem(let id):
return "/items/\(id)"
case .search:
return "/search"
}
}
var method: String {
switch self {
case .items, .item, .search:
return "GET"
case .createItem:
return "POST"
case .updateItem:
return "PUT"
case .deleteItem:
return "DELETE"
}
}
var queryItems: [URLQueryItem]? {
switch self {
case .search(let query, let page):
return [
URLQueryItem(name: "q", value: query),
URLQueryItem(name: "page", value: String(page))
]
default:
return nil
}
}
func urlRequest() throws -> URLRequest {
var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: true)
components?.queryItems = queryItems
guard let url = components?.url else {
throw NetworkError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Accept")
return request
}
}
Authentication
Bearer Token
actor AuthenticatedNetworkService {
private let session: URLSession
private let tokenProvider: TokenProvider
init(tokenProvider: TokenProvider) {
self.session = .shared
self.tokenProvider = tokenProvider
}
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
var request = try endpoint.urlRequest()
// Add auth header
let token = try await tokenProvider.validToken()
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
// Handle 401 - token expired
if httpResponse.statusCode == 401 {
// Refresh token and retry
let newToken = try await tokenProvider.refreshToken()
request.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization")
let (retryData, retryResponse) = try await session.data(for: request)
guard let retryHttpResponse = retryResponse as? HTTPURLResponse,
200..<300 ~= retryHttpResponse.statusCode else {
throw NetworkError.unauthorized
}
return try JSONDecoder().decode(T.self, from: retryData)
}
guard 200..<300 ~= httpResponse.statusCode else {
throw NetworkError.httpError(httpResponse.statusCode, data)
}
return try JSONDecoder().decode(T.self, from: data)
}
}
protocol TokenProvider {
func validToken() async throws -> String
func refreshToken() async throws -> String
}
OAuth 2.0 Flow
import AuthenticationServices
class OAuthService: NSObject {
func signIn() async throws -> String {
let authURL = URL(string: "https://auth.example.com/authorize?client_id=xxx&redirect_uri=myapp://callback&response_type=code")!
return try await withCheckedThrowingContinuation { continuation in
let session = ASWebAuthenticationSession(
url: authURL,
callbackURLScheme: "myapp"
) { callbackURL, error in
if let error = error {
continuation.resume(throwing: error)
return
}
guard let callbackURL = callbackURL,
let code = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
.queryItems?.first(where: { $0.name == "code" })?.value else {
continuation.resume(throwing: OAuthError.invalidCallback)
return
}
continuation.resume(returning: code)
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = true
session.start()
}
}
}
extension OAuthService: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first { $0.isKeyWindow }!
}
}
Caching
URLCache Configuration
class CachedNetworkService {
private let session: URLSession
init() {
let cache = URLCache(
memoryCapacity: 50 * 1024 * 1024, // 50 MB memory
diskCapacity: 200 * 1024 * 1024 // 200 MB disk
)
let config = URLSessionConfiguration.default
config.urlCache = cache
config.requestCachePolicy = .returnCacheDataElseLoad
self.session = URLSession(configuration: config)
}
func fetch<T: Decodable>(_ endpoint: Endpoint, cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy) async throws -> T {
var request = try endpoint.urlRequest()
request.cachePolicy = cachePolicy
let (data, _) = try await session.data(for: request)
return try JSONDecoder().decode(T.self, from: data)
}
func fetchFresh<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
try await fetch(endpoint, cachePolicy: .reloadIgnoringLocalCacheData)
}
}
Custom Caching
actor DataCache {
private var cache: [String: CachedItem] = [:]
private let maxAge: TimeInterval
struct CachedItem {
let data: Data
let timestamp: Date
}
init(maxAge: TimeInterval = 300) {
self.maxAge = maxAge
}
func get(_ key: String) -> Data? {
guard let item = cache[key] else { return nil }
guard Date().timeIntervalSince(item.timestamp) < maxAge else {
cache.removeValue(forKey: key)
return nil
}
return item.data
}
func set(_ data: Data, for key: String) {
cache[key] = CachedItem(data: data, timestamp: Date())
}
func invalidate(_ key: String) {
cache.removeValue(forKey: key)
}
func clearAll() {
cache.removeAll()
}
}
Offline Support
Network Monitor
import Network
@Observable
class NetworkMonitor {
var isConnected = true
var connectionType: ConnectionType = .wifi
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "NetworkMonitor")
enum ConnectionType {
case wifi, cellular, unknown
}
init() {
monitor.pathUpdateHandler = { [weak self] path in
DispatchQueue.main.async {
self?.isConnected = path.status == .satisfied
self?.connectionType = self?.getConnectionType(path) ?? .unknown
}
}
monitor.start(queue: queue)
}
private func getConnectionType(_ path: NWPath) -> ConnectionType {
if path.usesInterfaceType(.wifi) {
return .wifi
} else if path.usesInterfaceType(.cellular) {
return .cellular
}
return .unknown
}
deinit {
monitor.cancel()
}
}
Offline-First Pattern
actor OfflineFirstService {
private let network: NetworkService
private let storage: StorageService
private let cache: DataCache
func fetchItems() async throws -> [Item] {
// Try cache first
if let cached = await cache.get("items"),
let items = try? JSONDecoder().decode([Item].self, from: cached) {
// Return cached, fetch fresh in background
Task {
try? await fetchAndCacheFresh()
}
return items
}
// Try network
do {
let items: [Item] = try await network.fetch(.items)
await cache.set(try JSONEncoder().encode(items), for: "items")
return items
} catch {
// Fall back to storage
return try await storage.loadItems()
}
}
private func fetchAndCacheFresh() async throws {
let items: [Item] = try await network.fetch(.items)
await cache.set(try JSONEncoder().encode(items), for: "items")
try await storage.saveItems(items)
}
}
Pending Operations Queue
actor PendingOperationsQueue {
private var operations: [PendingOperation] = []
private let storage: StorageService
struct PendingOperation: Codable {
let id: UUID
let endpoint: String
let method: String
let body: Data?
let createdAt: Date
}
func add(_ operation: PendingOperation) async {
operations.append(operation)
try? await persist()
}
func processAll() async {
for operation in operations {
do {
try await execute(operation)
operations.removeAll { $0.id == operation.id }
} catch {
// Keep in queue for retry
continue
}
}
try? await persist()
}
private func execute(_ operation: PendingOperation) async throws {
// Execute network request
}
private func persist() async throws {
try await storage.savePendingOperations(operations)
}
}
Multipart Upload
extension NetworkService {
func upload(_ fileData: Data, filename: String, mimeType: String, to endpoint: Endpoint) async throws -> UploadResponse {
let boundary = UUID().uuidString
var request = try endpoint.urlRequest()
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
body.append(fileData)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
200..<300 ~= httpResponse.statusCode else {
throw NetworkError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0, data)
}
return try JSONDecoder().decode(UploadResponse.self, from: data)
}
}
Download with Progress
class DownloadService: NSObject, URLSessionDownloadDelegate {
private lazy var session: URLSession = {
URLSession(configuration: .default, delegate: self, delegateQueue: nil)
}()
private var progressHandler: ((Double) -> Void)?
private var completionHandler: ((Result<URL, Error>) -> Void)?
func download(from url: URL, progress: @escaping (Double) -> Void) async throws -> URL {
try await withCheckedThrowingContinuation { continuation in
self.progressHandler = progress
self.completionHandler = { result in
continuation.resume(with: result)
}
session.downloadTask(with: url).resume()
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
completionHandler?(.success(location))
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
DispatchQueue.main.async {
self.progressHandler?(progress)
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
completionHandler?(.failure(error))
}
}
}