package com.example.slflo import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper import java.io.IOException import java.net.URI import java.net.URLEncoder import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse import java.nio.charset.StandardCharsets import java.time.Duration /** What the connector keeps in its settings: base URL, the token (stored encrypted), page size, retry policy. */ data class ConnectorSettings( val baseUrl: String = "https://api.slflo.com/api/integration/v1", val token: String, val perPage: Int = 100, /** Attempts for one call on 429 / 5xx / a lost answer before the job gives up on it. */ val maxAttempts: Int = 6, /** First back-off after a 5xx; doubled each time. */ val firstBackOff: Duration = Duration.ofSeconds(1), ) /** 401: the token is wrong, expired or revoked, or the company is suspended. Stop the job and alert. */ class TokenRefused(val code: String) : RuntimeException("The integration token was refused ($code); job stopped") /** Any other refusal (4xx), to report per record, or a call that kept failing (5xx, 429). */ class ApiRefused(val status: Int, val code: String, val error: JsonNode?) : RuntimeException("Call refused: $status $code") /** * The API client with the policy every connector needs: stop on 401, wait `Retry-After` on * 429, back off on 5xx and on a lost answer, and send an `Idempotency-Key` on every write so * a retry is always safe. */ class SlfloClient( private var settings: ConnectorSettings, private val http: HttpClient = HttpClient.newHttpClient(), private val sleep: (Duration) -> Unit = { Thread.sleep(it.toMillis()) }, ) { private val json = ObjectMapper() val perPage: Int get() = settings.perPage /** A rotated token, put in the settings by the administrator. */ fun useToken(token: String) { settings = settings.copy(token = token) } fun get(path: String, query: Map = emptyMap()): JsonNode = call("GET", path, query, null, null) fun post(path: String, body: Any, idempotencyKey: String): JsonNode = call("POST", path, emptyMap(), body, idempotencyKey) fun put(path: String, body: Any, idempotencyKey: String, query: Map = emptyMap()): JsonNode = call("PUT", path, query, body, idempotencyKey) private fun call(method: String, path: String, query: Map, body: Any?, key: String?): JsonNode { val request = HttpRequest.newBuilder(URI.create(url(path, query))) .timeout(Duration.ofSeconds(TIMEOUT_SECONDS)) .header("Authorization", "Bearer ${settings.token}") .header("Accept", "application/json") .apply { if (body != null) header("Content-Type", "application/json") if (key != null) header("Idempotency-Key", key) } .method(method, body?.let { HttpRequest.BodyPublishers.ofString(json.writeValueAsString(it)) } ?: HttpRequest.BodyPublishers.noBody()) .build() var backOff = settings.firstBackOff repeat(settings.maxAttempts) { attempt -> val last = attempt == settings.maxAttempts - 1 val response = try { http.send(request, HttpResponse.BodyHandlers.ofString()) } catch (lost: IOException) { // The answer was lost: the retry carries the same Idempotency-Key, so it can't apply twice. if (last) throw lost sleep(backOff) backOff = backOff.multipliedBy(2) return@repeat } val status = response.statusCode() val code = errorOf(response.body())?.path("code")?.asText().orEmpty() val retryAfter = response.headers().firstValue("Retry-After").orElse(null)?.trim()?.toLongOrNull()?.let(Duration::ofSeconds) when { status in OK -> return json.readTree(response.body()) status == UNAUTHORIZED -> throw TokenRefused(code) status == TOO_MANY && !last -> sleep(retryAfter ?: backOff) (status >= SERVER_ERROR || code == IN_PROGRESS) && !last -> { sleep(retryAfter ?: backOff) backOff = backOff.multipliedBy(2) } else -> throw ApiRefused(status, code, errorOf(response.body())) } } error("unreachable") } private fun errorOf(body: String): JsonNode? = runCatching { json.readTree(body).path("error") }.getOrNull() /** Brackets percent-encoded: `java.net.URI` refuses them raw, and the API takes both. */ private fun url(path: String, query: Map): String { val base = settings.baseUrl.trimEnd('/') + path if (query.isEmpty()) return base return base + "?" + query.entries.joinToString("&") { (name, value) -> encode(name) + "=" + encode(value.toString()) } } private fun encode(text: String) = URLEncoder.encode(text, StandardCharsets.UTF_8) private companion object { val OK = 200..299 const val UNAUTHORIZED = 401 const val TOO_MANY = 429 const val SERVER_ERROR = 500 const val IN_PROGRESS = "REQUEST_IN_PROGRESS" const val TIMEOUT_SECONDS = 30L } }