Coverage / app/src/main/java/io/oxygenhub/android/api/Http.kt 87.5%
42 of 48 lines run, 18 of 20 functions, 59 of 80 branches · run #1647 on main · run missed
| 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 | } |