05/02 Phase B
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// API/APIModels.swift
|
||||
// -------------------
|
||||
// Codable structs that map to the JSON responses from the JQC Flask API.
|
||||
// All API responses use the envelope: { "ok": bool, "data": {...}, "error": string? }
|
||||
|
||||
import Foundation
|
||||
|
||||
// ── API Envelope ──────────────────────────────────────────────────────────────
|
||||
|
||||
struct APIResponse<T: Decodable>: Decodable {
|
||||
let ok: Bool
|
||||
let data: T?
|
||||
let error: String?
|
||||
}
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
struct LoginResponseData: Decodable {
|
||||
let accessToken: String
|
||||
let refreshToken: String
|
||||
let tokenType: String
|
||||
let expiresIn: Int
|
||||
let user: APIUser
|
||||
}
|
||||
|
||||
struct RefreshResponseData: Decodable {
|
||||
let accessToken: String
|
||||
let refreshToken: String
|
||||
let tokenType: String
|
||||
let expiresIn: Int
|
||||
}
|
||||
|
||||
struct MeResponseData: Decodable {
|
||||
let user: APIUser
|
||||
}
|
||||
|
||||
struct APIUser: Decodable {
|
||||
let id: Int
|
||||
let username: String
|
||||
let email: String
|
||||
let role: String
|
||||
let createdAt: String?
|
||||
|
||||
// Computed display name: full name if present, otherwise username.
|
||||
// The server's /auth/me endpoint returns username; display_name would
|
||||
// require a separate field. For Phase A we use username as the display name
|
||||
// and can extend later.
|
||||
var displayName: String { username }
|
||||
}
|
||||
|
||||
// ── Facilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
struct FacilitiesResponseData: Decodable {
|
||||
let facilities: [APIFacility]
|
||||
let count: Int
|
||||
}
|
||||
|
||||
struct AreasResponseData: Decodable {
|
||||
let facilityId: Int
|
||||
let areas: [APIArea]
|
||||
let count: Int
|
||||
}
|
||||
|
||||
struct APIFacility: Decodable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let address: String
|
||||
let contactPerson: String
|
||||
let contactPhone: String
|
||||
let projectId: Int?
|
||||
let projectName: String?
|
||||
let isActive: Bool
|
||||
}
|
||||
|
||||
struct APIArea: Decodable, Identifiable {
|
||||
let id: Int
|
||||
let facilityId: Int
|
||||
let name: String
|
||||
let areaType: String
|
||||
}
|
||||
|
||||
// ── Templates ─────────────────────────────────────────────────────────────────
|
||||
|
||||
struct TemplatesResponseData: Decodable {
|
||||
let templates: [APITemplateSummary]
|
||||
let count: Int
|
||||
}
|
||||
|
||||
struct TemplateDetailResponseData: Decodable {
|
||||
let template: APITemplate
|
||||
}
|
||||
|
||||
struct APITemplateSummary: Decodable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let description: String
|
||||
let frequency: String
|
||||
}
|
||||
|
||||
struct APITemplate: Decodable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let description: String
|
||||
let frequency: String
|
||||
let formSchema: [[String: AnyDecodable]] // Dynamic JSON fields
|
||||
}
|
||||
|
||||
// ── AnyDecodable helper ───────────────────────────────────────────────────────
|
||||
// Allows decoding JSON values of unknown type (String, Int, Bool, Array, Dict)
|
||||
|
||||
struct AnyDecodable: Decodable {
|
||||
let value: Any
|
||||
|
||||
init(_ value: Any) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let bool = try? container.decode(Bool.self) { value = bool; return }
|
||||
if let int = try? container.decode(Int.self) { value = int; return }
|
||||
if let dbl = try? container.decode(Double.self) { value = dbl; return }
|
||||
if let str = try? container.decode(String.self) { value = str; return }
|
||||
if let arr = try? container.decode([AnyDecodable].self) {
|
||||
value = arr.map(\.value); return
|
||||
}
|
||||
if let dict = try? container.decode([String: AnyDecodable].self) {
|
||||
value = dict.mapValues(\.value); return
|
||||
}
|
||||
value = NSNull()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user