Coverage / app/src/main/java/io/oxygenhub/android/api/HubClient.kt 42.4%
67 of 158 lines run, 32 of 74 functions, 16 of 32 branches · run #1628 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 | /** A repository's proposals: open (with drafts), landed, closed or all. */ | |
| 58 | suspend fun proposals(owner: String, name: String, state: ProposalState = ProposalState.Open): List<Proposal> | |
| 59 | ||
| 60 | suspend fun proposal(owner: String, name: String, number: Long): Proposal | |
| 61 | ||
| 62 | /** The checks on a proposal's projected state. */ | |
| 63 | suspend fun checks(owner: String, name: String, number: Long): List<Check> | |
| 64 | ||
| 65 | /** | |
| 66 | * Land it (`repo:write`); the proposal as landed. The hub refuses with 409 | |
| 67 | * and its reason when it may not land, and 403 when the token lacks the scope. | |
| 68 | */ | |
| 69 | suspend fun land(owner: String, name: String, number: Long): Proposal | |
| 70 | ||
| 71 | /** Land it by itself when its checks pass, or not (`repo:write`); the proposal after. */ | |
| 72 | suspend fun setAutoLand(owner: String, name: String, number: Long, on: Boolean): Proposal | |
| 73 | ||
| 74 | /** A one-time token registering this phone as a runner for the account's repositories (`repo:write`). */ | |
| 75 | suspend fun runnerToken(): RunnerToken | |
| 76 | ||
| 77 | suspend fun issue(owner: String, name: String, number: Long): Issue | |
| 78 | ||
| 79 | /** A repository's releases, newest first. */ | |
| 80 | suspend fun releases(owner: String, name: String): List<Release> | |
| 81 | ||
| 82 | /** Issues and proposals matching [query], across the repositories the caller can read. */ | |
| 83 | suspend fun search(query: String): List<SearchHit> | |
| 84 | ||
| 85 | /** A repository's runs, newest first. */ | |
| 86 | suspend fun runs(owner: String, name: String): List<FlowRun> | |
| 87 | ||
| 88 | /** One run, with its jobs. */ | |
| 89 | suspend fun run(owner: String, name: String, number: Long): FlowRun | |
| 90 | ||
| 91 | /** A job's log lines after [after] (0: from the start). */ | |
| 92 | suspend fun jobLog(owner: String, name: String, number: Long, job: String, after: Long): List<LogLine> | |
| 93 | ||
| 94 | suspend fun rerunRun(owner: String, name: String, number: Long) | |
| 95 | ||
| 96 | /** Run [job] again in the same run, with the jobs that depend on it. */ | |
| 97 | suspend fun rerunJob(owner: String, name: String, number: Long, job: String) | |
| 98 | ||
| 99 | suspend fun cancelRun(owner: String, name: String, number: Long) | |
| 100 | ||
| 101 | /** [owner]'s project boards the caller can see. */ | |
| 102 | suspend fun projects(owner: String): List<Project> | |
| 103 | ||
| 104 | suspend fun board(owner: String, number: Long): Board | |
| 105 | ||
| 106 | /** Move a card to the column named [column]. */ | |
| 107 | suspend fun moveCard(owner: String, number: Long, card: Long, column: String) | |
| 108 | ||
| 109 | suspend fun issueComments(owner: String, name: String, number: Long): List<Comment> | |
| 110 | ||
| 111 | suspend fun comment(owner: String, name: String, number: Long, body: String): Comment | |
| 112 | ||
| 113 | /** Close (with why: `completed` or `not_planned`) or reopen. */ | |
| 114 | suspend fun setIssueState(owner: String, name: String, number: Long, open: Boolean, reason: String = "completed"): Issue | |
| 115 | ||
| 116 | suspend fun createIssue(owner: String, name: String, title: String, body: String): Issue | |
| 117 | ||
| 118 | /** The repository's labels, to choose from. */ | |
| 119 | suspend fun labels(owner: String, name: String): List<Label> | |
| 120 | ||
| 121 | /** The issue's whole set of labels (triage). */ | |
| 122 | suspend fun setIssueLabels(owner: String, name: String, number: Long, labels: List<String>): Issue | |
| 123 | ||
| 124 | /** One repository (`GET /repos/{owner}/{name}`): its default bookmark, for the code browser. */ | |
| 125 | suspend fun repository(owner: String, name: String): Repository | |
| 126 | ||
| 127 | /** A directory of the tree at [ref] (a bookmark, tag or change), [path] "" for the top. */ | |
| 128 | suspend fun tree(owner: String, name: String, ref: String, path: String = ""): List<TreeEntry> | |
| 129 | ||
| 130 | /** A file's bytes at [ref], as text. */ | |
| 131 | suspend fun file(owner: String, name: String, ref: String, path: String): String | |
| 132 | ||
| 133 | /** [ref]'s history, newest first; only changes to [path] when given. */ | |
| 134 | suspend fun history(owner: String, name: String, ref: String, path: String? = null, offset: Int = 0, limit: Int = 50): History | |
| 135 | ||
| 136 | suspend fun change(owner: String, name: String, hash: String): ChangeDetail | |
| 137 | ||
| 138 | /** Review a proposal (#4): [verdict] is `approve`, `changes` or `comment`, with [body] said. */ | |
| 139 | suspend fun review(owner: String, name: String, number: Long, verdict: String, body: String) | |
| 140 | ||
| 141 | /** The files a proposal changes, each with its diff, as the proposal page shows them (#4). */ | |
| 142 | suspend fun proposalDiff(owner: String, name: String, number: Long): List<FileChange> | |
| 143 | ||
| 144 | /** The repository's bookmarks and tags, to browse at. */ | |
| 145 | suspend fun refs(owner: String, name: String): Pair<List<String>, List<String>> | |
| 146 | ||
| 147 | /** An account's picture (`/avatar/{name}`, public); null when the hub has none this app can draw. */ | |
| 148 | suspend fun avatar(name: String): Avatar? | |
| 149 | ||
| 150 | /** An account's public profile, and an organisation's (with its members). */ | |
| 151 | suspend fun account(name: String): User | |
| 152 | ||
| 153 | suspend fun organisation(name: String): Organisation | |
| 154 | } | |
| 155 | ||
| 156 | /** | |
| 157 | * The hub's JSON API over OkHttp. [onUnauthorized] runs when the hub | |
| 158 | * refuses the token (revoked on the web, or 90 days unused), so the app | |
| 159 | * can drop the session and ask for a new sign-in. | |
| 160 | */ | |
| 161 | class HubClient( | |
| 162 | hub: String, | |
| 163 | private val token: String, | |
| 164 | private val http: OkHttpClient, | |
| 165 | private val onUnauthorized: () -> Unit = {}, | |
| 166 | ) : HubApi { | |
| 167 | private val base: HttpUrl = "${hub.trimEnd('/')}/api/v1/".toHttpUrl() | |
| 168 | private val root: HttpUrl = "${hub.trimEnd('/')}/".toHttpUrl() | |
| 169 | ||
| 170 | override suspend fun user(): User = get("user", User.serializer()) | |
| 171 | ||
| 172 | override suspend fun repositories(): List<Repository> = get("repos", ListSerializer(Repository.serializer())) | |
| 173 | ||
| 174 | override suspend fun issues(owner: String, name: String, state: IssueState): List<Issue> = | |
| 175 | get(url("repos", owner, name, "issues") { addQueryParameter("state", state.query) }, ListSerializer(Issue.serializer())) | |
| 176 | ||
| 177 | override suspend fun inbox(limit: Int): Inbox = | |
| 178 | get(url("notifications") { addQueryParameter("limit", limit.coerceIn(1, 200).toString()) }, Inbox.serializer()) | |
| 179 | ||
| 180 | override suspend fun runnerToken(): RunnerToken = send("POST", url("user", "runner-token"), RunnerToken.serializer()) | |
| 181 | ||
| 182 | override suspend fun releases(owner: String, name: String): List<Release> = | |
| 183 | get(url("repos", owner, name, "releases"), ListSerializer(Release.serializer())) | |
| 184 | ||
| 185 | override suspend fun search(query: String): List<SearchHit> = | |
| 186 | get(url("search") { addQueryParameter("q", query) }, ListSerializer(SearchHit.serializer())) | |
| 187 | ||
| 188 | override suspend fun runs(owner: String, name: String): List<FlowRun> = | |
| 189 | get(url("repos", owner, name, "flow", "runs"), ListSerializer(FlowRun.serializer())) | |
| 190 | ||
| 191 | override suspend fun run(owner: String, name: String, number: Long): FlowRun = | |
| 192 | get(url("repos", owner, name, "flow", "runs", number.toString()), FlowRun.serializer()) | |
| 193 | ||
| 194 | override suspend fun jobLog(owner: String, name: String, number: Long, job: String, after: Long): List<LogLine> = | |
| 195 | get( | |
| 196 | url("repos", owner, name, "flow", "runs", number.toString(), "jobs", job, "log") { addQueryParameter("after", after.toString()) }, | |
| 197 | ListSerializer(LogLine.serializer()), | |
| 198 | ) | |
| 199 | ||
| 200 | override suspend fun rerunRun(owner: String, name: String, number: Long) { | |
| 201 | send("POST", url("repos", owner, name, "flow", "runs", number.toString(), "rerun")) | |
| 202 | } | |
| 203 | ||
| 204 | override suspend fun rerunJob(owner: String, name: String, number: Long, job: String) { | |
| 205 | send("POST", url("repos", owner, name, "flow", "runs", number.toString(), "jobs", job, "rerun")) | |
| 206 | } | |
| 207 | ||
| 208 | override suspend fun cancelRun(owner: String, name: String, number: Long) { | |
| 209 | send("POST", url("repos", owner, name, "flow", "runs", number.toString(), "cancel")) | |
| 210 | } | |
| 211 | ||
| 212 | override suspend fun projects(owner: String): List<Project> = get(url("projects", owner), ListSerializer(Project.serializer())) | |
| 213 | ||
| 214 | override suspend fun board(owner: String, number: Long): Board = get(url("projects", owner, number.toString()), Board.serializer()) | |
| 215 | ||
| 216 | override suspend fun moveCard(owner: String, number: Long, card: Long, column: String) { | |
| 217 | val json = buildJsonObject { put("column", column) } | |
| 218 | call(request(url("projects", owner, number.toString(), "cards", card.toString())).method("PATCH", json.toString().toRequestBody(JsonMedia)).build(), true) | |
| 219 | } | |
| 220 | ||
| 221 | override suspend fun issue(owner: String, name: String, number: Long): Issue = | |
| 222 | get(url("repos", owner, name, "issues", number.toString()), Issue.serializer()) | |
| 223 | ||
| 224 | override suspend fun issueComments(owner: String, name: String, number: Long): List<Comment> = | |
| 225 | get(url("repos", owner, name, "issues", number.toString(), "comments"), ListSerializer(Comment.serializer())) | |
| 226 | ||
| 227 | override suspend fun comment(owner: String, name: String, number: Long, body: String): Comment = | |
| 228 | sendJson("POST", url("repos", owner, name, "issues", number.toString(), "comments"), buildJsonObject { put("body", body) }, Comment.serializer()) | |
| 229 | ||
| 230 | override suspend fun setIssueState(owner: String, name: String, number: Long, open: Boolean, reason: String): Issue = | |
| 231 | sendJson( | |
| 232 | "POST", | |
| 233 | url("repos", owner, name, "issues", number.toString()), | |
| 234 | buildJsonObject { | |
| 235 | put("state", if (open) "open" else "closed") | |
| 236 | if (!open) put("reason", reason) | |
| 237 | }, | |
| 238 | Issue.serializer(), | |
| 239 | ) | |
| 240 | ||
| 241 | override suspend fun createIssue(owner: String, name: String, title: String, body: String): Issue = | |
| 242 | sendJson("POST", url("repos", owner, name, "issues"), buildJsonObject { | |
| 243 | put("title", title) | |
| 244 | put("body", body) | |
| 245 | }, Issue.serializer()) | |
| 246 | ||
| 247 | override suspend fun labels(owner: String, name: String): List<Label> = | |
| 248 | get(url("repos", owner, name, "labels"), ListSerializer(Label.serializer())) | |
| 249 | ||
| 250 | override suspend fun setIssueLabels(owner: String, name: String, number: Long, labels: List<String>): Issue = | |
| 251 | sendJson("PATCH", url("repos", owner, name, "issues", number.toString()), buildJsonObject { | |
| 252 | put("labels", JsonArray(labels.map { JsonPrimitive(it) })) | |
| 253 | }, Issue.serializer()) | |
| 254 | ||
| 255 | override suspend fun account(name: String): User = get(url("users", name), User.serializer()) | |
| 256 | ||
| 257 | override suspend fun organisation(name: String): Organisation = get(url("orgs", name), Organisation.serializer()) | |
| 258 | ||
| 259 | override suspend fun repository(owner: String, name: String): Repository = get(url("repos", owner, name), Repository.serializer()) | |
| 260 | ||
| 261 | private fun parts(path: String): Array<String> = path.split('/').filter { it.isNotEmpty() }.toTypedArray() | |
| 262 | ||
| 263 | override suspend fun tree(owner: String, name: String, ref: String, path: String): List<TreeEntry> = | |
| 264 | get(url("repos", owner, name, "tree", ref, *parts(path)), ListSerializer(TreeEntry.serializer())) | |
| 265 | ||
| 266 | override suspend fun file(owner: String, name: String, ref: String, path: String): String = | |
| 267 | call(request(url("repos", owner, name, "file", ref, *parts(path))).get().build(), notifyUnauthorized = true) | |
| 268 | ||
| 269 | override suspend fun history(owner: String, name: String, ref: String, path: String?, offset: Int, limit: Int): History = | |
| 270 | get( | |
| 271 | url("repos", owner, name, "history", ref) { | |
| 272 | path?.let { addQueryParameter("path", it) } | |
| 273 | addQueryParameter("offset", offset.toString()) | |
| 274 | addQueryParameter("limit", limit.toString()) | |
| 275 | }, | |
| 276 | History.serializer(), | |
| 277 | ) | |
| 278 | ||
| 279 | override suspend fun review(owner: String, name: String, number: Long, verdict: String, body: String) { | |
| 280 | sendJson("POST", url("repos", owner, name, "proposals", number.toString(), "reviews"), buildJsonObject { | |
| 281 | put("verdict", verdict) | |
| 282 | put("body", body) | |
| 283 | }, ReviewPosted.serializer()) | |
| 284 | } | |
| 285 | ||
| 286 | override suspend fun proposalDiff(owner: String, name: String, number: Long): List<FileChange> = | |
| 287 | get(url("repos", owner, name, "proposals", number.toString(), "diff"), ListSerializer(FileChange.serializer())) | |
| 288 | ||
| 289 | override suspend fun change(owner: String, name: String, hash: String): ChangeDetail = | |
| 290 | get(url("repos", owner, name, "changes", hash), ChangeDetail.serializer()) | |
| 291 | ||
| 292 | override suspend fun refs(owner: String, name: String): Pair<List<String>, List<String>> { | |
| 293 | val bookmarks = get(url("repos", owner, name, "bookmarks"), ListSerializer(NamedRef.serializer())).map { it.name } | |
| 294 | val tags = get(url("repos", owner, name, "tags"), ListSerializer(NamedRef.serializer())).map { it.name } | |
| 295 | return bookmarks to tags | |
| 296 | } | |
| 297 | ||
| 298 | override suspend fun userProposals(landable: Boolean): UserProposals = | |
| 299 | get(url("user", "proposals") { if (landable) addQueryParameter("landable", "true") }, UserProposals.serializer()) | |
| 300 | ||
| 301 | override suspend fun proposals(owner: String, name: String, state: ProposalState): List<Proposal> = | |
| 302 | get(url("repos", owner, name, "proposals") { addQueryParameter("state", state.query) }, ListSerializer(Proposal.serializer())) | |
| 303 | ||
| 304 | override suspend fun proposal(owner: String, name: String, number: Long): Proposal = | |
| 305 | get(url("repos", owner, name, "proposals", number.toString()), Proposal.serializer()) | |
| 306 | ||
| 307 | override suspend fun checks(owner: String, name: String, number: Long): List<Check> = | |
| 308 | get(url("repos", owner, name, "proposals", number.toString(), "checks"), ListSerializer(Check.serializer())) | |
| 309 | ||
| 310 | override suspend fun land(owner: String, name: String, number: Long): Proposal = | |
| 311 | send("POST", url("repos", owner, name, "proposals", number.toString(), "land"), Proposal.serializer()) | |
| 312 | ||
| 313 | override suspend fun setAutoLand(owner: String, name: String, number: Long, on: Boolean): Proposal = | |
| 314 | send(if (on) "POST" else "DELETE", url("repos", owner, name, "proposals", number.toString(), "auto-land"), Proposal.serializer()) | |
| 315 | ||
| 316 | override suspend fun markRead(id: Long) { | |
| 317 | send("POST", url("notifications", id.toString(), "read")) | |
| 318 | } | |
| 319 | ||
| 320 | override suspend fun markAllRead() { | |
| 321 | send("POST", url("notifications", "read")) | |
| 322 | } | |
| 323 | ||
| 324 | override suspend fun revokeToken() { | |
| 325 | send("DELETE", url("user", "token"), notifyUnauthorized = false) | |
| 326 | } | |
| 327 | ||
| 328 | override suspend fun registerPush(fcmToken: String, publicKey: String): Boolean = | |
| 329 | sendJson("PUT", url("user", "push"), buildJsonObject { | |
| 330 | put("fcm_token", fcmToken) | |
| 331 | put("public_key", publicKey) | |
| 332 | }, PushRegistered.serializer()).alerts | |
| 333 | ||
| 334 | override suspend fun avatar(name: String): Avatar? { | |
| 335 | val url = root.newBuilder().addPathSegment("avatar").addPathSegment(name).build() | |
| 336 | // A public picture: no token goes with it. | |
| 337 | val response = try { | |
| 338 | http.newCall(Request.Builder().url(url).get().build()).await() | |
| 339 | } catch (e: IOException) { | |
| 340 | throw HubException.Network(e) | |
| 341 | } | |
| 342 | response.use { r -> | |
| 343 | if (r.code == 404) return null | |
| 344 | val bytes = try { | |
| 345 | r.body?.bytes() ?: ByteArray(0) | |
| 346 | } catch (e: IOException) { | |
| 347 | throw HubException.Network(e) | |
| 348 | } | |
| 349 | if (!r.isSuccessful) throw HubException.Http(r.code, errorDetail(bytes.decodeToString())) | |
| 350 | return Avatar.parse(r.header("Content-Type"), bytes) | |
| 351 | } | |
| 352 | } | |
| 353 | ||
| 354 | // The bearer token is never part of a string anything prints. | |
| 355 | override fun toString(): String = "HubClient(${base})" | |
| 356 | ||
| 357 | private fun url(vararg segments: String, query: HttpUrl.Builder.() -> Unit = {}): HttpUrl { | |
| 358 | val b = base.newBuilder() | |
| 359 | // Each segment is encoded on its own: an owner or name never adds a path level. | |
| 360 | segments.forEach { b.addPathSegment(it) } | |
| 361 | b.query() | |
| 362 | return b.build() | |
| 363 | } | |
| 364 | ||
| 365 | private suspend fun <T> get(path: String, serializer: KSerializer<T>): T = get(url(*path.split('/').toTypedArray()), serializer) | |
| 366 | ||
| 367 | private suspend fun <T> get(url: HttpUrl, serializer: KSerializer<T>): T { | |
| 368 | val body = call(request(url).get().build(), notifyUnauthorized = true) | |
| 369 | return try { | |
| 370 | HubJson.decodeFromString(serializer, body) | |
| 371 | } catch (e: IllegalArgumentException) { | |
| 372 | // kotlinx.serialization's errors are IllegalArgumentExceptions. | |
| 373 | throw HubException.Unreadable("GET ${url.encodedPath}", e) | |
| 374 | } | |
| 375 | } | |
| 376 | ||
| 377 | private suspend fun send(method: String, url: HttpUrl, notifyUnauthorized: Boolean = true): String { | |
| 378 | val body = if (method == "DELETE") null else "{}".toRequestBody(JsonMedia) | |
| 379 | return call(request(url).method(method, body).build(), notifyUnauthorized) | |
| 380 | } | |
| 381 | ||
| 382 | private suspend fun <T> send(method: String, url: HttpUrl, serializer: KSerializer<T>): T { | |
| 383 | val body = send(method, url) | |
| 384 | return try { | |
| 385 | HubJson.decodeFromString(serializer, body) | |
| 386 | } catch (e: IllegalArgumentException) { | |
| 387 | throw HubException.Unreadable("$method ${url.encodedPath}", e) | |
| 388 | } | |
| 389 | } | |
| 390 | ||
| 391 | /** [json] as the body; the answer read with [serializer]. */ | |
| 392 | private suspend fun <T> sendJson(method: String, url: HttpUrl, json: JsonObject, serializer: KSerializer<T>): T { | |
| 393 | val body = call(request(url).method(method, json.toString().toRequestBody(JsonMedia)).build(), notifyUnauthorized = true) | |
| 394 | return try { | |
| 395 | HubJson.decodeFromString(serializer, body) | |
| 396 | } catch (e: IllegalArgumentException) { | |
| 397 | throw HubException.Unreadable("$method ${url.encodedPath}", e) | |
| 398 | } | |
| 399 | } | |
| 400 | ||
| 401 | private fun request(url: HttpUrl): Request.Builder = Request.Builder() | |
| 402 | .url(url) | |
| 403 | .header("Accept", "application/json") | |
| 404 | .header("Authorization", "Bearer $token") | |
| 405 | ||
| 406 | private suspend fun call(request: Request, notifyUnauthorized: Boolean): String { | |
| 407 | val response = try { | |
| 408 | http.newCall(request).await() | |
| 409 | } catch (e: IOException) { | |
| 410 | throw HubException.Network(e) | |
| 411 | } | |
| 412 | response.use { r -> | |
| 413 | val text = try { | |
| 414 | r.body?.string().orEmpty() | |
| 415 | } catch (e: IOException) { | |
| 416 | throw HubException.Network(e) | |
| 417 | } | |
| 418 | if (!r.isSuccessful) { | |
| 419 | if (r.code == 401 && notifyUnauthorized) onUnauthorized() | |
| 420 | throw HubException.Http(r.code, errorDetail(text)) | |
| 421 | } | |
| 422 | return text | |
| 423 | } | |
| 424 | } | |
| 425 | } |