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())
            }
    }
}