git.iko.soy/parseable-android
on c9b724f help sign in
380 lines 15 kB history raw
1package com.parseable.android.data.api
3import timber.log.Timber
4import com.parseable.android.data.model.*
5import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.withContext
7import kotlinx.serialization.SerializationException
8import kotlinx.serialization.json.*
9import okhttp3.*
10import okhttp3.MediaType.Companion.toMediaType
11import okhttp3.RequestBody.Companion.toRequestBody
12import java.io.IOException
13import java.net.ConnectException
14import java.net.SocketTimeoutException
15import java.net.URLEncoder
16import java.net.UnknownHostException
17import java.security.SecureRandom
18import java.security.cert.X509Certificate
19import java.util.concurrent.TimeUnit
20import javax.inject.Inject
21import javax.inject.Singleton
22import javax.net.ssl.SSLContext
23import javax.net.ssl.SSLException
24import javax.net.ssl.TrustManager
25import javax.net.ssl.X509TrustManager
27private data class ClientConfig(
28 val baseUrl: String,
29 val authHeader: String,
30 val allowInsecure: Boolean,
33@Singleton
34class ParseableApiClient @Inject constructor() {
36 private val json = Json {
37 ignoreUnknownKeys = true
38 isLenient = true
39 coerceInputValues = true
40 }
42 private val sharedPool = okhttp3.ConnectionPool(5, 5, TimeUnit.MINUTES)
43 private val sharedDispatcher = okhttp3.Dispatcher()
45 private val secureClient: OkHttpClient = buildClient(allowInsecure = false)
46 private val insecureClient: OkHttpClient by lazy { buildClient(allowInsecure = true) }
48 @Volatile
49 private var config: ClientConfig = ClientConfig("", "", false)
51 private val client: OkHttpClient
52 get() = if (config.allowInsecure) insecureClient else secureClient
54 private fun buildClient(allowInsecure: Boolean): OkHttpClient {
55 val builder = OkHttpClient.Builder()
56 .connectionPool(sharedPool)
57 .dispatcher(sharedDispatcher)
58 .connectTimeout(30, TimeUnit.SECONDS)
59 .readTimeout(60, TimeUnit.SECONDS)
60 .writeTimeout(30, TimeUnit.SECONDS)
62 if (allowInsecure) {
63 val trustManager = object : X509TrustManager {
64 override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
65 override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
66 override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
67 }
68 val sslContext = SSLContext.getInstance("TLS")
69 sslContext.init(null, arrayOf<TrustManager>(trustManager), SecureRandom())
70 builder
71 .sslSocketFactory(sslContext.socketFactory, trustManager)
72 .hostnameVerifier { _, _ -> true }
73 }
75 return builder.build()
76 }
78 fun configure(serverConfig: ServerConfig) {
79 val url = serverConfig.serverUrl.trimEnd('/')
80 val credentials = "${serverConfig.username}:${serverConfig.password}"
81 val auth = "Basic " + android.util.Base64.encodeToString(
82 credentials.toByteArray(),
83 android.util.Base64.NO_WRAP
84 )
85 config = ClientConfig(
86 baseUrl = url,
87 authHeader = auth,
88 allowInsecure = !serverConfig.useTls,
89 )
90 }
92 val isConfigured: Boolean get() = config.baseUrl.isNotEmpty() && config.authHeader.isNotEmpty()
94 fun clearConfig() {
95 config = ClientConfig("", "", false)
96 }
98 fun shutdown() {
99 sharedPool.evictAll()
100 sharedDispatcher.executorService.shutdown()
101 }
103 private fun encodePathSegment(segment: String): String =
104 URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
106 private fun buildRequest(path: String): Request.Builder {
107 val snapshot = config
108 return Request.Builder()
109 .url("${snapshot.baseUrl}$path")
110 .header("Authorization", snapshot.authHeader)
111 }
113 private suspend fun executeRequest(request: Request): ApiResult<String> =
114 withContext(Dispatchers.IO) {
115 try {
116 client.newCall(request).execute().use { response ->
117 val body = response.body?.string() ?: ""
118 if (response.isSuccessful) {
119 ApiResult.Success(body)
120 } else {
121 ApiResult.Error(
122 message = body.ifEmpty { response.message },
123 code = response.code
124 )
125 }
126 }
127 } catch (e: SocketTimeoutException) {
128 ApiResult.Error(message = "Connection timed out")
129 } catch (e: UnknownHostException) {
130 ApiResult.Error(message = "Unable to resolve host \"${e.message}\"")
131 } catch (e: ConnectException) {
132 ApiResult.Error(message = "Connection refused. Is the server running?")
133 } catch (e: SSLException) {
134 ApiResult.Error(message = "SSL error: ${e.message}. Check server certificate.")
135 } catch (e: IOException) {
136 ApiResult.Error(message = e.message ?: "Network error")
137 } catch (e: Exception) {
138 Timber.e(e, "Unexpected error")
139 ApiResult.Error(message = "Unexpected error: ${e.javaClass.simpleName}")
140 }
141 }
143 private fun <T> parseResponse(body: String, description: String, parse: () -> T): ApiResult<T> {
144 return try {
145 ApiResult.Success(parse())
146 } catch (e: SerializationException) {
147 ApiResult.Error("Invalid response format from server")
148 } catch (e: IllegalArgumentException) {
149 ApiResult.Error("Unexpected response format for $description")
150 }
151 }
153 /**
154 * GET /api/v1/about
155 */
156 suspend fun getAbout(): ApiResult<AboutInfo> {
157 val request = buildRequest("/api/v1/about").get().build()
158 return when (val result = executeRequest(request)) {
159 is ApiResult.Success -> parseResponse(result.data, "about info") {
160 json.decodeFromString<AboutInfo>(result.data)
161 }
162 is ApiResult.Error -> result
163 }
164 }
166 /**
167 * GET /api/v1/liveness - used to test connectivity
168 */
169 suspend fun checkLiveness(): ApiResult<String> {
170 val request = buildRequest("/api/v1/liveness").get().build()
171 return executeRequest(request)
172 }
174 /**
175 * GET /api/v1/logstream - list all log streams
176 */
177 suspend fun listStreams(): ApiResult<List<LogStream>> {
178 val request = buildRequest("/api/v1/logstream").get().build()
179 return when (val result = executeRequest(request)) {
180 is ApiResult.Success -> {
181 try {
182 val streams = json.decodeFromString<List<LogStream>>(result.data)
183 ApiResult.Success(streams)
184 } catch (e: Exception) {
185 // Try alternate format: list of objects with "name" key
186 try {
187 val parsed = json.parseToJsonElement(result.data)
188 val arr = (parsed as? JsonArray) ?: throw IllegalArgumentException("Expected JSON array")
189 val streams = arr.map { element ->
190 when (element) {
191 is JsonPrimitive -> LogStream(name = element.content)
192 is JsonObject -> LogStream(
193 name = element["name"]?.jsonPrimitive?.content ?: ""
194 )
195 else -> LogStream(name = element.toString())
196 }
197 }
198 ApiResult.Success(streams)
199 } catch (e2: Exception) {
200 ApiResult.Error("Failed to parse streams: ${e2.message}")
201 }
202 }
203 }
204 is ApiResult.Error -> result
205 }
206 }
208 /**
209 * GET /api/v1/logstream/{stream}/schema
210 */
211 suspend fun getStreamSchema(stream: String): ApiResult<StreamSchema> {
212 val encoded = encodePathSegment(stream)
213 val request = buildRequest("/api/v1/logstream/$encoded/schema").get().build()
214 return when (val result = executeRequest(request)) {
215 is ApiResult.Success -> parseResponse(result.data, "schema") {
216 json.decodeFromString<StreamSchema>(result.data)
217 }
218 is ApiResult.Error -> result
219 }
220 }
222 /**
223 * GET /api/v1/logstream/{stream}/stats
224 */
225 suspend fun getStreamStats(stream: String): ApiResult<StreamStats> {
226 val encoded = encodePathSegment(stream)
227 val request = buildRequest("/api/v1/logstream/$encoded/stats").get().build()
228 return when (val result = executeRequest(request)) {
229 is ApiResult.Success -> parseResponse(result.data, "stats") {
230 json.decodeFromString<StreamStats>(result.data)
231 }
232 is ApiResult.Error -> result
233 }
234 }
236 /**
237 * GET /api/v1/logstream/{stream}/info
238 */
239 suspend fun getStreamInfo(stream: String): ApiResult<JsonObject> {
240 val encoded = encodePathSegment(stream)
241 val request = buildRequest("/api/v1/logstream/$encoded/info").get().build()
242 return when (val result = executeRequest(request)) {
243 is ApiResult.Success -> parseResponse(result.data, "stream info") {
244 val element = json.parseToJsonElement(result.data)
245 (element as? JsonObject)
246 ?: throw IllegalArgumentException("Expected JSON object, got ${element::class.simpleName}")
247 }
248 is ApiResult.Error -> result
249 }
250 }
252 /**
253 * GET /api/v1/logstream/{stream}/retention
254 */
255 suspend fun getStreamRetention(stream: String): ApiResult<List<RetentionConfig>> {
256 val encoded = encodePathSegment(stream)
257 val request = buildRequest("/api/v1/logstream/$encoded/retention").get().build()
258 return when (val result = executeRequest(request)) {
259 is ApiResult.Success -> {
260 try {
261 ApiResult.Success(json.decodeFromString<List<RetentionConfig>>(result.data))
262 } catch (_: Exception) {
263 parseResponse(result.data, "retention config") {
264 listOf(json.decodeFromString<RetentionConfig>(result.data))
265 }
266 }
267 }
268 is ApiResult.Error -> result
269 }
270 }
272 /**
273 * POST /api/v1/query - query logs with SQL
274 */
275 suspend fun queryLogs(
276 query: String,
277 startTime: String,
278 endTime: String,
279 ): ApiResult<List<JsonObject>> {
280 val requestBody = json.encodeToString(
281 JsonObject.serializer(),
282 buildJsonObject {
283 put("query", query)
284 put("startTime", startTime)
285 put("endTime", endTime)
286 }
287 )
289 val request = buildRequest("/api/v1/query")
290 .post(requestBody.toRequestBody("application/json".toMediaType()))
291 .build()
293 return when (val result = executeRequest(request)) {
294 is ApiResult.Success -> parseResponse(result.data, "query results") {
295 val element = json.parseToJsonElement(result.data)
296 val elements = (element as? JsonArray)
297 ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
298 elements.mapNotNull { it as? JsonObject }
299 }
300 is ApiResult.Error -> result
301 }
302 }
304 /**
305 * DELETE /api/v1/logstream/{stream}
306 */
307 suspend fun deleteStream(stream: String): ApiResult<String> {
308 val encoded = encodePathSegment(stream)
309 val request = buildRequest("/api/v1/logstream/$encoded").delete().build()
310 return executeRequest(request)
311 }
313 /**
314 * GET /api/v1/alerts
315 */
316 suspend fun listAlerts(): ApiResult<List<Alert>> {
317 val request = buildRequest("/api/v1/alerts").get().build()
318 return when (val result = executeRequest(request)) {
319 is ApiResult.Success -> parseResponse(result.data, "alerts") {
320 val element = json.parseToJsonElement(result.data)
321 val alertsArray = when (element) {
322 is JsonArray -> element
323 is JsonObject -> element["alerts"]?.jsonArray ?: JsonArray(emptyList())
324 else -> JsonArray(emptyList())
325 }
326 alertsArray.mapNotNull { alertElement ->
327 try {
328 json.decodeFromJsonElement<Alert>(alertElement)
329 } catch (e: Exception) {
330 Timber.w(e, "Skipping malformed alert entry")
331 null
332 }
333 }
334 }
335 is ApiResult.Error -> result
336 }
337 }
339 /**
340 * DELETE /api/v1/alerts/{alertId}
341 */
342 suspend fun deleteAlert(alertId: String): ApiResult<String> {
343 val encoded = encodePathSegment(alertId)
344 val request = buildRequest("/api/v1/alerts/$encoded").delete().build()
345 return executeRequest(request)
346 }
348 /**
349 * POST /api/v1/alerts
350 */
351 suspend fun createAlert(alert: Alert): ApiResult<Alert> {
352 val requestBody = json.encodeToString(Alert.serializer(), alert)
353 .toRequestBody("application/json".toMediaType())
354 val request = buildRequest("/api/v1/alerts")
355 .post(requestBody)
356 .build()
357 return when (val result = executeRequest(request)) {
358 is ApiResult.Success -> parseResponse(result.data, "alert") {
359 json.decodeFromString<Alert>(result.data)
360 }
361 is ApiResult.Error -> result
362 }
363 }
365 /**
366 * GET /api/v1/user - list users
367 */
368 suspend fun listUsers(): ApiResult<List<JsonObject>> {
369 val request = buildRequest("/api/v1/user").get().build()
370 return when (val result = executeRequest(request)) {
371 is ApiResult.Success -> parseResponse(result.data, "users") {
372 val element = json.parseToJsonElement(result.data)
373 val elements = (element as? JsonArray)
374 ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
375 elements.mapNotNull { it as? JsonObject }
376 }
377 is ApiResult.Error -> result
378 }
379 }