OXYGENHUB
# 98dbd1220929 oxygenhub-android / app / src / main / java / io / oxygenhub / android / api / HubClient.kt
11.3 KiB · last changed A code browser: files at any bookmark or tag, highlighted, the README rendered, history and changes (refs #3) 10 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
     * The proposals the caller should act on, across repositories (the hub's
41
     * dashboard); with [landable], also those it can land now (#20, oxygenhub#311).
42
     */
43
    suspend fun userProposals(landable: Boolean = false): UserProposals
44
45
    /** A repository's proposals: open (with drafts), landed, closed or all. */
46
    suspend fun proposals(owner: String, name: String, state: ProposalState = ProposalState.Open): List<Proposal>
47
48
    suspend fun proposal(owner: String, name: String, number: Long): Proposal
49
50
    /** The checks on a proposal's projected state. */
51
    suspend fun checks(owner: String, name: String, number: Long): List<Check>
52
53
    /**
54
     * Land it (`repo:write`); the proposal as landed. The hub refuses with 409
55
     * and its reason when it may not land, and 403 when the token lacks the scope.
56
     */
57
    suspend fun land(owner: String, name: String, number: Long): Proposal
58
59
    /** Land it by itself when its checks pass, or not (`repo:write`); the proposal after. */
60
    suspend fun setAutoLand(owner: String, name: String, number: Long, on: Boolean): Proposal
61
62
    /** A one-time token registering this phone as a runner for the account's repositories (`repo:write`). */
63
    suspend fun runnerToken(): RunnerToken
64
65
    /** One repository (`GET /repos/{owner}/{name}`): its default bookmark, for the code browser. */
66
    suspend fun repository(owner: String, name: String): Repository
67
68
    /** A directory of the tree at [ref] (a bookmark, tag or change), [path] "" for the top. */
69
    suspend fun tree(owner: String, name: String, ref: String, path: String = ""): List<TreeEntry>
70
71
    /** A file's bytes at [ref], as text. */
72
    suspend fun file(owner: String, name: String, ref: String, path: String): String
73
74
    /** [ref]'s history, newest first; only changes to [path] when given. */
75
    suspend fun history(owner: String, name: String, ref: String, path: String? = null, offset: Int = 0, limit: Int = 50): History
76
77
    suspend fun change(owner: String, name: String, hash: String): ChangeDetail
78
79
    /** The repository's bookmarks and tags, to browse at. */
80
    suspend fun refs(owner: String, name: String): Pair<List<String>, List<String>>
81
82
    /** An account's picture (`/avatar/{name}`, public); null when the hub has none this app can draw. */
83
    suspend fun avatar(name: String): Avatar?
84
}
85
86
/**
87
 * The hub's JSON API over OkHttp. [onUnauthorized] runs when the hub
88
 * refuses the token (revoked on the web, or 90 days unused), so the app
89
 * can drop the session and ask for a new sign-in.
90
 */
91
class HubClient(
92
    hub: String,
93
    private val token: String,
94
    private val http: OkHttpClient,
95
    private val onUnauthorized: () -> Unit = {},
96
) : HubApi {
97
    private val base: HttpUrl = "${hub.trimEnd('/')}/api/v1/".toHttpUrl()
98
    private val root: HttpUrl = "${hub.trimEnd('/')}/".toHttpUrl()
99
100
    override suspend fun user(): User = get("user", User.serializer())
101
102
    override suspend fun repositories(): List<Repository> = get("repos", ListSerializer(Repository.serializer()))
103
104
    override suspend fun issues(owner: String, name: String, state: IssueState): List<Issue> =
105
        get(url("repos", owner, name, "issues") { addQueryParameter("state", state.query) }, ListSerializer(Issue.serializer()))
106
107
    override suspend fun inbox(limit: Int): Inbox =
108
        get(url("notifications") { addQueryParameter("limit", limit.coerceIn(1, 200).toString()) }, Inbox.serializer())
109
110
    override suspend fun runnerToken(): RunnerToken = send("POST", url("user", "runner-token"), RunnerToken.serializer())
111
112
    override suspend fun repository(owner: String, name: String): Repository = get(url("repos", owner, name), Repository.serializer())
113
114
    private fun parts(path: String): Array<String> = path.split('/').filter { it.isNotEmpty() }.toTypedArray()
115
116
    override suspend fun tree(owner: String, name: String, ref: String, path: String): List<TreeEntry> =
117
        get(url("repos", owner, name, "tree", ref, *parts(path)), ListSerializer(TreeEntry.serializer()))
118
119
    override suspend fun file(owner: String, name: String, ref: String, path: String): String =
120
        call(request(url("repos", owner, name, "file", ref, *parts(path))).get().build(), notifyUnauthorized = true)
121
122
    override suspend fun history(owner: String, name: String, ref: String, path: String?, offset: Int, limit: Int): History =
123
        get(
124
            url("repos", owner, name, "history", ref) {
125
                path?.let { addQueryParameter("path", it) }
126
                addQueryParameter("offset", offset.toString())
127
                addQueryParameter("limit", limit.toString())
128
            },
129
            History.serializer(),
130
        )
131
132
    override suspend fun change(owner: String, name: String, hash: String): ChangeDetail =
133
        get(url("repos", owner, name, "changes", hash), ChangeDetail.serializer())
134
135
    override suspend fun refs(owner: String, name: String): Pair<List<String>, List<String>> {
136
        val bookmarks = get(url("repos", owner, name, "bookmarks"), ListSerializer(NamedRef.serializer())).map { it.name }
137
        val tags = get(url("repos", owner, name, "tags"), ListSerializer(NamedRef.serializer())).map { it.name }
138
        return bookmarks to tags
139
    }
140
141
    override suspend fun userProposals(landable: Boolean): UserProposals =
142
        get(url("user", "proposals") { if (landable) addQueryParameter("landable", "true") }, UserProposals.serializer())
143
144
    override suspend fun proposals(owner: String, name: String, state: ProposalState): List<Proposal> =
145
        get(url("repos", owner, name, "proposals") { addQueryParameter("state", state.query) }, ListSerializer(Proposal.serializer()))
146
147
    override suspend fun proposal(owner: String, name: String, number: Long): Proposal =
148
        get(url("repos", owner, name, "proposals", number.toString()), Proposal.serializer())
149
150
    override suspend fun checks(owner: String, name: String, number: Long): List<Check> =
151
        get(url("repos", owner, name, "proposals", number.toString(), "checks"), ListSerializer(Check.serializer()))
152
153
    override suspend fun land(owner: String, name: String, number: Long): Proposal =
154
        send("POST", url("repos", owner, name, "proposals", number.toString(), "land"), Proposal.serializer())
155
156
    override suspend fun setAutoLand(owner: String, name: String, number: Long, on: Boolean): Proposal =
157
        send(if (on) "POST" else "DELETE", url("repos", owner, name, "proposals", number.toString(), "auto-land"), Proposal.serializer())
158
159
    override suspend fun markRead(id: Long) {
160
        send("POST", url("notifications", id.toString(), "read"))
161
    }
162
163
    override suspend fun markAllRead() {
164
        send("POST", url("notifications", "read"))
165
    }
166
167
    override suspend fun revokeToken() {
168
        send("DELETE", url("user", "token"), notifyUnauthorized = false)
169
    }
170
171
    override suspend fun avatar(name: String): Avatar? {
172
        val url = root.newBuilder().addPathSegment("avatar").addPathSegment(name).build()
173
        // A public picture: no token goes with it.
174
        val response = try {
175
            http.newCall(Request.Builder().url(url).get().build()).await()
176
        } catch (e: IOException) {
177
            throw HubException.Network(e)
178
        }
179
        response.use { r ->
180
            if (r.code == 404) return null
181
            val bytes = try {
182
                r.body?.bytes() ?: ByteArray(0)
183
            } catch (e: IOException) {
184
                throw HubException.Network(e)
185
            }
186
            if (!r.isSuccessful) throw HubException.Http(r.code, errorDetail(bytes.decodeToString()))
187
            return Avatar.parse(r.header("Content-Type"), bytes)
188
        }
189
    }
190
191
    // The bearer token is never part of a string anything prints.
192
    override fun toString(): String = "HubClient(${base})"
193
194
    private fun url(vararg segments: String, query: HttpUrl.Builder.() -> Unit = {}): HttpUrl {
195
        val b = base.newBuilder()
196
        // Each segment is encoded on its own: an owner or name never adds a path level.
197
        segments.forEach { b.addPathSegment(it) }
198
        b.query()
199
        return b.build()
200
    }
201
202
    private suspend fun <T> get(path: String, serializer: KSerializer<T>): T = get(url(*path.split('/').toTypedArray()), serializer)
203
204
    private suspend fun <T> get(url: HttpUrl, serializer: KSerializer<T>): T {
205
        val body = call(request(url).get().build(), notifyUnauthorized = true)
206
        return try {
207
            HubJson.decodeFromString(serializer, body)
208
        } catch (e: IllegalArgumentException) {
209
            // kotlinx.serialization's errors are IllegalArgumentExceptions.
210
            throw HubException.Unreadable("GET ${url.encodedPath}", e)
211
        }
212
    }
213
214
    private suspend fun send(method: String, url: HttpUrl, notifyUnauthorized: Boolean = true): String {
215
        val body = if (method == "DELETE") null else "{}".toRequestBody(JsonMedia)
216
        return call(request(url).method(method, body).build(), notifyUnauthorized)
217
    }
218
219
    private suspend fun <T> send(method: String, url: HttpUrl, serializer: KSerializer<T>): T {
220
        val body = send(method, url)
221
        return try {
222
            HubJson.decodeFromString(serializer, body)
223
        } catch (e: IllegalArgumentException) {
224
            throw HubException.Unreadable("$method ${url.encodedPath}", e)
225
        }
226
    }
227
228
    private fun request(url: HttpUrl): Request.Builder = Request.Builder()
229
        .url(url)
230
        .header("Accept", "application/json")
231
        .header("Authorization", "Bearer $token")
232
233
    private suspend fun call(request: Request, notifyUnauthorized: Boolean): String {
234
        val response = try {
235
            http.newCall(request).await()
236
        } catch (e: IOException) {
237
            throw HubException.Network(e)
238
        }
239
        response.use { r ->
240
            val text = try {
241
                r.body?.string().orEmpty()
242
            } catch (e: IOException) {
243
                throw HubException.Network(e)
244
            }
245
            if (!r.isSuccessful) {
246
                if (r.code == 401 && notifyUnauthorized) onUnauthorized()
247
                throw HubException.Http(r.code, errorDetail(text))
248
            }
249
            return text
250
        }
251
    }
252
}