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.*
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(
29 val authHeader: String,
30 val allowInsecure: Boolean,
34class ParseableApiClient @Inject constructor() {
36 private val json = Json {
37 ignoreUnknownKeys = true
39 coerceInputValues = true
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) }
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)
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()
68 val sslContext = SSLContext.getInstance("TLS")
69 sslContext.init(null, arrayOf<TrustManager>(trustManager), SecureRandom())
71 .sslSocketFactory(sslContext.socketFactory, trustManager)
72 .hostnameVerifier { _, _ -> true }
75 return builder.build()
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
85 config = ClientConfig(
88 allowInsecure = !serverConfig.useTls,
92 val isConfigured: Boolean get() = config.baseUrl.isNotEmpty() && config.authHeader.isNotEmpty()
95 config = ClientConfig("", "", false)
100 sharedDispatcher.executorService.shutdown()
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)
113 private suspend fun executeRequest(request: Request): ApiResult<String> =
114 withContext(Dispatchers.IO) {
116 client.newCall(request).execute().use { response ->
117 val body = response.body?.string() ?: ""
118 if (response.isSuccessful) {
119 ApiResult.Success(body)
122 message = body.ifEmpty { response.message },
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}")
143 private fun <T> parseResponse(body: String, description: String, parse: () -> T): ApiResult<T> {
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")
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)
162 is ApiResult.Error -> result
167 * GET /api/v1/liveness - used to test connectivity
169 suspend fun checkLiveness(): ApiResult<String> {
170 val request = buildRequest("/api/v1/liveness").get().build()
171 return executeRequest(request)
175 * GET /api/v1/logstream - list all log streams
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 -> {
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
187 val parsed = json.parseToJsonElement(result.data)
188 val arr = (parsed as? JsonArray) ?: throw IllegalArgumentException("Expected JSON array")
189 val streams = arr.map { element ->
191 is JsonPrimitive -> LogStream(name = element.content)
192 is JsonObject -> LogStream(
193 name = element["name"]?.jsonPrimitive?.content ?: ""
195 else -> LogStream(name = element.toString())
198 ApiResult.Success(streams)
199 } catch (e2: Exception) {
200 ApiResult.Error("Failed to parse streams: ${e2.message}")
204 is ApiResult.Error -> result
209 * GET /api/v1/logstream/{stream}/schema
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)
218 is ApiResult.Error -> result
223 * GET /api/v1/logstream/{stream}/stats
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)
232 is ApiResult.Error -> result
237 * GET /api/v1/logstream/{stream}/info
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}")
248 is ApiResult.Error -> result
253 * GET /api/v1/logstream/{stream}/retention
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 -> {
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))
268 is ApiResult.Error -> result
273 * POST /api/v1/query - query logs with SQL
275 suspend fun queryLogs(
279 ): ApiResult<List<JsonObject>> {
280 val requestBody = json.encodeToString(
281 JsonObject.serializer(),
284 put("startTime", startTime)
285 put("endTime", endTime)
289 val request = buildRequest("/api/v1/query")
290 .post(requestBody.toRequestBody("application/json".toMediaType()))
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 }
300 is ApiResult.Error -> result
305 * DELETE /api/v1/logstream/{stream}
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)
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())
326 alertsArray.mapNotNull { alertElement ->
328 json.decodeFromJsonElement<Alert>(alertElement)
329 } catch (e: Exception) {
330 Timber.w(e, "Skipping malformed alert entry")
335 is ApiResult.Error -> result
340 * DELETE /api/v1/alerts/{alertId}
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)
349 * POST /api/v1/alerts
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")
357 return when (val result = executeRequest(request)) {
358 is ApiResult.Success -> parseResponse(result.data, "alert") {
359 json.decodeFromString<Alert>(result.data)
361 is ApiResult.Error -> result
366 * GET /api/v1/user - list users
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 }
377 is ApiResult.Error -> result
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list