| 1 |
package io.oxygenhub.android.api |
| 2 |
|
| 3 |
import kotlinx.coroutines.suspendCancellableCoroutine |
| 4 |
import kotlinx.serialization.json.Json |
| 5 |
import okhttp3.Call |
| 6 |
import okhttp3.Callback |
| 7 |
import okhttp3.MediaType.Companion.toMediaType |
| 8 |
import okhttp3.OkHttpClient |
| 9 |
import okhttp3.Response |
| 10 |
import java.io.IOException |
| 11 |
import java.util.concurrent.TimeUnit |
| 12 |
import kotlin.coroutines.resume |
| 13 |
import kotlin.coroutines.resumeWithException |
| 14 |
|
| 15 |
/** The JSON settings every hub answer is read with. */ |
| 16 |
val HubJson: Json = Json { |
| 17 |
ignoreUnknownKeys = true |
| 18 |
explicitNulls = false |
| 19 |
coerceInputValues = true |
| 20 |
} |
| 21 |
|
| 22 |
internal val JsonMedia = "application/json".toMediaType() |
| 23 |
|
| 24 |
/** One HTTP client for the whole app (connection pool, TLS sessions). */ |
| 25 |
fun hubHttpClient(): OkHttpClient = OkHttpClient.Builder() |
| 26 |
.connectTimeout(15, TimeUnit.SECONDS) |
| 27 |
.readTimeout(60, TimeUnit.SECONDS) |
| 28 |
.build() |
| 29 |
|
| 30 |
/** A hub address as typed, made canonical: `https://host[:port][/path]`, no trailing slash. */ |
| 31 |
fun normalizeHubUrl(input: String): String? { |
| 32 |
var s = input.trim() |
| 33 |
if (s.isEmpty()) return null |
| 34 |
if (!s.contains("://")) s = "https://$s" |
| 35 |
s = s.trimEnd('/') |
| 36 |
val url = runCatching { java.net.URI(s) }.getOrNull() ?: return null |
| 37 |
if (url.scheme?.lowercase() != "https" || url.host.isNullOrBlank()) return null |
| 38 |
if (url.query != null || url.fragment != null) return null |
| 39 |
return s |
| 40 |
} |
| 41 |
|
| 42 |
/** Runs the call without blocking, cancelling it when the coroutine is. */ |
| 43 |
internal suspend fun Call.await(): Response = suspendCancellableCoroutine { cont -> |
| 44 |
cont.invokeOnCancellation { runCatching { cancel() } } |
| 45 |
enqueue(object : Callback { |
| 46 |
override fun onResponse(call: Call, response: Response) { |
| 47 |
cont.resume(response) |
| 48 |
} |
| 49 |
|
| 50 |
override fun onFailure(call: Call, e: IOException) { |
| 51 |
if (!cont.isCancelled) cont.resumeWithException(e) |
| 52 |
} |
| 53 |
}) |
| 54 |
} |
| 55 |
|
| 56 |
/** Why a call to the hub failed. */ |
| 57 |
sealed class HubException(message: String, cause: Throwable? = null) : Exception(message, cause) { |
| 58 |
/** The hub answered with an error; [detail] is its problem document's reason. */ |
| 59 |
class Http(val status: Int, val detail: String) : HubException("HTTP $status: $detail") |
| 60 |
|
| 61 |
/** The hub could not be reached, or the connection broke. */ |
| 62 |
class Network(cause: IOException) : HubException(cause.message ?: "network error", cause) |
| 63 |
|
| 64 |
/** The hub answered with something this app cannot read. */ |
| 65 |
class Unreadable(what: String, cause: Throwable) : HubException("unexpected answer to $what", cause) |
| 66 |
} |
| 67 |
|
| 68 |
/** The reason in an error body: a problem's `detail`, an OAuth `error_description`, or the text itself. */ |
| 69 |
internal fun errorDetail(body: String): String { |
| 70 |
val problem = runCatching { HubJson.decodeFromString<Problem>(body) }.getOrNull() |
| 71 |
if (problem != null && problem.detail.isNotBlank()) return problem.detail |
| 72 |
val oauth = runCatching { HubJson.decodeFromString<OAuthError>(body) }.getOrNull() |
| 73 |
if (oauth != null && oauth.description.isNotBlank()) return oauth.description |
| 74 |
return body.trim().take(200) |
| 75 |
} |
| 76 |
|
| 77 |
/** The scope a token lacked when the hub refused it for that (`this token lacks the repo:write scope`), else null. */ |
| 78 |
val Throwable.lackedScope: String? |
| 79 |
get() = (this as? HubException.Http)?.takeIf { it.status == 403 } |
| 80 |
?.let { Regex("""lacks the (\S+) scope""").find(it.detail)?.groupValues?.get(1) } |
| 81 |
|
| 82 |
/** A sentence for the user, for any failure the API layer throws. */ |
| 83 |
fun Throwable.userMessage(): String = when (this) { |
| 84 |
is HubException.Http -> when (status) { |
| 85 |
401 -> "Your sign-in has ended. Sign in again." |
| 86 |
403 -> "Not allowed: $detail" |
| 87 |
404 -> "Not found: $detail" |
| 88 |
in 500..599 -> "The hub had a problem ($status). Try again in a moment." |
| 89 |
else -> detail.ifBlank { "The hub said HTTP $status." } |
| 90 |
} |
| 91 |
is HubException.Network -> "Can't reach the hub. Check your connection and try again." |
| 92 |
is HubException.Unreadable -> "The hub's answer couldn't be read. The app may need an update." |
| 93 |
else -> message ?: "Something went wrong." |
| 94 |
} |