AilianceApiClient.kt
408 lignes · 16708 octets
package fr.ebii.card2vcf.sync import android.util.Log import kotlinx.serialization.builtins.ListSerializer import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.io.IOException import java.net.Proxy import java.util.concurrent.TimeUnit class AilianceApiClient( baseUrl: String, private val apiKey: String? = null, private val client: OkHttpClient = defaultHttpClient(), ) : AilianceApi { private val base = baseUrl.trimEnd('/') private val jsonMediaType = "application/json; charset=utf-8".toMediaType() sealed class ApiResult<out T> { data class Ok<T>(val value: T) : ApiResult<T>() data class Err(val code: Int, val message: String) : ApiResult<Nothing>() } override fun authCle(nom: String, motDePasse: String): ApiResult<AuthCleResponse> { val body = syncJson.encodeToString(AuthCleRequest.serializer(), AuthCleRequest(nom, motDePasse)) return post("/api/auth/cle", body, useBearer = false) { responseBody -> syncJson.decodeFromString(AuthCleResponse.serializer(), responseBody) } } override fun syncStatus(sinceIso: String, ressourcesQuery: String?): ApiResult<SyncStatusResponse> = get("/api/sync/status?since=${encodeQuery(sinceIso)}${ressourcesQueryParam(ressourcesQuery)}") { body -> syncJson.decodeFromString(SyncStatusResponse.serializer(), body) } override fun syncPull(sinceIso: String, ressourcesQuery: String?): ApiResult<SyncPullResponse> = get("/api/sync/pull?since=${encodeQuery(sinceIso)}${ressourcesQueryParam(ressourcesQuery)}") { body -> syncJson.decodeFromString(SyncPullResponse.serializer(), body) } override fun createContact(jsonBody: String): ApiResult<String> = post("/api/contacts", jsonBody) { it } override fun updateContact(id: String, jsonBody: String): ApiResult<String> = put("/api/contacts/${encodePath(id)}", jsonBody) { it } override fun deleteContact(id: String): ApiResult<Unit> = delete("/api/contacts/${encodePath(id)}") override fun createEntreprise(jsonBody: String): ApiResult<String> = post("/api/entreprises", jsonBody) { it } override fun updateEntreprise(id: String, jsonBody: String): ApiResult<String> = put("/api/entreprises/${encodePath(id)}", jsonBody) { it } override fun deleteEntreprise(id: String): ApiResult<Unit> = delete("/api/entreprises/${encodePath(id)}") override fun createProjet(jsonBody: String): ApiResult<String> = post("/api/projets", jsonBody) { it } override fun updateProjet(id: String, jsonBody: String): ApiResult<String> = put("/api/projets/${encodePath(id)}", jsonBody) { it } override fun deleteProjet(id: String): ApiResult<Unit> = delete("/api/projets/${encodePath(id)}") override fun createTache(projetId: String, jsonBody: String): ApiResult<String> = post("/api/projets/${encodePath(projetId)}/taches", jsonBody) { it } override fun updateTache(projetId: String, tacheId: String, jsonBody: String): ApiResult<String> = put("/api/projets/${encodePath(projetId)}/taches/${encodePath(tacheId)}", jsonBody) { it } override fun deleteTache(projetId: String, tacheId: String): ApiResult<Unit> = delete("/api/projets/${encodePath(projetId)}/taches/${encodePath(tacheId)}") override fun moveTache(projetId: String, tacheId: String, jsonBody: String): ApiResult<String> = post("/api/projets/${encodePath(projetId)}/taches/${encodePath(tacheId)}/deplacer", jsonBody) { it } override fun toggleSousTache(projetId: String, tacheId: String, sousTacheId: String): ApiResult<String> = put( "/api/projets/${encodePath(projetId)}/taches/${encodePath(tacheId)}" + "/sous-taches/${encodePath(sousTacheId)}", "", ) { it } override fun createInteraction(contactId: String, jsonBody: String): ApiResult<String> = post("/api/contacts/${encodePath(contactId)}/interactions", jsonBody) { it } override fun deleteInteraction(iid: String): ApiResult<Unit> = delete("/api/interactions/${encodePath(iid)}") override fun relancerTranscriptionInteraction(contactServerId: String, iid: String): ApiResult<Unit> = when (val r = post( "/api/contacts/${encodePath(contactServerId)}/interactions/${encodePath(iid)}/transcription", "{}", ) { it }) { is ApiResult.Ok -> ApiResult.Ok(Unit) is ApiResult.Err -> r } override fun relancerTranscriptionNote(projetServerId: String, nid: String): ApiResult<Unit> = when (val r = post( "/api/projets/${encodePath(projetServerId)}/notes/${encodePath(nid)}/transcription", "{}", ) { it }) { is ApiResult.Ok -> ApiResult.Ok(Unit) is ApiResult.Err -> r } override fun listRdv(): ApiResult<List<RendezVousDto>> = get("/api/rdv") { body -> syncJson.decodeFromString(ListSerializer(RendezVousDto.serializer()), body) } override fun createRdv(jsonBody: String): ApiResult<String> = post("/api/rdv", jsonBody) { it } override fun getRdv(id: String): ApiResult<RendezVousDto> = get("/api/rdv/${encodePath(id)}") { body -> syncJson.decodeFromString(RendezVousDto.serializer(), body) } override fun updateRdv(id: String, jsonBody: String): ApiResult<String> = put("/api/rdv/${encodePath(id)}", jsonBody) { it } override fun deleteRdv(id: String): ApiResult<Unit> = delete("/api/rdv/${encodePath(id)}") override fun getRessourcesCatalogue(): ApiResult<RessourcesCatalogueDto> = get("/api/ressources") { body -> syncJson.decodeFromString(RessourcesCatalogueDto.serializer(), body) } override fun listReservations(genre: String, resourceId: String): ApiResult<List<ReservationDto>> = get("/api/ressources/${encodePath(genre)}/${encodePath(resourceId)}/reservations") { body -> syncJson.decodeFromString(ListSerializer(ReservationDto.serializer()), body) } override fun createReservation(genre: String, resourceId: String, jsonBody: String): ApiResult<String> = post("/api/ressources/${encodePath(genre)}/${encodePath(resourceId)}/reservations", jsonBody) { it } override fun updateReservation( genre: String, resourceId: String, reservationId: String, jsonBody: String, ): ApiResult<String> = put( "/api/ressources/${encodePath(genre)}/${encodePath(resourceId)}/reservations/${encodePath(reservationId)}", jsonBody, ) { it } override fun deleteReservation(genre: String, resourceId: String, reservationId: String): ApiResult<Unit> = delete("/api/ressources/${encodePath(genre)}/${encodePath(resourceId)}/reservations/${encodePath(reservationId)}") override fun listIndisponibilites(genre: String, resourceId: String): ApiResult<List<IndisponibiliteDto>> = get("/api/ressources/${encodePath(genre)}/${encodePath(resourceId)}/indisponibilites") { body -> syncJson.decodeFromString(ListSerializer(IndisponibiliteDto.serializer()), body) } override fun uploadContactCarte( id: String, bytes: ByteArray, filename: String, contentType: String, ): ApiResult<String> = uploadMultipart("/api/contacts/${encodePath(id)}/carte", bytes, filename, contentType) override fun uploadContactPhoto( id: String, bytes: ByteArray, filename: String, contentType: String, ): ApiResult<String> = uploadMultipart("/api/contacts/${encodePath(id)}/photo", bytes, filename, contentType) override fun downloadContactCarte(id: String): ApiResult<ByteArray> = executeBytes(buildRequest("GET", "/api/contacts/${encodePath(id)}/carte", null, useBearer = true)) override fun downloadContactPhoto(id: String): ApiResult<ByteArray> = executeBytes(buildRequest("GET", "/api/contacts/${encodePath(id)}/photo", null, useBearer = true)) override fun uploadInteractionAudio( contactId: String, iid: String, bytes: ByteArray, filename: String, contentType: String, ): ApiResult<String> = uploadMultipart( "/api/contacts/${encodePath(contactId)}/interactions/${encodePath(iid)}/audio", bytes, filename, contentType, ) override fun downloadInteractionAudio(contactId: String, iid: String): ApiResult<ByteArray> = executeBytes( buildRequest( "GET", "/api/contacts/${encodePath(contactId)}/interactions/${encodePath(iid)}/audio", null, useBearer = true, ), ) override fun createNoteProjet(projetId: String, jsonBody: String): ApiResult<String> = post("/api/projets/${encodePath(projetId)}/notes", jsonBody) { it } override fun updateNoteProjet(projetId: String, nid: String, jsonBody: String): ApiResult<String> = put("/api/projets/${encodePath(projetId)}/notes/${encodePath(nid)}", jsonBody) { it } override fun deleteNoteProjet(projetId: String, nid: String): ApiResult<Unit> = delete("/api/projets/${encodePath(projetId)}/notes/${encodePath(nid)}") override fun uploadNoteAudio( projetId: String, nid: String, bytes: ByteArray, filename: String, contentType: String, ): ApiResult<String> = uploadMultipart( "/api/projets/${encodePath(projetId)}/notes/${encodePath(nid)}/audio", bytes, filename, contentType, ) override fun downloadNoteAudio(projetId: String, nid: String): ApiResult<ByteArray> = executeBytes( buildRequest( "GET", "/api/projets/${encodePath(projetId)}/notes/${encodePath(nid)}/audio", null, useBearer = true, ), ) private fun uploadMultipart( path: String, bytes: ByteArray, filename: String, contentType: String, ): ApiResult<String> { val part = MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart( "file", filename, bytes.toRequestBody(contentType.toMediaType()), ) .build() val builder = Request.Builder().url("$base$path").post(part) if (!apiKey.isNullOrBlank()) { builder.header("Authorization", "Bearer $apiKey") } return execute(builder.build()) { it } } private fun executeBytes(request: Request): ApiResult<ByteArray> { return try { client.newCall(request).execute().use { response -> val body = response.body?.bytes() ?: ByteArray(0) if (response.isSuccessful) { ApiResult.Ok(body) } else { val text = body.toString(Charsets.UTF_8) ApiResult.Err(response.code, errorMessage(text, response.message)) } } } catch (e: IOException) { ApiResult.Err(-1, e.message ?: "Erreur réseau") } catch (e: Exception) { ApiResult.Err(-1, e.message ?: "Erreur inattendue") } } private inline fun <T> get(path: String, crossinline parse: (String) -> T): ApiResult<T> = execute(buildRequest("GET", path, null, useBearer = true), parse) private inline fun <T> post( path: String, jsonBody: String, useBearer: Boolean = true, crossinline parse: (String) -> T, ): ApiResult<T> = execute(buildRequest("POST", path, jsonBody, useBearer), parse) private inline fun <T> put( path: String, jsonBody: String, crossinline parse: (String) -> T, ): ApiResult<T> = execute(buildRequest("PUT", path, jsonBody, useBearer = true), parse) private fun delete(path: String): ApiResult<Unit> = when (val result = execute(buildRequest("DELETE", path, null, useBearer = true)) { it }) { is ApiResult.Ok -> ApiResult.Ok(Unit) is ApiResult.Err -> result } private fun buildRequest( method: String, path: String, jsonBody: String?, useBearer: Boolean, ): Request { val builder = Request.Builder().url("$base$path") if (useBearer && !apiKey.isNullOrBlank()) { builder.header("Authorization", "Bearer $apiKey") } when (method) { "GET" -> builder.get() "DELETE" -> builder.delete() "POST" -> builder.post(jsonBody!!.toRequestBody(jsonMediaType)) "PUT" -> builder.put(jsonBody!!.toRequestBody(jsonMediaType)) else -> error("Unsupported method: $method") } return builder.build() } private inline fun <T> execute( request: Request, crossinline parse: (String) -> T, ): ApiResult<T> { return try { client.newCall(request).execute().use { response -> val body = response.body?.string().orEmpty() if (response.isSuccessful) { ApiResult.Ok(parse(body)) } else { ApiResult.Err(response.code, errorMessage(body, response.message)) } } } catch (e: IOException) { val detail = e.message ?: "connexion impossible" val hint = if (detail.contains("refused", ignoreCase = true) || detail.contains("ECONNREFUSED", ignoreCase = true) ) { " (paquet non reçu par le serveur : vérifiez IP Wi‑Fi du PC, pas de proxy téléphone, même Wi‑Fi)" } else { "" } ApiResult.Err(-1, "$detail → ${request.method} ${request.url}$hint") } catch (e: Exception) { ApiResult.Err(-1, e.message ?: "Erreur inattendue") } } private fun errorMessage(body: String, fallback: String): String { if (body.isBlank()) return fallback return try { val err = syncJson.decodeFromString(ErrorBody.serializer(), body) err.message ?: err.error ?: body } catch (_: Exception) { body.trim().ifEmpty { fallback } } } @kotlinx.serialization.Serializable private data class ErrorBody( val message: String? = null, val error: String? = null, ) private fun ressourcesQueryParam(ressourcesQuery: String?): String = if (ressourcesQuery.isNullOrBlank()) "" else "&ressources=${encodeQuery(ressourcesQuery)}" companion object { const val HTTP_LOG_TAG = "Card2vcfHttp" /** * Pas de proxy système : sinon OkHttp peut tenter un proxy Wi‑Fi/VPN qui refuse * le LAN (`ECONNREFUSED`) alors que le navigateur (souvent hors proxy) fonctionne. * Interceptor : traces `logcat -s Card2vcfHttp` pour debug téléphone. */ fun defaultHttpClient(): OkHttpClient = OkHttpClient.Builder() .proxy(Proxy.NO_PROXY) .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .addInterceptor(httpTraceInterceptor()) .build() private fun httpTraceInterceptor(): Interceptor = Interceptor { chain -> val request = chain.request() Log.i(HTTP_LOG_TAG, "→ ${request.method} ${request.url}") try { val response = chain.proceed(request) Log.i(HTTP_LOG_TAG, "← ${response.code} ${request.url}") response } catch (e: Exception) { Log.e(HTTP_LOG_TAG, "✗ ${request.method} ${request.url}: ${e.javaClass.simpleName}: ${e.message}", e) throw e } } private fun encodeQuery(value: String): String = java.net.URLEncoder.encode(value, Charsets.UTF_8.name()) private fun encodePath(segment: String): String = segment.split("/").joinToString("/") { part -> java.net.URLEncoder.encode(part, Charsets.UTF_8.name()) } } }
GitRust