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 #1665 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 | } |