AilianceApiClient.swift
453 lignes · 18543 octets
import Foundation /// URLSession port of the OkHttp `AilianceApiClient`: /// Bearer auth (except `/api/auth/cle`), JSON bodies, multipart uploads, /// typed `ApiResult` error mapping (HTTP code + parsed `{message|error}` body). final class AilianceApiClient: AilianceApi { enum ApiResult<T> { case ok(T) case err(code: Int, message: String) var isOk: Bool { if case .ok = self { return true } return false } var value: T? { if case .ok(let value) = self { return value } return nil } } private let base: String private let apiKey: String? private let session: URLSession init(baseUrl: String, apiKey: String? = nil, session: URLSession = AilianceApiClient.defaultSession()) { var trimmed = baseUrl while trimmed.hasSuffix("/") { trimmed.removeLast() } self.base = trimmed self.apiKey = apiKey self.session = session } /// No system proxy (OkHttp `Proxy.NO_PROXY` equivalent) ; read timeout 30 s. static func defaultSession() -> URLSession { let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = 30 config.connectionProxyDictionary = [:] return URLSession(configuration: config) } // MARK: - AilianceApi func authCle(nom: String, motDePasse: String) async -> ApiResult<AuthCleResponse> { let body = SyncJson.encodeToString(AuthCleRequest(nom: nom, motDePasse: motDePasse)) return await post("/api/auth/cle", body, useBearer: false) { data in try SyncJson.decoder().decode(AuthCleResponse.self, from: data) } } func syncStatus(sinceIso: String, ressourcesQuery: String?) async -> ApiResult<SyncStatusResponse> { await get("/api/sync/status?since=\(Self.encodeQuery(sinceIso))\(ressourcesQueryParam(ressourcesQuery))") { data in try SyncJson.decoder().decode(SyncStatusResponse.self, from: data) } } func syncPull(sinceIso: String, ressourcesQuery: String?) async -> ApiResult<SyncPullResponse> { await get("/api/sync/pull?since=\(Self.encodeQuery(sinceIso))\(ressourcesQueryParam(ressourcesQuery))") { data in try SyncJson.decoder().decode(SyncPullResponse.self, from: data) } } func createContact(jsonBody: String) async -> ApiResult<String> { await post("/api/contacts", jsonBody, parse: Self.text) } func updateContact(id: String, jsonBody: String) async -> ApiResult<String> { await put("/api/contacts/\(Self.encodePath(id))", jsonBody, parse: Self.text) } func deleteContact(id: String) async -> ApiResult<Void> { await delete("/api/contacts/\(Self.encodePath(id))") } func createEntreprise(jsonBody: String) async -> ApiResult<String> { await post("/api/entreprises", jsonBody, parse: Self.text) } func updateEntreprise(id: String, jsonBody: String) async -> ApiResult<String> { await put("/api/entreprises/\(Self.encodePath(id))", jsonBody, parse: Self.text) } func deleteEntreprise(id: String) async -> ApiResult<Void> { await delete("/api/entreprises/\(Self.encodePath(id))") } func createProjet(jsonBody: String) async -> ApiResult<String> { await post("/api/projets", jsonBody, parse: Self.text) } func updateProjet(id: String, jsonBody: String) async -> ApiResult<String> { await put("/api/projets/\(Self.encodePath(id))", jsonBody, parse: Self.text) } func deleteProjet(id: String) async -> ApiResult<Void> { await delete("/api/projets/\(Self.encodePath(id))") } func createTache(projetId: String, jsonBody: String) async -> ApiResult<String> { await post("/api/projets/\(Self.encodePath(projetId))/taches", jsonBody, parse: Self.text) } func updateTache(projetId: String, tacheId: String, jsonBody: String) async -> ApiResult<String> { await put("/api/projets/\(Self.encodePath(projetId))/taches/\(Self.encodePath(tacheId))", jsonBody, parse: Self.text) } func deleteTache(projetId: String, tacheId: String) async -> ApiResult<Void> { await delete("/api/projets/\(Self.encodePath(projetId))/taches/\(Self.encodePath(tacheId))") } func moveTache(projetId: String, tacheId: String, jsonBody: String) async -> ApiResult<String> { await post( "/api/projets/\(Self.encodePath(projetId))/taches/\(Self.encodePath(tacheId))/deplacer", jsonBody, parse: Self.text ) } func toggleSousTache( projetId: String, tacheId: String, sousTacheId: String ) async -> ApiResult<String> { await put( "/api/projets/\(Self.encodePath(projetId))/taches/\(Self.encodePath(tacheId))" + "/sous-taches/\(Self.encodePath(sousTacheId))", "", parse: Self.text ) } func createInteraction(contactId: String, jsonBody: String) async -> ApiResult<String> { await post("/api/contacts/\(Self.encodePath(contactId))/interactions", jsonBody, parse: Self.text) } func listRdv() async -> ApiResult<[RendezVousDto]> { await get("/api/rdv") { data in try SyncJson.decoder().decode([RendezVousDto].self, from: data) } } func createRdv(jsonBody: String) async -> ApiResult<String> { await post("/api/rdv", jsonBody, parse: Self.text) } func getRdv(id: String) async -> ApiResult<RendezVousDto> { await get("/api/rdv/\(Self.encodePath(id))") { data in try SyncJson.decoder().decode(RendezVousDto.self, from: data) } } func updateRdv(id: String, jsonBody: String) async -> ApiResult<String> { await put("/api/rdv/\(Self.encodePath(id))", jsonBody, parse: Self.text) } func deleteRdv(id: String) async -> ApiResult<Void> { await delete("/api/rdv/\(Self.encodePath(id))") } func getRessourcesCatalogue() async -> ApiResult<RessourcesCatalogueDto> { await get("/api/ressources") { data in try SyncJson.decoder().decode(RessourcesCatalogueDto.self, from: data) } } func listReservations(genre: String, resourceId: String) async -> ApiResult<[ReservationDto]> { await get("/api/ressources/\(Self.encodePath(genre))/\(Self.encodePath(resourceId))/reservations") { data in try SyncJson.decoder().decode([ReservationDto].self, from: data) } } func createReservation(genre: String, resourceId: String, jsonBody: String) async -> ApiResult<String> { await post( "/api/ressources/\(Self.encodePath(genre))/\(Self.encodePath(resourceId))/reservations", jsonBody, parse: Self.text ) } func updateReservation( genre: String, resourceId: String, reservationId: String, jsonBody: String ) async -> ApiResult<String> { await put( "/api/ressources/\(Self.encodePath(genre))/\(Self.encodePath(resourceId))/reservations/\(Self.encodePath(reservationId))", jsonBody, parse: Self.text ) } func deleteReservation(genre: String, resourceId: String, reservationId: String) async -> ApiResult<Void> { await delete( "/api/ressources/\(Self.encodePath(genre))/\(Self.encodePath(resourceId))/reservations/\(Self.encodePath(reservationId))" ) } func listIndisponibilites(genre: String, resourceId: String) async -> ApiResult<[IndisponibiliteDto]> { await get("/api/ressources/\(Self.encodePath(genre))/\(Self.encodePath(resourceId))/indisponibilites") { data in try SyncJson.decoder().decode([IndisponibiliteDto].self, from: data) } } func uploadContactCarte( id: String, bytes: Data, filename: String, contentType: String ) async -> ApiResult<String> { await uploadMultipart("/api/contacts/\(Self.encodePath(id))/carte", bytes, filename, contentType) } func uploadContactPhoto( id: String, bytes: Data, filename: String, contentType: String ) async -> ApiResult<String> { await uploadMultipart("/api/contacts/\(Self.encodePath(id))/photo", bytes, filename, contentType) } func downloadContactCarte(id: String) async -> ApiResult<Data> { await executeBytes(buildRequest("GET", "/api/contacts/\(Self.encodePath(id))/carte", nil, useBearer: true)) } func downloadContactPhoto(id: String) async -> ApiResult<Data> { await executeBytes(buildRequest("GET", "/api/contacts/\(Self.encodePath(id))/photo", nil, useBearer: true)) } func uploadInteractionAudio( contactId: String, interactionId: String, bytes: Data, filename: String ) async -> ApiResult<String> { let path = "/api/contacts/\(Self.encodePath(contactId))/interactions/\(Self.encodePath(interactionId))/audio" return await uploadMultipart(path, bytes, filename, "audio/wav") } func downloadInteractionAudio(contactId: String, interactionId: String) async -> ApiResult<Data> { let path = "/api/contacts/\(Self.encodePath(contactId))/interactions/\(Self.encodePath(interactionId))/audio" return await executeBytes(buildRequest("GET", path, nil, useBearer: true)) } func createNoteProjet(projetId: String, jsonBody: String) async -> ApiResult<String> { await post("/api/projets/\(Self.encodePath(projetId))/notes", jsonBody, parse: Self.text) } func uploadNoteProjetAudio( projetId: String, noteId: String, bytes: Data, filename: String ) async -> ApiResult<String> { let path = "/api/projets/\(Self.encodePath(projetId))/notes/\(Self.encodePath(noteId))/audio" return await uploadMultipart(path, bytes, filename, "audio/wav") } func downloadNoteProjetAudio(projetId: String, noteId: String) async -> ApiResult<Data> { let path = "/api/projets/\(Self.encodePath(projetId))/notes/\(Self.encodePath(noteId))/audio" return await executeBytes(buildRequest("GET", path, nil, useBearer: true)) } func deleteInteraction(interactionId: String) async -> ApiResult<Void> { await delete("/api/interactions/\(Self.encodePath(interactionId))") } func deleteNoteProjet(projetId: String, noteId: String) async -> ApiResult<Void> { await delete("/api/projets/\(Self.encodePath(projetId))/notes/\(Self.encodePath(noteId))") } func relancerTranscriptionInteraction(contactId: String, interactionId: String) async -> ApiResult<String> { let path = "/api/contacts/\(Self.encodePath(contactId))/interactions/\(Self.encodePath(interactionId))/transcription" return await post(path, "{}", parse: Self.text) } func relancerTranscriptionNoteProjet(projetId: String, noteId: String) async -> ApiResult<String> { let path = "/api/projets/\(Self.encodePath(projetId))/notes/\(Self.encodePath(noteId))/transcription" return await post(path, "{}", parse: Self.text) } // MARK: - Internals private func uploadMultipart( _ path: String, _ bytes: Data, _ filename: String, _ contentType: String ) async -> ApiResult<String> { let boundary = "Card2vcf-\(UUID().uuidString)" var body = Data() body.append(Data("--\(boundary)\r\n".utf8)) body.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".utf8)) body.append(Data("Content-Type: \(contentType)\r\n\r\n".utf8)) body.append(bytes) body.append(Data("\r\n--\(boundary)--\r\n".utf8)) guard var request = makeRequest(path) else { return .err(code: -1, message: "URL invalide : \(base)\(path)") } request.httpMethod = "POST" request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.httpBody = body if let apiKey, !apiKey.isEmpty { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") } return await execute(request, parse: Self.text) } private func executeBytes(_ request: URLRequest?) async -> ApiResult<Data> { guard let request else { return .err(code: -1, message: "URL invalide") } do { let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { return .err(code: -1, message: "Réponse invalide") } if (200..<300).contains(http.statusCode) { return .ok(data) } let text = String(data: data, encoding: .utf8) ?? "" return .err(code: http.statusCode, message: errorMessage(text, fallback: Self.fallbackMessage(http))) } catch { return .err(code: -1, message: networkErrorMessage(error, request: request)) } } private func get<T>(_ path: String, parse: @escaping (Data) throws -> T) async -> ApiResult<T> { await execute(buildRequest("GET", path, nil, useBearer: true), parse: parse) } private func post<T>( _ path: String, _ jsonBody: String, useBearer: Bool = true, parse: @escaping (Data) throws -> T ) async -> ApiResult<T> { await execute(buildRequest("POST", path, jsonBody, useBearer: useBearer), parse: parse) } private func put<T>(_ path: String, _ jsonBody: String, parse: @escaping (Data) throws -> T) async -> ApiResult<T> { await execute(buildRequest("PUT", path, jsonBody, useBearer: true), parse: parse) } private func delete(_ path: String) async -> ApiResult<Void> { let result = await execute(buildRequest("DELETE", path, nil, useBearer: true), parse: Self.text) switch result { case .ok: return .ok(()) case .err(let code, let message): return .err(code: code, message: message) } } private func makeRequest(_ path: String) -> URLRequest? { guard let url = URL(string: "\(base)\(path)") else { return nil } return URLRequest(url: url) } private func buildRequest(_ method: String, _ path: String, _ jsonBody: String?, useBearer: Bool) -> URLRequest? { guard var request = makeRequest(path) else { return nil } request.httpMethod = method if useBearer, let apiKey, !apiKey.isEmpty { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") } if let jsonBody { request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") request.httpBody = Data(jsonBody.utf8) } return request } private func execute<T>(_ request: URLRequest?, parse: (Data) throws -> T) async -> ApiResult<T> { guard let request else { return .err(code: -1, message: "URL invalide") } do { let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { return .err(code: -1, message: "Réponse invalide") } if (200..<300).contains(http.statusCode) { return .ok(try parse(data)) } let text = String(data: data, encoding: .utf8) ?? "" return .err(code: http.statusCode, message: errorMessage(text, fallback: Self.fallbackMessage(http))) } catch let error as URLError { return .err(code: -1, message: networkErrorMessage(error, request: request)) } catch { return .err(code: -1, message: error.localizedDescription) } } private func networkErrorMessage(_ error: Error, request: URLRequest) -> String { let detail = (error as? URLError)?.localizedDescription ?? error.localizedDescription let refused = detail.lowercased().contains("refused") || (error as? URLError)?.code == .cannotConnectToHost let hint = refused ? " (paquet non reçu par le serveur : vérifiez IP Wi‑Fi du PC, pas de proxy téléphone, même Wi‑Fi)" : "" let method = request.httpMethod ?? "GET" let url = request.url?.absoluteString ?? "" return "\(detail) → \(method) \(url)\(hint)" } private func errorMessage(_ body: String, fallback: String) -> String { if body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return fallback } if let err = try? SyncJson.decode(ErrorBody.self, from: body) { return err.message ?? err.error ?? body } let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? fallback : trimmed } private struct ErrorBody: Codable { var message: String? = nil var error: String? = nil init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) message = try c.decodeIfPresent(String.self, forKey: .message) error = try c.decodeIfPresent(String.self, forKey: .error) } } private func ressourcesQueryParam(_ ressourcesQuery: String?) -> String { guard let ressourcesQuery, !ressourcesQuery.isEmpty else { return "" } return "&ressources=\(Self.encodeQuery(ressourcesQuery))" } private static func text(_ data: Data) -> String { String(data: data, encoding: .utf8) ?? "" } private static func fallbackMessage(_ response: HTTPURLResponse) -> String { HTTPURLResponse.localizedString(forStatusCode: response.statusCode) } /// `java.net.URLEncoder` charset: letters/digits and `-._*` unescaped. private static let urlEncoderAllowed: CharacterSet = { var set = CharacterSet.alphanumerics set.insert(charactersIn: "-._*") return set }() static func encodeQuery(_ value: String) -> String { value.addingPercentEncoding(withAllowedCharacters: urlEncoderAllowed) ?? value } static func encodePath(_ segment: String) -> String { segment.split(separator: "/", omittingEmptySubsequences: false) .map { String($0).addingPercentEncoding(withAllowedCharacters: urlEncoderAllowed) ?? String($0) } .joined(separator: "/") } }
GitRust