OXYGENHUB
# 23279b95b5b2 oxygenhub-android / app / src / main / java / io / oxygenhub / android / api / Http.kt
4.0 KiB · last changed Land proposals from the phone (closes #17) 10 days ago source blame history raw
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
}