feat: add iOS support and harden receipt scanning

This commit is contained in:
Timo
2026-08-20 23:48:26 +02:00
parent f5e06c32b0
commit cf1b799e1b
107 changed files with 11755 additions and 122 deletions

View File

@@ -0,0 +1,189 @@
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)
}
}