OXYGENHUB
# 31e4f3755627 oxygenhub-android / app / src / main / java / io / oxygenhub / android / api / HubClient.kt
14.6 KiB · last changed Issues on the phone: read, comment, close and reopen, labels, and new issues (refs #4) 10 days ago source blame history raw
1
package io.oxygenhub.android.api
2
3
import java.io.IOException
4
import kotlinx.serialization.KSerializer
5
import kotlinx.serialization.builtins.ListSerializer
6
import kotlinx.serialization.json.JsonArray
7
import kotlinx.serialization.json.JsonObject
8
import kotlinx.serialization.json.JsonPrimitive
9
import kotlinx.serialization.json.buildJsonObject
10
import kotlinx.serialization.json.put
11
import okhttp3.HttpUrl
12
import okhttp3.HttpUrl.Companion.toHttpUrl
13
import okhttp3.OkHttpClient
14
import okhttp3.Request
15
import okhttp3.RequestBody.Companion.toRequestBody
16
17
/**
18
 * What the app asks of a hub, signed in. The view models see only this, so
19
 * their tests give them a fake. Later screens (oxygenhub-android#3–#5) add
20
 * to it: a repository's tree, history, proposals, runs.
21
 */
22
interface HubApi {
23
    /** The signed-in account (`GET /user`, scope `user:read`). */
24
    suspend fun user(): User
25
26
    /** Every repository the caller can read (`GET /repos`). */
27
    suspend fun repositories(): List<Repository>
28
29
    /** A repository's issues (`GET /repos/{owner}/{name}/issues?state=`). */
30
    suspend fun issues(owner: String, name: String, state: IssueState = IssueState.Open): List<Issue>
31
32
    /** The inbox: the unread count and the newest entries (`GET /notifications`). */
33
    suspend fun inbox(limit: Int = 50): Inbox
34
35
    /** Marks one notification read (`POST /notifications/{id}/read`). */
36
    suspend fun markRead(id: Long)
37
38
    /** Marks the whole inbox read (`POST /notifications/read`). */
39
    suspend fun markAllRead()
40
41
    /** Revokes the token this client signs in with (`DELETE /user/token`). */
42
    suspend fun revokeToken()
43
44
    /**
45
     * The proposals the caller should act on, across repositories (the hub's
46
     * dashboard); with [landable], also those it can land now (#20, oxygenhub#311).
47
     */
48
    suspend fun userProposals(landable: Boolean = false): UserProposals
49
50
    /** A repository's proposals: open (with drafts), landed, closed or all. */
51
    suspend fun proposals(owner: String, name: String, state: ProposalState = ProposalState.Open): List<Proposal>
52
53
    suspend fun proposal(owner: String, name: String, number: Long): Proposal
54
55
    /** The checks on a proposal's projected state. */
56
    suspend fun checks(owner: String, name: String, number: Long): List<Check>
57
58
    /**
59
     * Land it (`repo:write`); the proposal as landed. The hub refuses with 409
60
     * and its reason when it may not land, and 403 when the token lacks the scope.
61
     */
62
    suspend fun land(owner: String, name: String, number: Long): Proposal
63
64
    /** Land it by itself when its checks pass, or not (`repo:write`); the proposal after. */
65
    suspend fun setAutoLand(owner: String, name: String, number: Long, on: Boolean): Proposal
66
67
    /** A one-time token registering this phone as a runner for the account's repositories (`repo:write`). */
68
    suspend fun runnerToken(): RunnerToken
69
70
    suspend fun issue(owner: String, name: String, number: Long): Issue
71
72
    suspend fun issueComments(owner: String, name: String, number: Long): List<Comment>
73
74
    suspend fun comment(owner: String, name: String, number: Long, body: String): Comment
75
76
    /** Close (with why: `completed` or `not_planned`) or reopen. */
77
    suspend fun setIssueState(owner: String, name: String, number: Long, open: Boolean, reason: String = "completed"): Issue
78
79
    suspend fun createIssue(owner: String, name: String, title: String, body: String): Issue
80
81
    /** The repository's labels, to choose from. */
82
    suspend fun labels(owner: String, name: String): List<Label>
83
84
    /** The issue's whole set of labels (triage). */
85
    suspend fun setIssueLabels(owner: String, name: String, number: Long, labels: List<String>): Issue
86
87
    /** One repository (`GET /repos/{owner}/{name}`): its default bookmark, for the code browser. */
88
    suspend fun repository(owner: String, name: String): Repository
89
90
    /** A directory of the tree at [ref] (a bookmark, tag or change), [path] "" for the top. */
91
    suspend fun tree(owner: String, name: String, ref: String, path: String = ""): List<TreeEntry>
92
93
    /** A file's bytes at [ref], as text. */
94
    suspend fun file(owner: String, name: String, ref: String, path: String): String
95
96
    /** [ref]'s history, newest first; only changes to [path] when given. */
97
    suspend fun history(owner: String, name: String, ref: String, path: String? = null, offset: Int = 0, limit: Int = 50): History
98
99
    suspend fun change(owner: String, name: String, hash: String): ChangeDetail
100
101
    /** The repository's bookmarks and tags, to browse at. */
102
    suspend fun refs(owner: String, name: String): Pair<List<String>, List<String>>
103
104
    /** An account's picture (`/avatar/{name}`, public); null when the hub has none this app can draw. */
105
    suspend fun avatar(name: String): Avatar?
106
}
107
108
/**
109
 * The hub's JSON API over OkHttp. [onUnauthorized] runs when the hub
110
 * refuses the token (revoked on the web, or 90 days unused), so the app
111
 * can drop the session and ask for a new sign-in.
112
 */
113
class HubClient(
114
    hub: String,
115
    private val token: String,
116
    private val http: OkHttpClient,
117
    private val onUnauthorized: () -> Unit = {},
118
) : HubApi {
119
    private val base: HttpUrl = "${hub.trimEnd('/')}/api/v1/".toHttpUrl()
120
    private val root: HttpUrl = "${hub.trimEnd('/')}/".toHttpUrl()
121
122
    override suspend fun user(): User = get("user", User.serializer())
123
124
    override suspend fun repositories(): List<Repository> = get("repos", ListSerializer(Repository.serializer()))
125
126
    override suspend fun issues(owner: String, name: String, state: IssueState): List<Issue> =
127
        get(url("repos", owner, name, "issues") { addQueryParameter("state", state.query) }, ListSerializer(Issue.serializer()))
128
129
    override suspend fun inbox(limit: Int): Inbox =
130
        get(url("notifications") { addQueryParameter("limit", limit.coerceIn(1, 200).toString()) }, Inbox.serializer())
131
132
    override suspend fun runnerToken(): RunnerToken = send("POST", url("user", "runner-token"), RunnerToken.serializer())
133
134
    override suspend fun issue(owner: String, name: String, number: Long): Issue =
135
        get(url("repos", owner, name, "issues", number.toString()), Issue.serializer())
136
137
    override suspend fun issueComments(owner: String, name: String, number: Long): List<Comment> =
138
        get(url("repos", owner, name, "issues", number.toString(), "comments"), ListSerializer(Comment.serializer()))
139
140
    override suspend fun comment(owner: String, name: String, number: Long, body: String): Comment =
141
        sendJson("POST", url("repos", owner, name, "issues", number.toString(), "comments"), buildJsonObject { put("body", body) }, Comment.serializer())
142
143
    override suspend fun setIssueState(owner: String, name: String, number: Long, open: Boolean, reason: String): Issue =
144
        sendJson(
145
            "POST",
146
            url("repos", owner, name, "issues", number.toString()),
147
            buildJsonObject {
148
                put("state", if (open) "open" else "closed")
149
                if (!open) put("reason", reason)
150
            },
151
            Issue.serializer(),
152
        )
153
154
    override suspend fun createIssue(owner: String, name: String, title: String, body: String): Issue =
155
        sendJson("POST", url("repos", owner, name, "issues"), buildJsonObject {
156
            put("title", title)
157
            put("body", body)
158
        }, Issue.serializer())
159
160
    override suspend fun labels(owner: String, name: String): List<Label> =
161
        get(url("repos", owner, name, "labels"), ListSerializer(Label.serializer()))
162
163
    override suspend fun setIssueLabels(owner: String, name: String, number: Long, labels: List<String>): Issue =
164
        sendJson("PATCH", url("repos", owner, name, "issues", number.toString()), buildJsonObject {
165
            put("labels", JsonArray(labels.map { JsonPrimitive(it) }))
166
        }, Issue.serializer())
167
168
    override suspend fun repository(owner: String, name: String): Repository = get(url("repos", owner, name), Repository.serializer())
169
170
    private fun parts(path: String): Array<String> = path.split('/').filter { it.isNotEmpty() }.toTypedArray()
171
172
    override suspend fun tree(owner: String, name: String, ref: String, path: String): List<TreeEntry> =
173
        get(url("repos", owner, name, "tree", ref, *parts(path)), ListSerializer(TreeEntry.serializer()))
174
175
    override suspend fun file(owner: String, name: String, ref: String, path: String): String =
176
        call(request(url("repos", owner, name, "file", ref, *parts(path))).get().build(), notifyUnauthorized = true)
177
178
    override suspend fun history(owner: String, name: String, ref: String, path: String?, offset: Int, limit: Int): History =
179
        get(
180
            url("repos", owner, name, "history", ref) {
181
                path?.let { addQueryParameter("path", it) }
182
                addQueryParameter("offset", offset.toString())
183
                addQueryParameter("limit", limit.toString())
184
            },
185
            History.serializer(),
186
        )
187
188
    override suspend fun change(owner: String, name: String, hash: String): ChangeDetail =
189
        get(url("repos", owner, name, "changes", hash), ChangeDetail.serializer())
190
191
    override suspend fun refs(owner: String, name: String): Pair<List<String>, List<String>> {
192
        val bookmarks = get(url("repos", owner, name, "bookmarks"), ListSerializer(NamedRef.serializer())).map { it.name }
193
        val tags = get(url("repos", owner, name, "tags"), ListSerializer(NamedRef.serializer())).map { it.name }
194
        return bookmarks to tags
195
    }
196
197
    override suspend fun userProposals(landable: Boolean): UserProposals =
198
        get(url("user", "proposals") { if (landable) addQueryParameter("landable", "true") }, UserProposals.serializer())
199
200
    override suspend fun proposals(owner: String, name: String, state: ProposalState): List<Proposal> =
201
        get(url("repos", owner, name, "proposals") { addQueryParameter("state", state.query) }, ListSerializer(Proposal.serializer()))
202
203
    override suspend fun proposal(owner: String, name: String, number: Long): Proposal =
204
        get(url("repos", owner, name, "proposals", number.toString()), Proposal.serializer())
205
206
    override suspend fun checks(owner: String, name: String, number: Long): List<Check> =
207
        get(url("repos", owner, name, "proposals", number.toString(), "checks"), ListSerializer(Check.serializer()))
208
209
    override suspend fun land(owner: String, name: String, number: Long): Proposal =
210
        send("POST", url("repos", owner, name, "proposals", number.toString(), "land"), Proposal.serializer())
211
212
    override suspend fun setAutoLand(owner: String, name: String, number: Long, on: Boolean): Proposal =
213
        send(if (on) "POST" else "DELETE", url("repos", owner, name, "proposals", number.toString(), "auto-land"), Proposal.serializer())
214
215
    override suspend fun markRead(id: Long) {
216
        send("POST", url("notifications", id.toString(), "read"))
217
    }
218
219
    override suspend fun markAllRead() {
220
        send("POST", url("notifications", "read"))
221
    }
222
223
    override suspend fun revokeToken() {
224
        send("DELETE", url("user", "token"), notifyUnauthorized = false)
225
    }
226
227
    override suspend fun avatar(name: String): Avatar? {
228
        val url = root.newBuilder().addPathSegment("avatar").addPathSegment(name).build()
229
        // A public picture: no token goes with it.
230
        val response = try {
231
            http.newCall(Request.Builder().url(url).get().build()).await()
232
        } catch (e: IOException) {
233
            throw HubException.Network(e)
234
        }
235
        response.use { r ->
236
            if (r.code == 404) return null
237
            val bytes = try {
238
                r.body?.bytes() ?: ByteArray(0)
239
            } catch (e: IOException) {
240
                throw HubException.Network(e)
241
            }
242
            if (!r.isSuccessful) throw HubException.Http(r.code, errorDetail(bytes.decodeToString()))
243
            return Avatar.parse(r.header("Content-Type"), bytes)
244
        }
245
    }
246
247
    // The bearer token is never part of a string anything prints.
248
    override fun toString(): String = "HubClient(${base})"
249
250
    private fun url(vararg segments: String, query: HttpUrl.Builder.() -> Unit = {}): HttpUrl {
251
        val b = base.newBuilder()
252
        // Each segment is encoded on its own: an owner or name never adds a path level.
253
        segments.forEach { b.addPathSegment(it) }
254
        b.query()
255
        return b.build()
256
    }
257
258
    private suspend fun <T> get(path: String, serializer: KSerializer<T>): T = get(url(*path.split('/').toTypedArray()), serializer)
259
260
    private suspend fun <T> get(url: HttpUrl, serializer: KSerializer<T>): T {
261
        val body = call(request(url).get().build(), notifyUnauthorized = true)
262
        return try {
263
            HubJson.decodeFromString(serializer, body)
264
        } catch (e: IllegalArgumentException) {
265
            // kotlinx.serialization's errors are IllegalArgumentExceptions.
266
            throw HubException.Unreadable("GET ${url.encodedPath}", e)
267
        }
268
    }
269
270
    private suspend fun send(method: String, url: HttpUrl, notifyUnauthorized: Boolean = true): String {
271
        val body = if (method == "DELETE") null else "{}".toRequestBody(JsonMedia)
272
        return call(request(url).method(method, body).build(), notifyUnauthorized)
273
    }
274
275
    private suspend fun <T> send(method: String, url: HttpUrl, serializer: KSerializer<T>): T {
276
        val body = send(method, url)
277
        return try {
278
            HubJson.decodeFromString(serializer, body)
279
        } catch (e: IllegalArgumentException) {
280
            throw HubException.Unreadable("$method ${url.encodedPath}", e)
281
        }
282
    }
283
284
    /** [json] as the body; the answer read with [serializer]. */
285
    private suspend fun <T> sendJson(method: String, url: HttpUrl, json: JsonObject, serializer: KSerializer<T>): T {
286
        val body = call(request(url).method(method, json.toString().toRequestBody(JsonMedia)).build(), notifyUnauthorized = true)
287
        return try {
288
            HubJson.decodeFromString(serializer, body)
289
        } catch (e: IllegalArgumentException) {
290
            throw HubException.Unreadable("$method ${url.encodedPath}", e)
291
        }
292
    }
293
294
    private fun request(url: HttpUrl): Request.Builder = Request.Builder()
295
        .url(url)
296
        .header("Accept", "application/json")
297
        .header("Authorization", "Bearer $token")
298
299
    private suspend fun call(request: Request, notifyUnauthorized: Boolean): String {
300
        val response = try {
301
            http.newCall(request).await()
302
        } catch (e: IOException) {
303
            throw HubException.Network(e)
304
        }
305
        response.use { r ->
306
            val text = try {
307
                r.body?.string().orEmpty()
308
            } catch (e: IOException) {
309
                throw HubException.Network(e)
310
            }
311
            if (!r.isSuccessful) {
312
                if (r.code == 401 && notifyUnauthorized) onUnauthorized()
313
                throw HubException.Http(r.code, errorDetail(text))
314
            }
315
            return text
316
        }
317
    }
318
}