Files
2025-11-29 18:28:37 +08:00

15 KiB

Networking

URLSession patterns for API calls, authentication, caching, and offline support.

<basic_requests> <async_await>

actor NetworkService {
    private let session: URLSession
    private let decoder: JSONDecoder

    init(session: URLSession = .shared) {
        self.session = session
        self.decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
    }

    func fetch<T: Decodable>(_ request: URLRequest) async throws -> T {
        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(T.self, from: data)
    }

    func fetchData(_ request: URLRequest) async throws -> Data {
        let (data, response) = try await session.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse,
              200..<300 ~= httpResponse.statusCode else {
            throw NetworkError.requestFailed
        }

        return data
    }
}

enum NetworkError: Error {
    case invalidResponse
    case httpError(Int, Data)
    case requestFailed
    case decodingError(Error)
}

</async_await>

<request_building>

struct Endpoint {
    let path: String
    let method: HTTPMethod
    let queryItems: [URLQueryItem]?
    let body: Data?
    let headers: [String: String]?

    enum HTTPMethod: String {
        case get = "GET"
        case post = "POST"
        case put = "PUT"
        case patch = "PATCH"
        case delete = "DELETE"
    }

    var request: URLRequest {
        var components = URLComponents()
        components.scheme = "https"
        components.host = "api.example.com"
        components.path = path
        components.queryItems = queryItems

        var request = URLRequest(url: components.url!)
        request.httpMethod = method.rawValue
        request.httpBody = body

        // Default headers
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("application/json", forHTTPHeaderField: "Accept")

        // Custom headers
        headers?.forEach { request.setValue($1, forHTTPHeaderField: $0) }

        return request
    }
}

// Usage
extension Endpoint {
    static func projects() -> Endpoint {
        Endpoint(path: "/v1/projects", method: .get, queryItems: nil, body: nil, headers: nil)
    }

    static func project(id: UUID) -> Endpoint {
        Endpoint(path: "/v1/projects/\(id)", method: .get, queryItems: nil, body: nil, headers: nil)
    }

    static func createProject(_ project: CreateProjectRequest) -> Endpoint {
        let body = try? JSONEncoder().encode(project)
        return Endpoint(path: "/v1/projects", method: .post, queryItems: nil, body: body, headers: nil)
    }
}

</request_building> </basic_requests>

```swift actor AuthenticatedNetworkService { private let session: URLSession private var token: String?
init() {
    let config = URLSessionConfiguration.default
    config.httpAdditionalHeaders = [
        "User-Agent": "MyApp/1.0"
    ]
    self.session = URLSession(configuration: config)
}

func setToken(_ token: String) {
    self.token = token
}

func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
    var request = endpoint.request

    if let token = token {
        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
    }

    if httpResponse.statusCode == 401 {
        throw NetworkError.unauthorized
    }

    guard 200..<300 ~= httpResponse.statusCode else {
        throw NetworkError.httpError(httpResponse.statusCode, data)
    }

    return try JSONDecoder().decode(T.self, from: data)
}

}

</bearer_token>

<oauth_refresh>
```swift
actor OAuthService {
    private var accessToken: String?
    private var refreshToken: String?
    private var tokenExpiry: Date?
    private var isRefreshing = false

    func validToken() async throws -> String {
        // Return existing valid token
        if let token = accessToken,
           let expiry = tokenExpiry,
           expiry > Date().addingTimeInterval(60) {
            return token
        }

        // Refresh if needed
        return try await refreshAccessToken()
    }

    private func refreshAccessToken() async throws -> String {
        guard !isRefreshing else {
            // Wait for in-progress refresh
            try await Task.sleep(for: .milliseconds(100))
            return try await validToken()
        }

        isRefreshing = true
        defer { isRefreshing = false }

        guard let refresh = refreshToken else {
            throw AuthError.noRefreshToken
        }

        let request = Endpoint.refreshToken(refresh).request
        let (data, _) = try await URLSession.shared.data(for: request)
        let response = try JSONDecoder().decode(TokenResponse.self, from: data)

        accessToken = response.accessToken
        refreshToken = response.refreshToken
        tokenExpiry = Date().addingTimeInterval(TimeInterval(response.expiresIn))

        // Save to keychain
        try saveTokens()

        return response.accessToken
    }
}

