import Foundation /// One HTTP call against the backend. Feature code builds one of these and /// hands it to `APIClient.shared.send(...)` — nobody outside this file talks /// to `URLSession` directly, so auth headers and error decoding stay in /// exactly one place. struct APIRequest { enum Method: String { case get = "GET", post = "POST", patch = "PATCH", delete = "DELETE" } var path: String var method: Method = .get var query: [URLQueryItem] = [] var body: Data? var contentType: String = "application/json" /// False only for the couple of endpoints callable while signed out /// (login, signup, forgot-password). Everything else sends the Bearer /// token automatically. var requiresAuth: Bool = true static func json( path: String, method: Method, body: Body, requiresAuth: Bool = true ) throws -> APIRequest { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 return APIRequest( path: path, method: method, body: try encoder.encode(body), requiresAuth: requiresAuth ) } /// Builds a single-file `multipart/form-data` body — used by /// `POST /api/scan`, which reads the file from a `file` form field (see /// `formData.get("file")` in src/app/api/scan/route.ts). Only one file /// field is needed anywhere in this app; if that changes, generalise this /// rather than hand-rolling multipart bodies elsewhere. static func multipartFile( path: String, fieldName: String = "file", fileName: String, mimeType: String, fileData: Data ) -> APIRequest { let boundary = "ScanReceipts-\(UUID().uuidString)" var body = Data() body.append("--\(boundary)\r\n".utf8) body.append("Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(fileName)\"\r\n".utf8) body.append("Content-Type: \(mimeType)\r\n\r\n".utf8) body.append(fileData) body.append("\r\n--\(boundary)--\r\n".utf8) return APIRequest( path: path, method: .post, body: body, contentType: "multipart/form-data; boundary=\(boundary)", requiresAuth: true ) } } /// Posted whenever a request comes back 401. `AppState` observes this and /// signs the user out locally — the server-side session is already gone by /// the time this fires, so there is nothing left to clean up remotely. extension Notification.Name { static let sessionExpired = Notification.Name("ScanReceipts.sessionExpired") } final class APIClient { static let shared = APIClient() private let session: URLSession private let baseURL: URL private let tokenStore: KeychainTokenStore init( baseURL: URL = APIEnvironment.current.baseURL, session: URLSession = .shared, tokenStore: KeychainTokenStore = .shared ) { self.baseURL = baseURL self.session = session self.tokenStore = tokenStore } /// Decodes a JSON response body as `T`. @discardableResult func send(_ request: APIRequest, as type: T.Type = T.self) async throws -> T { let data = try await sendRaw(request) do { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return try decoder.decode(T.self, from: data) } catch { throw APIError.decoding(error) } } /// For endpoints that return a raw file, not JSON — the three /// `/api/export/*` routes, which respond with the file bytes directly and /// the filename in `Content-Disposition` (see ExportAPI.swift). Still /// validates the status and decodes `{ error }` JSON on failure exactly /// like `send`. func sendForFile(_ request: APIRequest) async throws -> (data: Data, suggestedFileName: String?) { let (data, response) = try await sendRawWithResponse(request) let fileName = response.value(forHTTPHeaderField: "Content-Disposition") .flatMap { header -> String? in guard let range = header.range(of: "filename=\"") else { return nil } let rest = header[range.upperBound...] guard let end = rest.firstIndex(of: "\"") else { return nil } return String(rest[rest.startIndex.. Data { try await sendRawWithResponse(apiRequest).data } private func sendRawWithResponse(_ apiRequest: APIRequest) async throws -> (data: Data, response: HTTPURLResponse) { var components = URLComponents(url: baseURL.appendingPathComponent(apiRequest.path), resolvingAgainstBaseURL: false) if !apiRequest.query.isEmpty { components?.queryItems = apiRequest.query } guard let url = components?.url else { throw APIError.unexpectedStatus(-1) } var urlRequest = URLRequest(url: url) urlRequest.httpMethod = apiRequest.method.rawValue // Identifies this as a native client so the backend (a) returns the // raw session token in the login/signup JSON body instead of only a // Set-Cookie, and (b) exempts the request from the browser-only CSRF // double-submit check. See src/lib/auth/config.ts#isMobileClientRequest // and src/lib/auth/csrf.ts#isCsrfExempt on the backend. urlRequest.setValue("ios", forHTTPHeaderField: "X-Client") if let body = apiRequest.body { urlRequest.httpBody = body urlRequest.setValue(apiRequest.contentType, forHTTPHeaderField: "Content-Type") } if apiRequest.requiresAuth { guard let token = tokenStore.token else { throw APIError.unauthorized } urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } let data: Data let response: URLResponse do { (data, response) = try await session.data(for: urlRequest) } catch { throw APIError.network(error) } guard let http = response as? HTTPURLResponse else { throw APIError.unexpectedStatus(-1) } if (200..<300).contains(http.statusCode) { return (data, http) } if http.statusCode == 401 { tokenStore.token = nil NotificationCenter.default.post(name: .sessionExpired, object: nil) throw APIError.unauthorized } if let body = try? JSONDecoder().decode(APIErrorBody.self, from: data) { throw APIError.server(code: body.error, status: http.statusCode, retryAfterSeconds: body.retryAfter) } throw APIError.unexpectedStatus(http.statusCode) } }