Initial commit
This commit is contained in:
549
skills/expertise/macos-apps/references/networking.md
Normal file
549
skills/expertise/macos-apps/references/networking.md
Normal file
@@ -0,0 +1,549 @@
|
||||
# Networking
|
||||
|
||||
URLSession patterns for API calls, authentication, caching, and offline support.
|
||||
|
||||
<basic_requests>
|
||||
<async_await>
|
||||
```swift
|
||||
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>
|
||||
```swift
|
||||
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>
|
||||
|
||||
<authentication>
|
||||
<bearer_token>
|
||||
```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>
|
||||
</authentication>
|
||||
|
||||
<caching>
|
||||
<urlcache>
|
||||
```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>
|
||||
</caching>
|
||||
|
||||
<offline_support>
|
||||
```swift
|
||||
@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>
|
||||
```swift
|
||||
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>
|
||||
```swift
|
||||
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>
|
||||
```swift
|
||||
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>
|
||||
|
||||
<testing>
|
||||
```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>
|
||||
Reference in New Issue
Block a user