OXYGENHUB
# 3f3508bd5f16 oxygenhub-android / app / src / main / java / io / oxygenhub / android / api / HubClient.kt
4.8 KiB · last changed The hub's API and device sign-in, the token in the Keystore (refs #2) 11 days ago source blame history raw
1
package io.oxygenhub.android.api
2
3
import kotlinx.serialization.KSerializer
4
import kotlinx.serialization.builtins.ListSerializer
5
import okhttp3.HttpUrl
6
import okhttp3.HttpUrl.Companion.toHttpUrl
7
import okhttp3.OkHttpClient
8
import okhttp3.Request
9
import okhttp3.RequestBody.Companion.toRequestBody
10
import java.io.IOException
11
12
/**
13
 * What the app asks of a hub, signed in. The view models see only this, so
14
 * their tests give them a fake. Later screens (oxygenhub-android#3–#5) add
15
 * to it: a repository's tree, history, proposals, runs.
16
 */
17
interface HubApi {
18
    /** The signed-in account (`GET /user`, scope `user:read`). */
19
    suspend fun user(): User
20
21
    /** Every repository the caller can read (`GET /repos`). */
22
    suspend fun repositories(): List<Repository>
23
24
    /** A repository's issues (`GET /repos/{owner}/{name}/issues?state=`). */
25
    suspend fun issues(owner: String, name: String, state: IssueState = IssueState.Open): List<Issue>
26
27
    /** The inbox: the unread count and the newest entries (`GET /notifications`). */
28
    suspend fun inbox(limit: Int = 50): Inbox
29
30
    /** Marks one notification read (`POST /notifications/{id}/read`). */
31
    suspend fun markRead(id: Long)
32
33
    /** Marks the whole inbox read (`POST /notifications/read`). */
34
    suspend fun markAllRead()
35
36
    /** Revokes the token this client signs in with (`DELETE /user/token`). */
37
    suspend fun revokeToken()
38
}
39
40
/**
41
 * The hub's JSON API over OkHttp. [onUnauthorized] runs when the hub
42
 * refuses the token (revoked on the web, or 90 days unused), so the app
43
 * can drop the session and ask for a new sign-in.
44
 */
45
class HubClient(
46
    hub: String,
47
    private val token: String,
48
    private val http: OkHttpClient,
49
    private val onUnauthorized: () -> Unit = {},
50
) : HubApi {
51
    private val base: HttpUrl = "${hub.trimEnd('/')}/api/v1/".toHttpUrl()
52
53
    override suspend fun user(): User = get("user", User.serializer())
54
55
    override suspend fun repositories(): List<Repository> = get("repos", ListSerializer(Repository.serializer()))
56
57
    override suspend fun issues(owner: String, name: String, state: IssueState): List<Issue> =
58
        get(url("repos", owner, name, "issues") { addQueryParameter("state", state.query) }, ListSerializer(Issue.serializer()))
59
60
    override suspend fun inbox(limit: Int): Inbox =
61
        get(url("notifications") { addQueryParameter("limit", limit.coerceIn(1, 200).toString()) }, Inbox.serializer())
62
63
    override suspend fun markRead(id: Long) {
64
        send("POST", url("notifications", id.toString(), "read"))
65
    }
66
67
    override suspend fun markAllRead() {
68
        send("POST", url("notifications", "read"))
69
    }
70
71
    override suspend fun revokeToken() {
72
        send("DELETE", url("user", "token"), notifyUnauthorized = false)
73
    }
74
75
    // The bearer token is never part of a string anything prints.
76
    override fun toString(): String = "HubClient(${base})"
77
78
    private fun url(vararg segments: String, query: HttpUrl.Builder.() -> Unit = {}): HttpUrl {
79
        val b = base.newBuilder()
80
        // Each segment is encoded on its own: an owner or name never adds a path level.
81
        segments.forEach { b.addPathSegment(it) }
82
        b.query()
83
        return b.build()
84
    }
85
86
    private suspend fun <T> get(path: String, serializer: KSerializer<T>): T = get(url(*path.split('/').toTypedArray()), serializer)
87
88
    private suspend fun <T> get(url: HttpUrl, serializer: KSerializer<T>): T {
89
        val body = call(request(url).get().build(), notifyUnauthorized = true)
90
        return try {
91
            HubJson.decodeFromString(serializer, body)
92
        } catch (e: IllegalArgumentException) {
93
            // kotlinx.serialization's errors are IllegalArgumentExceptions.
94
            throw HubException.Unreadable("GET ${url.encodedPath}", e)
95
        }
96
    }
97
98
    private suspend fun send(method: String, url: HttpUrl, notifyUnauthorized: Boolean = true) {
99
        val body = if (method == "DELETE") null else "{}".toRequestBody(JsonMedia)
100
        call(request(url).method(method, body).build(), notifyUnauthorized)
101
    }
102
103
    private fun request(url: HttpUrl): Request.Builder = Request.Builder()
104
        .url(url)
105
        .header("Accept", "application/json")
106
        .header("Authorization", "Bearer $token")
107
108
    private suspend fun call(request: Request, notifyUnauthorized: Boolean): String {
109
        val response = try {
110
            http.newCall(request).await()
111
        } catch (e: IOException) {
112
            throw HubException.Network(e)
113
        }
114
        response.use { r ->
115
            val text = try {
116
                r.body?.string().orEmpty()
117
            } catch (e: IOException) {
118
                throw HubException.Network(e)
119
            }
120
            if (!r.isSuccessful) {
121
                if (r.code == 401 && notifyUnauthorized) onUnauthorized()
122
                throw HubException.Http(r.code, errorDetail(text))
123
            }
124
            return text
125
        }
126
    }
127
}