15 KiB
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 = .returnCacheDataElseLoadlet 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>