</oauth_refresh>

```swift // Configure cache in URLSession let config = URLSessionConfiguration.default config.urlCache = URLCache( memoryCapacity: 50 * 1024 * 1024, // 50 MB memory diskCapacity: 100 * 1024 * 1024, // 100 MB disk diskPath: "network_cache" ) config.requestCachePolicy = .returnCacheDataElseLoad

let session = URLSession(configuration: config)

</urlcache>

<custom_cache>
```swift
actor ResponseCache {
    private var cache: [String: CachedResponse] = [:]
    private let maxAge: TimeInterval

    init(maxAge: TimeInterval = 300) {  // 5 minutes default
        self.maxAge = maxAge
    }

    func get<T: Decodable>(_ key: String) -> T? {
        guard let cached = cache[key],
              Date().timeIntervalSince(cached.timestamp) < maxAge else {
            cache[key] = nil
            return nil
        }

        return try? JSONDecoder().decode(T.self, from: cached.data)
    }

    func set<T: Encodable>(_ value: T, for key: String) {
        guard let data = try? JSONEncoder().encode(value) else { return }
        cache[key] = CachedResponse(data: data, timestamp: Date())
    }

    func invalidate(_ key: String) {
        cache[key] = nil
    }

    func clear() {
        cache.removeAll()
    }
}

struct CachedResponse {
    let data: Data
    let timestamp: Date
}

// Usage
actor CachedNetworkService {
    private let network: NetworkService
    private let cache = ResponseCache()

    func fetchProjects(forceRefresh: Bool = false) async throws -> [Project] {
        let cacheKey = "projects"

        if !forceRefresh, let cached: [Project] = await cache.get(cacheKey) {
            return cached
        }

        let projects: [Project] = try await network.fetch(Endpoint.projects().request)
        await cache.set(projects, for: cacheKey)

        return projects
    }
}

</custom_cache>

<offline_support>

@Observable
class OfflineAwareService {
    private let network: NetworkService
    private let storage: LocalStorage
    var isOnline = true

    init(network: NetworkService, storage: LocalStorage) {
        self.network = network
        self.storage = storage
        monitorConnectivity()
    }

    func fetchProjects() async throws -> [Project] {
        if isOnline {
            do {
                let projects = try await network.fetch(Endpoint.projects().request)
                try storage.save(projects, for: "projects")
                return projects
            } catch {
                // Fall back to cache on network error
                if let cached = try? storage.load("projects") as [Project] {
                    return cached
                }
                throw error
            }
        } else {
            // Offline: use cache
            guard let cached = try? storage.load("projects") as [Project] else {
                throw NetworkError.offline
            }
            return cached
        }
    }

    private func monitorConnectivity() {
        let monitor = NWPathMonitor()
        monitor.pathUpdateHandler = { [weak self] path in
            Task { @MainActor in
                self?.isOnline = path.status == .satisfied
            }
        }
        monitor.start(queue: .global())
    }
}

</offline_support>

<upload_download> <file_upload>

actor UploadService {
    func upload(file: URL, to endpoint: Endpoint) async throws -> UploadResponse {
        var request = endpoint.request

        let boundary = UUID().uuidString
        request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")

        let data = try Data(contentsOf: file)
        let body = createMultipartBody(
            data: data,
            filename: file.lastPathComponent,
            boundary: boundary
        )
        request.httpBody = body

        let (responseData, _) = try await URLSession.shared.data(for: request)
        return try JSONDecoder().decode(UploadResponse.self, from: responseData)
    }

    private func createMultipartBody(data: Data, filename: String, boundary: String) -> Data {
        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: application/octet-stream\r\n\r\n".data(using: .utf8)!)
        body.append(data)
        body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)

        return body
    }
}

</file_upload>

<file_download>

