OXYGENHUB

Coverage / app/src/main/java/io/oxygenhub/android/api/HubClient.kt 34.3%

68 of 198 lines run, 33 of 94 functions, 16 of 42 branches · run #1667 on main · run missed

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
     * This phone takes alerts (#16): its Cloud Messaging [fcmToken] and its
46
     * alert key's [publicKey] (hex), for the token it signed in with
47
     * (`PUT /user/push`). Whether the hub sends alerts at all.
48
     */
49
    suspend fun registerPush(fcmToken: String, publicKey: String): Boolean
50
51
    /**
52
     * The proposals the caller should act on, across repositories (the hub's
53
     * dashboard); with [landable], also those it can land now (#20, oxygenhub#311).
54
     */
55
    suspend fun userProposals(landable: Boolean = false): UserProposals
56
57
    /** The open issues assigned to the caller, across repositories (#31, oxygenhub#359); a hub before it answers 404. */
58
    suspend fun userIssues(): List<IssueBrief>
59
60
    /** This phone as a second factor (#32, oxygenhub#356): its Keystore key's SubjectPublicKeyInfo in hex and a current app code. */
61
    suspend fun addAuthenticator(publicKey: String, code: String): Authenticator
62
63
    /** The account's phones that approve. */
64
    suspend fun authenticators(): List<Authenticator>
65
66
    /** This phone stops approving. */
67
    suspend fun removeAuthenticator()
68
69
    /** Approvals waiting for this phone's answer. */
70
    suspend fun approvals(): List<PendingApproval>
71
72
    /** Answer an approval: to approve, the number picked and the key's signature (hex DER) over [io.oxygenhub.android.secondfactor.ApprovalMessage]. */
73
    suspend fun answerApproval(id: String, approve: Boolean, number: Long, signature: String): ApprovalState
74
75
    /** Your sky's data, the dashboard backdrop's (#23); a hub before it answers 404. */
76
    suspend fun userSky(): UserSky
77
78
    /** The organisations the account belongs to (oxygenhub#302). */
79
    suspend fun userOrgs(): List<UserOrg>
80
81
    /** Create a repository (#27): `repo:admin`. */
82
    suspend fun createRepository(owner: String, name: String, description: String, visibility: Visibility): Repository
83
84
    /** The assistants a new repository under [owner] may ask for its starting files (oxygenhub#347). */
85
    suspend fun starterAssistants(owner: String): List<StarterAssistant>
86
87
    /** Ask an assistant for an empty repository's starting files; it works in the background. */
88
    suspend fun startStarter(owner: String, name: String, description: String): StarterStatus
89
90
    suspend fun starterStatus(owner: String, name: String): StarterStatus
91
92
    /** The newest runs of your repositories, with their jobs (#26); [before] (ms) pages back. */
93
    suspend fun userFlowRuns(before: Long? = null): List<UserFlowRun>
94
95
    /** One repository's sky, its pages' backdrop (#23); a hub before it answers 404. */
96
    suspend fun repoSky(owner: String, name: String): UserSky
97
98
    /** A repository's proposals: open (with drafts), landed, closed or all. */
99
    suspend fun proposals(owner: String, name: String, state: ProposalState = ProposalState.Open): List<Proposal>
100
101
    suspend fun proposal(owner: String, name: String, number: Long): Proposal
102
103
    /** The checks on a proposal's projected state. */
104
    suspend fun checks(owner: String, name: String, number: Long): List<Check>
105
106
    /**
107
     * Land it (`repo:write`); the proposal as landed. The hub refuses with 409
108
     * and its reason when it may not land, and 403 when the token lacks the scope.
109
     */
110
    suspend fun land(owner: String, name: String, number: Long): Proposal
111
112
    /** Land it by itself when its checks pass, or not (`repo:write`); the proposal after. */
113
    suspend fun setAutoLand(owner: String, name: String, number: Long, on: Boolean): Proposal
114
115
    /** A one-time token registering this phone as a runner for the account's repositories (`repo:write`). */
116
    suspend fun runnerToken(): RunnerToken
117
118
    suspend fun issue(owner: String, name: String, number: Long): Issue
119
120
    /** A repository's releases, newest first. */
121
    suspend fun releases(owner: String, name: String): List<Release>
122
123
    /** Issues and proposals matching [query], across the repositories the caller can read. */
124
    suspend fun search(query: String): List<SearchHit>
125
126
    /** A repository's runs, newest first. */
127
    suspend fun runs(owner: String, name: String): List<FlowRun>
128
129
    /** One run, with its jobs. */
130
    suspend fun run(owner: String, name: String, number: Long): FlowRun
131
132
    /** A job's log lines after [after] (0: from the start). */
133
    suspend fun jobLog(owner: String, name: String, number: Long, job: String, after: Long): List<LogLine>
134
135
    suspend fun rerunRun(owner: String, name: String, number: Long)
136
137
    /** Run [job] again in the same run, with the jobs that depend on it. */
138
    suspend fun rerunJob(owner: String, name: String, number: Long, job: String)
139
140
    suspend fun cancelRun(owner: String, name: String, number: Long)
141
142
    /** Approve, or reject, a job that waits for a person (#5, oxygenflow#57); its approval rules decide who may. */
143
    suspend fun decideJob(owner: String, name: String, number: Long, job: String, approve: Boolean)
144
145
    /** Make a run urgent, saying why (#5, oxygenhub#244): a maintainer's, within the owner's cap. */
146
    suspend fun expediteRun(owner: String, name: String, number: Long, reason: String)
147
148
    /** [owner]'s project boards the caller can see. */
149
    suspend fun projects(owner: String): List<Project>
150
151
    suspend fun board(owner: String, number: Long): Board
152
153
    /** Move a card to the column named [column]. */
154
    suspend fun moveCard(owner: String, number: Long, card: Long, column: String)
155
156
    suspend fun issueComments(owner: String, name: String, number: Long): List<Comment>
157
158
    suspend fun comment(owner: String, name: String, number: Long, body: String): Comment
159
160
    /** Close (with why: `completed` or `not_planned`) or reopen. */
161
    suspend fun setIssueState(owner: String, name: String, number: Long, open: Boolean, reason: String = "completed"): Issue
162
163
    suspend fun createIssue(owner: String, name: String, title: String, body: String): Issue
164
165
    /** The repository's labels, to choose from. */
166
    suspend fun labels(owner: String, name: String): List<Label>
167
168
    /** The issue's whole set of labels (triage). */
169
    suspend fun setIssueLabels(owner: String, name: String, number: Long, labels: List<String>): Issue
170
171
    /** The issue's whole set of assignees (triage, #4). */
172
    suspend fun setIssueAssignees(owner: String, name: String, number: Long, assignees: List<String>): Issue
173
174
    /** One repository (`GET /repos/{owner}/{name}`): its default bookmark, for the code browser. */
175
    suspend fun repository(owner: String, name: String): Repository
176
177
    /** A directory of the tree at [ref] (a bookmark, tag or change), [path] "" for the top. */
178
    suspend fun tree(owner: String, name: String, ref: String, path: String = ""): List<TreeEntry>
179
180
    /** A file's bytes at [ref], as text. */
181
    suspend fun file(owner: String, name: String, ref: String, path: String): String
182
183
    /** [ref]'s history, newest first; only changes to [path] when given. */
184
    suspend fun history(owner: String, name: String, ref: String, path: String? = null, offset: Int = 0, limit: Int = 50): History
185
186
    suspend fun change(owner: String, name: String, hash: String): ChangeDetail
187
188
    /** Review a proposal (#4): [verdict] is `approve`, `changes` or `comment`, with [body] said. */
189
    suspend fun review(owner: String, name: String, number: Long, verdict: String, body: String)
190
191
    /** The files a proposal changes, each with its diff, as the proposal page shows them (#4). */
192
    suspend fun proposalDiff(owner: String, name: String, number: Long): List<FileChange>
193
194
    /** The repository's bookmarks and tags, to browse at. */
195
    suspend fun refs(owner: String, name: String): Pair<List<String>, List<String>>
196
197
    /** An account's picture (`/avatar/{name}`, public); null when the hub has none this app can draw. */
198
    suspend fun avatar(name: String): Avatar?
199
200
    /** An account's public profile, and an organisation's (with its members). */
201
    suspend fun account(name: String): User
202
203
    suspend fun organisation(name: String): Organisation
204
}
205
206
/**
207
 * The hub's JSON API over OkHttp. [onUnauthorized] runs when the hub
208
 * refuses the token (revoked on the web, or 90 days unused), so the app
209
 * can drop the session and ask for a new sign-in.
210
 */
