The weather call in the beginner course was fine for a demo. A real app calls dozens of endpoints, signs requests with a token that expires, survives bad connections, and must keep that token somewhere safe. If each screen builds its own request, the code becomes copy and paste, and bugs multiply. This lesson builds the layer that sits between screens and the network, and then the safe storage and sign-in pieces around it.
Describe endpoints as values
Instead of building URLs inside each view model, describe each call in one place, as a small type. Then one client sends them all.
import Foundation
enum HTTPMethod: String {
case get = "GET", post = "POST", put = "PUT", delete = "DELETE"
}
protocol Endpoint {
var path: String { get }
var method: HTTPMethod { get }
var queryItems: [URLQueryItem] { get }
var body: Data? { get }
}
extension Endpoint {
var method: HTTPMethod { .get }
var queryItems: [URLQueryItem] { [] }
var body: Data? { nil }
}
struct ListExpenses: Endpoint {
let path = "/v1/expenses"
let queryItems: [URLQueryItem]
init(month: String) { queryItems = [URLQueryItem(name: "month", value: month)] }
}
The protocol extension from lesson 1 supplies defaults, so a simple endpoint needs only a path. Adding an endpoint is now a few lines in one file, and a view model never sees a URL.
One client for all calls
enum NetworkError: Error {
case badURL
case badStatus(Int)
case unauthorized
}
struct APIClient {
let baseURL: URL
let session: URLSession
let auth: AuthTokenProviding
func send<T: Decodable>(_ endpoint: some Endpoint, as type: T.Type = T.self) async throws -> T {
var attempt = 0
while true {
do {
return try await perform(endpoint)
} catch let error as NetworkError where error.isRetryable && attempt < 2 {
attempt += 1
try await Task.sleep(for: .seconds(pow(2, Double(attempt)))) // 2 s, then 4 s
}
}
}
private func perform<T: Decodable>(_ endpoint: some Endpoint) async throws -> T {
guard var parts = URLComponents(url: baseURL.appendingPathComponent(endpoint.path),
resolvingAgainstBaseURL: false) else { throw NetworkError.badURL }
if !endpoint.queryItems.isEmpty { parts.queryItems = endpoint.queryItems }
guard let url = parts.url else { throw NetworkError.badURL }
var request = URLRequest(url: url, timeoutInterval: 20)
request.httpMethod = endpoint.method.rawValue
request.httpBody = endpoint.body
let token = try await auth.validToken()
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw NetworkError.badStatus(-1) }
switch http.statusCode {
case 200..<300: return try JSONDecoder().decode(T.self, from: data)
case 401: throw NetworkError.unauthorized
default: throw NetworkError.badStatus(http.statusCode)
}
}
}
extension NetworkError {
var isRetryable: Bool {
if case .badStatus(let code) = self { return code >= 500 }
return false
}
}
- Retry only what can succeed on a second try. Server errors (5xx) and timeouts may. A 400 or 404 will fail again, so do not retry them.
- Back off. Wait longer after each failure, here 2 s and then 4 s. Hammering a struggling server makes it worse.
- Never retry a request that changes data (a payment, a purchase) unless the server supports an *idempotency key*, so a repeat cannot charge twice.
- Set a timeout so a slow connection ends in an error and not an endless spinner.
Refreshing the token once, not ten times
An access token expires, and a refresh token gets a new one. If ten requests start at once with an expired token, you do not want ten refresh calls. An actor is the right tool: it holds one shared refresh task, and every caller waits for the same one.
protocol AuthTokenProviding {
func validToken() async throws -> String
}
actor TokenStore: AuthTokenProviding {
private var accessToken: String?
private var expiry: Date = .distantPast
private var refreshTask: Task<String, Error>?
func validToken() async throws -> String {
if let accessToken, expiry > Date().addingTimeInterval(30) { return accessToken }
if let refreshTask { return try await refreshTask.value }
let task = Task { try await self.refresh() }
refreshTask = task
defer { refreshTask = nil }
return try await task.value
}
private func refresh() async throws -> String {
// call your server with the refresh token from the Keychain, then:
let newToken = "new-access-token"
accessToken = newToken
expiry = Date().addingTimeInterval(15 * 60)
return newToken
}
}
The Keychain: where secrets live
Never keep tokens, passwords or keys in UserDefaults, in a plain file or in the code. Those are easy to read from a backup or a device that has been opened. The Keychain is the system's encrypted store for small secrets, and it is the correct place. Its API is old and written in C, so most teams wrap it in a small type, as below.
import Foundation
import Security
enum KeychainError: Error { case failed(OSStatus) }
enum Keychain {
private static func baseQuery(_ account: String) -> [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.expensetracker",
kSecAttrAccount as String: account]
}
static func save(_ data: Data, account: String) throws {
SecItemDelete(baseQuery(account) as CFDictionary)
var query = baseQuery(account)
query[kSecValueData as String] = data
query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.failed(status) }
}
static func load(account: String) throws -> Data? {
var query = baseQuery(account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess else { throw KeychainError.failed(status) }
return result as? Data
}
static func delete(account: String) {
SecItemDelete(baseQuery(account) as CFDictionary)
}
}
Replace the service name with your own bundle identifier. The setting WhenUnlockedThisDeviceOnly means that the item is readable only while the phone is unlocked and is not copied to other devices through a backup. Log out by deleting the item.
Face ID and Touch ID
The LocalAuthentication framework asks the system to check the person's face or fingerprint. You never see the biometric data. You only learn whether it worked. You must add a short Face ID usage description to your app's Info settings, or the app will be stopped when it asks.
import LocalAuthentication
func unlockWithBiometrics() async -> Bool {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { return false }
do {
return try await context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: "Unlock your expenses")
} catch {
return false
}
}
.deviceOwnerAuthentication lets the person fall back to the device passcode if the face or finger does not work. That is kinder and safer than blocking them.
Sign in with Apple, and other rules
- Sign in with Apple lets people create an account without sharing their email address. SwiftUI has a ready-made
SignInWithAppleButton, and it needs the *Sign in with Apple* capability in your target. Apple's App Review Guidelines may require you to offer it if your app offers other third-party sign-in options. Read the current guideline before you submit. - Account deletion: Apple requires apps that let people create an account to also let them delete it from inside the app. Check the current rules and plan this early.
- HTTPS only. App Transport Security blocks plain
http. Avoid turning it off. - Certificate pinning (only trusting your own server's certificate) can add safety for banking-like apps, but it also breaks when you change certificates. Use it only if you can manage that.
- Do not log secrets. Never print tokens or passwords, even in debug builds.
Common mistakes
- Tokens in
UserDefaults. - Retrying a payment request automatically.
- Ten parallel refresh calls, because the refresh is not shared.
- Showing the user a raw error such as
badStatus(500). Map errors to messages a person can act on.
Your task
- Make
Endpoint, three endpoints of your own and theAPIClient. Point it at any test server you have or at a local mock. - Write the
TokenStoreactor and test it by starting five requests at once. Print how many timesrefresh()ran. It should be once. - Store a fake token in the Keychain, read it back, then delete it.
- Add a biometric unlock before a *settings* screen, and add the Face ID usage description.
How to check it worked
- A 503 response is retried twice with a delay, and a 404 is not retried.
- Five simultaneous calls cause one refresh.
- The token survives an app restart and is gone after logout.