Files
JQC_iOS_App/JanitorialQC/API/APIClient.swift
T
2026-05-02 11:46:28 -04:00

272 lines
10 KiB
Swift

// API/APIClient.swift
// -------------------
// Central HTTP client for all JQC API calls.
// Phase B adds: uploadPhoto(), submitInspection(), submitIssue()
import Foundation
import Combine
enum APIError: Error, LocalizedError {
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)"
}
}
}
actor APIClient {
static let shared = APIClient()
private let baseURL: String
private let session: URLSession
private init() {
self.baseURL = Constants.baseURL
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
self.session = URLSession(configuration: config)
}
// ── Generic JSON Request ──────────────────────────────────────────────
func request<T: Decodable>(
_ endpoint: String,
method: String = "GET",
body: [String: Any]? = nil,
retrying: Bool = false
) async throws -> T {
guard let url = URL(string: baseURL + endpoint) else {
throw APIError.invalidURL
}
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token = KeychainHelper.get(Constants.Keychain.accessToken) {
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body {
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
}
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw APIError.networkError("Invalid response")
}
if http.statusCode == 401 && !retrying {
let refreshed = await refreshAccessToken()
if refreshed {
return try await request(endpoint, method: method, body: body, retrying: true)
} else {
throw APIError.notAuthenticated
}
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
if let envelope = try? decoder.decode(APIResponse<T>.self, from: data) {
if envelope.ok, let result = envelope.data {
return result
} else {
throw APIError.serverError(envelope.error ?? "Unknown server error")
}
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw APIError.decodingError(error.localizedDescription)
}
}
func post<T: Decodable>(_ endpoint: String, body: [String: Any]) async throws -> T {
return try await request(endpoint, method: "POST", body: body)
}
// ── Photo Upload (multipart/form-data) ────────────────────────────────
/// Upload a local photo file to the server.
/// Returns the server_path string on success.
func uploadPhoto(localPath: String, entityType: String) async throws -> String {
guard let url = URL(string: baseURL + "/api/v1/photos/upload") else {
throw APIError.invalidURL
}
guard let imageData = FileManager.default.contents(atPath: localPath) else {
throw APIError.networkError("Could not read photo file: \(localPath)")
}
let boundary = "Boundary-\(UUID().uuidString)"
var body = Data()
// -- entity_type field
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"entity_type\"\r\n\r\n".data(using: .utf8)!)
body.append("\(entityType)\r\n".data(using: .utf8)!)
// -- file field
let filename = URL(fileURLWithPath: localPath).lastPathComponent
let ext = (filename as NSString).pathExtension.lowercased()
let mimeType = ext == "png" ? "image/png" : "image/jpeg"
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
body.append(imageData)
body.append("\r\n".data(using: .utf8)!)
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("multipart/form-data; boundary=\(boundary)",
forHTTPHeaderField: "Content-Type")
if let token = KeychainHelper.get(Constants.Keychain.accessToken) {
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
req.httpBody = body
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw APIError.networkError("Invalid response")
}
if http.statusCode == 401 {
let refreshed = await refreshAccessToken()
if refreshed {
return try await uploadPhoto(localPath: localPath, entityType: entityType)
} else {
throw APIError.notAuthenticated
}
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
struct PhotoUploadData: Decodable { let serverPath: String }
if let envelope = try? decoder.decode(APIResponse<PhotoUploadData>.self, from: data),
envelope.ok,
let result = envelope.data {
return result.serverPath
}
throw APIError.serverError("Photo upload failed")
}
// ── Submit Inspection ─────────────────────────────────────────────────
/// Submit a completed LocalInspection to the server.
/// Returns the server-assigned inspection ID.
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,
"overall_score": inspection.overallScore as Any,
]
if let areaId = inspection.areaServerId {
body["area_id"] = areaId
}
if !inspection.inspectorNotes.isEmpty {
body["notes"] = inspection.inspectorNotes
}
let formatter = ISO8601DateFormatter()
body["inspection_date"] = formatter.string(from: inspection.inspectionDate)
if let completedAt = inspection.completedAt {
body["completed_at"] = formatter.string(from: completedAt)
}
struct InspectionResponseData: Decodable {
let inspectionId: Int
let duplicate: Bool
}
let result: InspectionResponseData = try await post("/api/v1/inspections", body: body)
return result.inspectionId
}
// ── Submit Issue ──────────────────────────────────────────────────────
/// Submit a LocalIssue to the server.
/// Returns the server-assigned issue ID.
func submitIssue(_ issue: LocalIssue) async throws -> Int {
var body: [String: Any] = [
"area_id": issue.areaServerId,
"severity": issue.severity,
"description": issue.issueDescription,
"mobile_local_id": issue.localId,
]
// Link to server inspection if already synced
// (inspection must be synced before its issues)
if let inspServerId = (issue.inspection?.serverId) {
body["inspection_id"] = inspServerId
}
if let serverPhotoPath = issue.photoServerPath {
body["photo_path"] = serverPhotoPath
}
struct IssueResponseData: Decodable {
let issueId: Int
let duplicate: Bool
}
let result: IssueResponseData = try await post("/api/v1/issues", body: body)
return result.issueId
}
// ── Token Refresh ─────────────────────────────────────────────────────
private func refreshAccessToken() async -> Bool {
guard let refreshToken = KeychainHelper.get(Constants.Keychain.refreshToken),
let url = URL(string: 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": refreshToken]
)
guard let (data, response) = try? await session.data(for: req),
let http = response as? HTTPURLResponse,
http.statusCode == 200
else { return false }
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
guard let envelope = try? decoder.decode(APIResponse<RefreshResponseData>.self, from: data),
envelope.ok,
let refreshData = envelope.data
else { return false }
KeychainHelper.set(refreshData.accessToken, forKey: Constants.Keychain.accessToken)
KeychainHelper.set(refreshData.refreshToken, forKey: Constants.Keychain.refreshToken)
return true
}
}