211
class HubClient(
212
    hub: String,
213
    private val token: String,
214
    private val http: OkHttpClient,
215
    private val onUnauthorized: () -> Unit = {},
216
) : HubApi {
217
    private val base: HttpUrl = "${hub.trimEnd('/')}/api/v1/".toHttpUrl()
218
    private val root: HttpUrl = "${hub.trimEnd('/')}/".toHttpUrl()
219
220
    override suspend fun user(): User = get("user", User.serializer())
221
222
    override suspend fun repositories(): List<Repository> = get("repos", ListSerializer(Repository.serializer()))
223
224
    override suspend fun issues(owner: String, name: String, state: IssueState): List<Issue> =
225
        get(url("repos", owner, name, "issues") { addQueryParameter("state", state.query) }, ListSerializer(Issue.serializer()))
226
227
    override suspend fun inbox(limit: Int): Inbox =
228
        get(url("notifications") { addQueryParameter("limit", limit.coerceIn(1, 200).toString()) }, Inbox.serializer())
229
230
    override suspend fun runnerToken(): RunnerToken = send("POST", url("user", "runner-token"), RunnerToken.serializer())
231
232
    override suspend fun releases(owner: String, name: String): List<Release> =
233
        get(url("repos", owner, name, "releases"), ListSerializer(Release.serializer()))
234
235
    override suspend fun search(query: String): List<SearchHit> =
236
        get(url("search") { addQueryParameter("q", query) }, ListSerializer(SearchHit.serializer()))
237
238
    override suspend fun runs(owner: String, name: String): List<FlowRun> =
239
        get(url("repos", owner, name, "flow", "runs"), ListSerializer(FlowRun.serializer()))
240
241
    override suspend fun run(owner: String, name: String, number: Long): FlowRun =
242
        get(url("repos", owner, name, "flow", "runs", number.toString()), FlowRun.serializer())
243
244
    override suspend fun jobLog(owner: String, name: String, number: Long, job: String, after: Long): List<LogLine> =
245
        get(
246
            url("repos", owner, name, "flow", "runs", number.toString(), "jobs", job, "log") { addQueryParameter("after", after.toString()) },
247
            ListSerializer(LogLine.serializer()),
248
        )
249
250
    override suspend fun rerunRun(owner: String, name: String, number: Long) {
251
        send("POST", url("repos", owner, name, "flow", "runs", number.toString(), "rerun"))
252
    }
253
254
    override suspend fun rerunJob(owner: String, name: String, number: Long, job: String) {
255
        send("POST", url("repos", owner, name, "flow", "runs", number.toString(), "jobs", job, "rerun"))
256
    }
257
258
    override suspend fun cancelRun(owner: String, name: String, number: Long) {
259
        send("POST", url("repos", owner, name, "flow", "runs", number.toString(), "cancel"))
260
    }
261
262
    override suspend fun decideJob(owner: String, name: String, number: Long, job: String, approve: Boolean) {
263
        send("POST", url("repos", owner, name, "flow", "runs", number.toString(), "jobs", job, if (approve) "approve" else "reject"))
264
    }
265
266
    override suspend fun expediteRun(owner: String, name: String, number: Long, reason: String) {
267
        sendJson("POST", url("repos", owner, name, "flow", "runs", number.toString(), "expedite"), buildJsonObject {
268
            put("reason", reason)
269
        }, Expedited.serializer())
270
    }
271
272
    override suspend fun projects(owner: String): List<Project> = get(url("projects", owner), ListSerializer(Project.serializer()))
273
274
    override suspend fun board(owner: String, number: Long): Board = get(url("projects", owner, number.toString()), Board.serializer())
275
276
    override suspend fun moveCard(owner: String, number: Long, card: Long, column: String) {
277
        val json = buildJsonObject { put("column", column) }
278
        call(request(url("projects", owner, number.toString(), "cards", card.toString())).method("PATCH", json.toString().toRequestBody(JsonMedia)).build(), true)
279
    }
280
281
    override suspend fun issue(owner: String, name: String, number: Long): Issue =
282
        get(url("repos", owner, name, "issues", number.toString()), Issue.serializer())
283
284
    override suspend fun issueComments(owner: String, name: String, number: Long): List<Comment> =
285
        get(url("repos", owner, name, "issues", number.toString(), "comments"), ListSerializer(Comment.serializer()))
286
287
    override suspend fun comment(owner: String, name: String, number: Long, body: String): Comment =
288
        sendJson("POST", url("repos", owner, name, "issues", number.toString(), "comments"), buildJsonObject { put("body", body) }, Comment.serializer())
289
290
    override suspend fun setIssueState(owner: String, name: String, number: Long, open: Boolean, reason: String): Issue =
291
        sendJson(
292
            "POST",
293
            url("repos", owner, name, "issues", number.toString()),
294
            buildJsonObject {
295
                put("state", if (open) "open" else "closed")
296
                if (!open) put("reason", reason)
297
            },
298
            Issue.serializer(),
299
        )
300
301
    override suspend fun createIssue(owner: String, name: String, title: String, body: String): Issue =
302
        sendJson("POST", url("repos", owner, name, "issues"), buildJsonObject {
303
            put("title", title)
304
            put("body", body)
305
        }, Issue.serializer())
306
307
    override suspend fun labels(owner: String, name: String): List<Label> =
308
        get(url("repos", owner, name, "labels"), ListSerializer(Label.serializer()))
309
310
    override suspend fun setIssueAssignees(owner: String, name: String, number: Long, assignees: List<String>): Issue =
311
        sendJson("PATCH", url("repos", owner, name, "issues", number.toString()), buildJsonObject {
312
            put("assignees", JsonArray(assignees.map { JsonPrimitive(it) }))
313
        }, Issue.serializer())
314
315
    override suspend fun setIssueLabels(owner: String, name: String, number: Long, labels: List<String>): Issue =
316
        sendJson("PATCH", url("repos", owner, name, "issues", number.toString()), buildJsonObject {
317
            put("labels", JsonArray(labels.map { JsonPrimitive(it) }))
318
        }, Issue.serializer())
319
320
    override suspend fun account(name: String): User = get(url("users", name), User.serializer())
321
322
    override suspend fun organisation(name: String): Organisation = get(url("orgs", name), Organisation.serializer())
323
324
    override suspend fun repository(owner: String, name: String): Repository = get(url("repos", owner, name), Repository.serializer())
325
326
    private fun parts(path: String): Array<String> = path.split('/').filter { it.isNotEmpty() }.toTypedArray()
327
328
    override suspend fun tree(owner: String, name: String, ref: String, path: String): List<TreeEntry> =
329
        get(url("repos", owner, name, "tree", ref, *parts(path)), ListSerializer(TreeEntry.serializer()))
330
331
    override suspend fun file(owner: String, name: String, ref: String, path: String): String =
332
        call(request(url("repos", owner, name, "file", ref, *parts(path))).get().build(), notifyUnauthorized = true)
333
334
    override suspend fun history(owner: String, name: String, ref: String, path: String?, offset: Int, limit: Int): History =
335
        get(
336
            url("repos", owner, name, "history", ref) {
337
                path?.let { addQueryParameter("path", it) }
338
                addQueryParameter("offset", offset.toString())
339
                addQueryParameter("limit", limit.toString())
340
            },
341
            History.serializer(),
342
        )
343
344
    override suspend fun review(owner: String, name: String, number: Long, verdict: String, body: String) {
345
        sendJson("POST", url("repos", owner, name, "proposals", number.toString(), "reviews"), buildJsonObject {
346
            put("verdict", verdict)
347
            put("body", body)
348
        }, ReviewPosted.serializer())
349
    }
350
351
    override suspend fun proposalDiff(owner: String, name: String, number: Long): List<FileChange> =
352
        get(url("repos", owner, name, "proposals", number.toString(), "diff"), ListSerializer(FileChange.serializer()))
353
354
    override suspend fun change(owner: String, name: String, hash: String): ChangeDetail =
355
        get(url("repos", owner, name, "changes", hash), ChangeDetail.serializer())
356
357
    override suspend fun refs(owner: String, name: String): Pair<List<String>, List<String>> {
358
        val bookmarks = get(url("repos", owner, name, "bookmarks"), ListSerializer(NamedRef.serializer())).map { it.name }
359
        val tags = get(url("repos", owner, name, "tags"), ListSerializer(NamedRef.serializer())).map { it.name }
360
        return bookmarks to tags
361
    }
362
363
    override suspend fun userSky(): UserSky = get(url("user", "sky"), UserSky.serializer())
364
365
    override suspend fun userOrgs(): List<UserOrg> = get(url("user", "orgs"), ListSerializer(UserOrg.serializer()))
366
367
    override suspend fun createRepository(owner: String, name: String, description: String, visibility: Visibility): Repository {
368
        val json = buildJsonObject {
369
            put("owner", owner)
370
            put("name", name)
371
            put("description", description)
372
            put("visibility", HubJson.encodeToJsonElement(Visibility.serializer(), visibility))
373
        }
374
        return sendJson("POST", url("repos"), json, Repository.serializer())
375
    }
376
377
    override suspend fun starterAssistants(owner: String): List<StarterAssistant> =
378
        get(url("user", "starter") { addQueryParameter("owner", owner) }, StarterAssistants.serializer()).assistants
379
380
    override suspend fun startStarter(owner: String, name: String, description: String): StarterStatus =
381
        sendJson("POST", url("repos", owner, name, "starter"), buildJsonObject { put("description", description) }, StarterStatus.serializer())
382
383
    override suspend fun starterStatus(owner: String, name: String): StarterStatus =
384
        get(url("repos", owner, name, "starter"), StarterStatus.serializer())
385
386
    override suspend fun userFlowRuns(before: Long?): List<UserFlowRun> =
387
        get(url("user", "flow", "runs") { before?.let { addQueryParameter("before", it.toString()) } }, ListSerializer(JsonObject.serializer()))
388
            .map { o -> UserFlowRun((o["repository"] as? JsonPrimitive)?.content.orEmpty(), HubJson.decodeFromJsonElement(FlowRun.serializer(), o)) }
389
390
    override suspend fun repoSky(owner: String, name: String): UserSky = get(url("repos", owner, name, "sky"), UserSky.serializer())
391
392
    override suspend fun userProposals(landable: Boolean): UserProposals =
393
        get(url("user", "proposals") { if (landable) addQueryParameter("landable", "true") }, UserProposals.serializer())
394
395
    override suspend fun userIssues(): List<IssueBrief> = get(url("user", "issues"), ListSerializer(IssueBrief.serializer()))
396
397
    override suspend fun addAuthenticator(publicKey: String, code: String): Authenticator =
398
        sendJson("POST", url("user", "authenticators"), buildJsonObject {
399
            put("public_key", publicKey)
400
            put("code", code)
401
        }, Authenticator.serializer())
402
403
    override suspend fun authenticators(): List<Authenticator> = get(url("user", "authenticators"), ListSerializer(Authenticator.serializer()))
404
405
    override suspend fun removeAuthenticator() {
406
        send("DELETE", url("user", "authenticators"))
407
    }
408
409
    override suspend fun approvals(): List<PendingApproval> = get(url("user", "approvals"), ListSerializer(PendingApproval.serializer()))
410
411
    override suspend fun answerApproval(id: String, approve: Boolean, number: Long, signature: String): ApprovalState =
412
        sendJson("POST", url("user", "approvals", id), buildJsonObject {
413
            put("approve", approve)
414
            put("number", number)
415
            put("signature", signature)
416
        }, ApprovalState.serializer())
417
418
    override suspend fun proposals(owner: String, name: String, state: ProposalState): List<Proposal> =
419
        get(url("repos", owner, name, "proposals") { addQueryParameter("state", state.query) }, ListSerializer(Proposal.serializer()))
420
421
    override suspend fun proposal(owner: String, name: String, number: Long): Proposal =
422
        get(url("repos", owner, name, "proposals", number.toString()), Proposal.serializer())
423
424
    override suspend fun checks(owner: String, name: String, number: Long): List<Check> =
425
        get(url("repos", owner, name, "proposals", number.toString(), "checks"), ListSerializer(Check.serializer()))
426
427
    override suspend fun land(owner: String, name: String, number: Long): Proposal =
428
        send("POST", url("repos", owner, name, "proposals", number.toString(), "land"), Proposal.serializer())
429
430
    override suspend fun setAutoLand(owner: String, name: String, number: Long, on: Boolean): Proposal =
431
        send(if (on) "POST" else "DELETE", url("repos", owner, name, "proposals", number.toString(), "auto-land"), Proposal.serializer())
432
433
    override suspend fun markRead(id: Long) {
434
        send("POST", url("notifications", id.toString(), "read"))
435
    }
436
437
    override suspend fun markAllRead() {
438
        send("POST", url("notifications", "read"))
439
    }
440
441
    override suspend fun revokeToken() {
442
        send("DELETE", url("user", "token"), notifyUnauthorized = false)
443
    }
444
445
    override suspend fun registerPush(fcmToken: String, publicKey: String): Boolean =
446
        sendJson("PUT", url("user", "push"), buildJsonObject {
447
            put("fcm_token", fcmToken)
448
            put("public_key", publicKey)
449
        }, PushRegistered.serializer()).alerts
450
451
    override suspend fun avatar(name: String): Avatar? {
452
        val url = root.newBuilder().addPathSegment("avatar").addPathSegment(name).build()
453
        // A public picture: no token goes with it.
454
        val response = try {
455
            http.newCall(Request.Builder().url(url).get().build()).await()
456
        } catch (e: IOException) {
457
            throw HubException.Network(e)
458
        }
459
        response.use { r ->
460
            if (r.code == 404) return null
461
            val bytes = try {
462
                r.body?.bytes() ?: ByteArray(0)
463
            } catch (e: IOException) {
464
                throw HubException.Network(e)
465
            }
466
            if (!r.isSuccessful) throw HubException.Http(r.code, errorDetail(bytes.decodeToString()))
467
            return Avatar.parse(r.header("Content-Type"), bytes)
468
        }
469
    }
470
471
    // The bearer token is never part of a string anything prints.
472
    override fun toString(): String = "HubClient(${base})"
473
474
    private fun url(vararg segments: String, query: HttpUrl.Builder.() -> Unit = {}): HttpUrl {
475
        val b = base.newBuilder()
476
        // Each segment is encoded on its own: an owner or name never adds a path level.
477
        segments.forEach { b.addPathSegment(it) }
478
        b.query()
479
        return b.build()
480
    }
481
482
    private suspend fun <T> get(path: String, serializer: KSerializer<T>): T = get(url(*path.split('/').toTypedArray()), serializer)
483
484
    private suspend fun <T> get(url: HttpUrl, serializer: KSerializer<T>): T {
485
        val body = call(request(url).get().build(), notifyUnauthorized = true)
486
        return try {
487
            HubJson.decodeFromString(serializer, body)
488
        } catch (e: IllegalArgumentException) {
489
            // kotlinx.serialization's errors are IllegalArgumentExceptions.
490
            throw HubException.Unreadable("GET ${url.encodedPath}", e)
491
        }
492
    }
493
494
    private suspend fun send(method: String, url: HttpUrl, notifyUnauthorized: Boolean = true): String {
495
        val body = if (method == "DELETE") null else "{}".toRequestBody(JsonMedia)
496
        return call(request(url).method(method, body).build(), notifyUnauthorized)
497
    }
498
499
    private suspend fun <T> send(method: String, url: HttpUrl, serializer: KSerializer<T>): T {
500
        val body = send(method, url)
501
        return try {
502
            HubJson.decodeFromString(serializer, body)
503
        } catch (e: IllegalArgumentException) {
504
            throw HubException.Unreadable("$method ${url.encodedPath}", e)
505
        }
506
    }
507
508
    /** [json] as the body; the answer read with [serializer]. */
509
    private suspend fun <T> sendJson(method: String, url: HttpUrl, json: JsonObject, serializer: KSerializer<T>): T {
510
        val body = call(request(url).method(method, json.toString().toRequestBody(JsonMedia)).build(), notifyUnauthorized = true)
511
        return try {
512
            HubJson.decodeFromString(serializer, body)
513
        } catch (e: IllegalArgumentException) {
514
            throw HubException.Unreadable("$method ${url.encodedPath}", e)
515
        }
516
    }
517
518
    private fun request(url: HttpUrl): Request.Builder = Request.Builder()
519
        .url(url)
520
        .header("Accept", "application/json")
521
        .header("Authorization", "Bearer $token")
522
523
    private suspend fun call(request: Request, notifyUnauthorized: Boolean): String {
524
        val response = try {
525
            http.newCall(request).await()
526
        } catch (e: IOException) {
527
            throw HubException.Network(e)
528
        }
529
        response.use { r ->
530
            val text = try {
531
                r.body?.string().orEmpty()
532
            } catch (e: IOException) {
533
                throw HubException.Network(e)
534
            }
535
            if (!r.isSuccessful) {
536
                if (r.code == 401 && notifyUnauthorized) onUnauthorized()
537
                throw HubException.Http(r.code, errorDetail(text))
538
            }
539
            return text
540
        }
541
    }
542
}