190 lines
7.1 KiB
Swift
190 lines
7.1 KiB
Swift
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<Body: Encodable>(
|
|
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<T: Decodable>(_ 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..<end])
|
|
}
|
|
return (data, fileName)
|
|
}
|
|
|
|
/// For endpoints whose success body carries nothing the caller needs
|
|
/// (e.g. `DELETE /api/receipts`) — still validates the status code and
|
|
/// decodes `{ error }` on failure.
|
|
func sendDiscardingResponse(_ request: APIRequest) async throws {
|
|
_ = try await sendRaw(request)
|
|
}
|
|
|
|
private func sendRaw(_ apiRequest: APIRequest) async throws -> 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)
|
|
}
|
|
}
|