267 lines
11 KiB
Swift
267 lines
11 KiB
Swift
// API/APIClient.swift
|
|
|
|
import Foundation
|
|
import Combine
|
|
|
|
enum APIError: Error, LocalizedError, Sendable {
|
|
case invalidURL
|
|
case notAuthenticated
|
|
case serverError(String)
|
|
case decodingError(String)
|
|
case networkError(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .invalidURL: return "Invalid URL."
|
|
case .notAuthenticated: return "Session expired. Please log in again."
|
|
case .serverError(let msg): return msg
|
|
case .decodingError(let msg): return "Data error: \(msg)"
|
|
case .networkError(let msg): return "Network error: \(msg)"
|
|
}
|
|
}
|
|
}
|
|
|
|
// File-scope envelope — cannot be nested in a generic function (Swift restriction).
|
|
// T is constrained to Sendable so `data: T?` does not inherit @MainActor isolation.
|
|
private struct _Envelope<T: Decodable & Sendable>: Decodable, Sendable {
|
|
let ok: Bool
|
|
let data: T?
|
|
let error: String?
|
|
|
|
nonisolated init(from decoder: any Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
ok = try c.decode(Bool.self, forKey: .ok)
|
|
data = try? c.decode(T.self, forKey: .data)
|
|
error = try? c.decode(String.self, forKey: .error)
|
|
}
|
|
private enum CodingKeys: String, CodingKey { case ok, data, error }
|
|
}
|
|
|
|
// Free function removed — see refreshAccessToken() which decodes using a
|
|
// local JSONDecoder to avoid Swift 6 actor-isolation errors.
|
|
|
|
// Refresh-only envelope — Sendable so it can cross actor boundaries in Swift 6.
|
|
private struct _RefreshEnvelope: Decodable, Sendable {
|
|
struct Tokens: Decodable, Sendable {
|
|
let accessToken: String
|
|
let refreshToken: String
|
|
}
|
|
let ok: Bool
|
|
let data: Tokens?
|
|
}
|
|
|
|
actor APIClient {
|
|
static let shared = APIClient()
|
|
|
|
private let session: URLSession
|
|
private let decoder: JSONDecoder
|
|
|
|
private init() {
|
|
let config = URLSessionConfiguration.default
|
|
config.timeoutIntervalForRequest = 30
|
|
self.session = URLSession(configuration: config)
|
|
self.decoder = JSONDecoder()
|
|
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
}
|
|
|
|
// ── Generic JSON Request ──────────────────────────────────────────────
|
|
|
|
func request<T: Decodable & Sendable>(
|
|
_ endpoint: String,
|
|
method: String = "GET",
|
|
body: [String: Any]? = nil,
|
|
retrying: Bool = false
|
|
) async throws -> T {
|
|
let url = try buildURL(endpoint)
|
|
var req = buildRequest(url: url, method: method, body: body)
|
|
injectToken(&req)
|
|
|
|
let (data, response) = try await performRequest(req)
|
|
|
|
if shouldRefresh(response, retrying: retrying) {
|
|
let refreshed = await refreshAccessToken()
|
|
if refreshed {
|
|
return try await request(endpoint, method: method, body: body, retrying: true)
|
|
}
|
|
throw APIError.notAuthenticated
|
|
}
|
|
|
|
return try decode(data)
|
|
}
|
|
|
|
func post<T: Decodable & Sendable>(_ endpoint: String, body: [String: Any]) async throws -> T {
|
|
return try await request(endpoint, method: "POST", body: body)
|
|
}
|
|
|
|
// ── Inspection History ────────────────────────────────────────────────
|
|
|
|
func fetchInspectionHistory(
|
|
limit: Int = 50,
|
|
offset: Int = 0,
|
|
facilityId: Int? = nil
|
|
) async throws -> InspectionHistoryResponseData {
|
|
var ep = "/api/v1/inspections?limit=\(limit)&offset=\(offset)&status=completed"
|
|
if let fid = facilityId { ep += "&facility_id=\(fid)" }
|
|
return try await request(ep)
|
|
}
|
|
|
|
// ── Photo Upload ──────────────────────────────────────────────────────
|
|
|
|
func uploadPhoto(localPath: String, entityType: String) async throws -> String {
|
|
let url = try buildURL("/api/v1/photos/upload")
|
|
|
|
guard let imageData = FileManager.default.contents(atPath: localPath) else {
|
|
throw APIError.networkError("Could not read photo: \(localPath)")
|
|
}
|
|
|
|
let boundary = "Boundary-\(UUID().uuidString)"
|
|
let filename = URL(fileURLWithPath: localPath).lastPathComponent
|
|
let ext = (filename as NSString).pathExtension.lowercased()
|
|
let mime = ext == "png" ? "image/png" : "image/jpeg"
|
|
|
|
var body = Data()
|
|
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"entity_type\"\r\n\r\n\(entityType)\r\n".data(using: .utf8)!)
|
|
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\nContent-Type: \(mime)\r\n\r\n".data(using: .utf8)!)
|
|
body.append(imageData)
|
|
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
|
|
|
var req = URLRequest(url: url)
|
|
req.httpMethod = "POST"
|
|
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
|
injectToken(&req)
|
|
req.httpBody = body
|
|
|
|
let (data, response) = try await performRequest(req)
|
|
|
|
if shouldRefresh(response, retrying: false) {
|
|
let refreshed = await refreshAccessToken()
|
|
if refreshed { return try await uploadPhoto(localPath: localPath, entityType: entityType) }
|
|
throw APIError.notAuthenticated
|
|
}
|
|
|
|
struct PhotoResult: Decodable, Sendable { let serverPath: String }
|
|
if let env = try? decoder.decode(_Envelope<PhotoResult>.self, from: data),
|
|
env.ok, let r = env.data { return r.serverPath }
|
|
throw APIError.serverError("Photo upload failed")
|
|
}
|
|
|
|
// ── Submit Inspection ─────────────────────────────────────────────────
|
|
|
|
func submitInspection(_ inspection: LocalInspection) async throws -> Int {
|
|
var body: [String: Any] = [
|
|
"template_id": inspection.templateServerId,
|
|
"facility_id": inspection.facilityServerId,
|
|
"status": "completed",
|
|
"form_data": inspection.formData,
|
|
"mobile_local_id": inspection.localId,
|
|
]
|
|
if let score = inspection.overallScore { body["overall_score"] = score }
|
|
if let areaId = inspection.areaServerId { body["area_id"] = areaId }
|
|
if let parentId = inspection.parentServerId { body["parent_inspection_id"] = parentId }
|
|
if !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes }
|
|
|
|
let fmt = ISO8601DateFormatter()
|
|
body["inspection_date"] = fmt.string(from: inspection.inspectionDate)
|
|
if let c = inspection.completedAt { body["completed_at"] = fmt.string(from: c) }
|
|
|
|
struct R: Decodable, Sendable { let inspectionId: Int; let duplicate: Bool }
|
|
let r: R = try await post("/api/v1/inspections", body: body)
|
|
return r.inspectionId
|
|
}
|
|
|
|
// ── Submit Issue ──────────────────────────────────────────────────────
|
|
|
|
func submitIssue(_ issue: LocalIssue) async throws -> Int {
|
|
var body: [String: Any] = [
|
|
"facility_id": issue.facilityServerId,
|
|
"severity": issue.severity,
|
|
"description": issue.issueDescription,
|
|
"mobile_local_id": issue.localId,
|
|
]
|
|
if let id = issue.inspection?.serverId { body["inspection_id"] = id }
|
|
if let path = issue.photoServerPath { body["photo_path"] = path }
|
|
|
|
struct R: Decodable, Sendable { let issueId: Int; let duplicate: Bool }
|
|
let r: R = try await post("/api/v1/issues", body: body)
|
|
return r.issueId
|
|
}
|
|
|
|
// ── Token Refresh ─────────────────────────────────────────────────────
|
|
|
|
private func refreshAccessToken() async -> Bool {
|
|
guard let token = KeychainHelper.get(Constants.Keychain.refreshToken),
|
|
let url = URL(string: Constants.baseURL + "/api/v1/auth/refresh")
|
|
else { return false }
|
|
|
|
var req = URLRequest(url: url)
|
|
req.httpMethod = "POST"
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
req.httpBody = try? JSONSerialization.data(withJSONObject: ["refresh_token": token])
|
|
|
|
guard let (data, response) = try? await session.data(for: req),
|
|
let http = response as? HTTPURLResponse, http.statusCode == 200
|
|
else { return false }
|
|
|
|
// Use a local decoder — avoids referencing the actor-isolated self.decoder
|
|
// which would trigger a Swift 6 main-actor isolation error.
|
|
let localDecoder = JSONDecoder()
|
|
localDecoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
guard let env = try? localDecoder.decode(_RefreshEnvelope.self, from: data),
|
|
env.ok,
|
|
let tokens = env.data
|
|
else { return false }
|
|
|
|
KeychainHelper.set(tokens.accessToken, forKey: Constants.Keychain.accessToken)
|
|
KeychainHelper.set(tokens.refreshToken, forKey: Constants.Keychain.refreshToken)
|
|
return true
|
|
}
|
|
|
|
// ── Private Helpers ───────────────────────────────────────────────────
|
|
|
|
private func buildURL(_ endpoint: String) throws -> URL {
|
|
guard let url = URL(string: Constants.baseURL + endpoint) else {
|
|
throw APIError.invalidURL
|
|
}
|
|
return url
|
|
}
|
|
|
|
private func buildRequest(url: URL, method: String, body: [String: Any]?) -> URLRequest {
|
|
var req = URLRequest(url: url)
|
|
req.httpMethod = method
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
if let body { req.httpBody = try? JSONSerialization.data(withJSONObject: body) }
|
|
return req
|
|
}
|
|
|
|
private func injectToken(_ req: inout URLRequest) {
|
|
if let token = KeychainHelper.get(Constants.Keychain.accessToken) {
|
|
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
}
|
|
}
|
|
|
|
private func performRequest(_ req: URLRequest) async throws -> (Data, URLResponse) {
|
|
do {
|
|
return try await session.data(for: req)
|
|
} catch {
|
|
throw APIError.networkError(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
private func shouldRefresh(_ response: URLResponse, retrying: Bool) -> Bool {
|
|
guard !retrying, let http = response as? HTTPURLResponse else { return false }
|
|
return http.statusCode == 401
|
|
}
|
|
|
|
private func decode<T: Decodable & Sendable>(_ data: Data) throws -> T {
|
|
if let env = try? decoder.decode(_Envelope<T>.self, from: data) {
|
|
if env.ok, let result = env.data { return result }
|
|
throw APIError.serverError(env.error ?? "Unknown server error")
|
|
}
|
|
do {
|
|
return try decoder.decode(T.self, from: data)
|
|
} catch {
|
|
throw APIError.decodingError(error.localizedDescription)
|
|
}
|
|
}
|
|
}
|