actor DownloadService {
    func download(from url: URL, to destination: URL) async throws {
        let (tempURL, response) = try await URLSession.shared.download(from: url)

        guard let httpResponse = response as? HTTPURLResponse,
              200..<300 ~= httpResponse.statusCode else {
            throw NetworkError.downloadFailed
        }

        // Move to destination
        let fileManager = FileManager.default
        if fileManager.fileExists(atPath: destination.path) {
            try fileManager.removeItem(at: destination)
        }
        try fileManager.moveItem(at: tempURL, to: destination)
    }

    func downloadWithProgress(from url: URL) -> AsyncThrowingStream<DownloadProgress, Error> {
        AsyncThrowingStream { continuation in
            let task = URLSession.shared.downloadTask(with: url) { tempURL, response, error in
                if let error = error {
                    continuation.finish(throwing: error)
                    return
                }

                guard let tempURL = tempURL else {
                    continuation.finish(throwing: NetworkError.downloadFailed)
                    return
                }

                continuation.yield(.completed(tempURL))
                continuation.finish()
            }

            // Observe progress
            let observation = task.progress.observe(\.fractionCompleted) { progress, _ in
                continuation.yield(.progress(progress.fractionCompleted))
            }

            continuation.onTermination = { _ in
                observation.invalidate()
                task.cancel()
            }

            task.resume()
        }
    }
}

enum DownloadProgress {
    case progress(Double)
    case completed(URL)
}

</file_download> </upload_download>

<error_handling>

enum NetworkError: LocalizedError {
    case invalidResponse
    case httpError(Int, Data)
    case unauthorized
    case offline
    case timeout
    case decodingError(Error)

    var errorDescription: String? {
        switch self {
        case .invalidResponse:
            return "Invalid server response"
        case .httpError(let code, _):
            return "Server error: \(code)"
        case .unauthorized:
            return "Authentication required"
        case .offline:
            return "No internet connection"
        case .timeout:
            return "Request timed out"
        case .decodingError(let error):
            return "Data error: \(error.localizedDescription)"
        }
    }

    var isRetryable: Bool {
        switch self {
        case .httpError(let code, _):
            return code >= 500
        case .timeout, .offline:
            return true
        default:
            return false
        }
    }
}

// Retry logic
func fetchWithRetry<T: Decodable>(
    _ request: URLRequest,
    maxAttempts: Int = 3
) async throws -> T {
    var lastError: Error?

    for attempt in 1...maxAttempts {
        do {
            return try await network.fetch(request)
        } catch let error as NetworkError where error.isRetryable {
            lastError = error
            let delay = pow(2.0, Double(attempt - 1))  // Exponential backoff
            try await Task.sleep(for: .seconds(delay))
        } catch {
            throw error
        }
    }

    throw lastError ?? NetworkError.requestFailed
}

</error_handling>

```swift // Mock URLProtocol for testing class MockURLProtocol: URLProtocol { static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool {
    true
}

override class func canonicalRequest(for request: URLRequest) -> URLRequest {
    request
}

override func startLoading() {
    guard let handler = MockURLProtocol.requestHandler else {
        fatalError("Handler not set")
    }

    do {
        let (response, data) = try handler(request)
        client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
        client?.urlProtocol(self, didLoad: data)
        client?.urlProtocolDidFinishLoading(self)
    } catch {
        client?.urlProtocol(self, didFailWithError: error)
    }
}

override func stopLoading() {}

}

// Test setup func testFetchProjects() async throws { let config = URLSessionConfiguration.ephemeral config.protocolClasses = [MockURLProtocol.self] let session = URLSession(configuration: config)

MockURLProtocol.requestHandler = { request in
    let response = HTTPURLResponse(
        url: request.url!,
        statusCode: 200,
        httpVersion: nil,
        headerFields: nil
    )!
    let data = try JSONEncoder().encode([Project(name: "Test")])
    return (response, data)
}

let service = NetworkService(session: session)
let projects: [Project] = try await service.fetch(Endpoint.projects().request)

XCTAssertEqual(projects.count, 1)

}

</testing>