OXYGENHUB
# ff65ea456ff1 oxygenhub-android / app / src / main / java / io / oxygenhub / android / api / HubClient.kt
8.8 KiB · last changed Notifications when a proposal can be landed; the runner comes back after an update or restart (closes #20, refs #8) 10 days ago source blame history raw
1
package io.oxygenhub.android.api
2
3
import kotlinx.serialization.KSerializer
4
import kotlinx.serialization.builtins.ListSerializer
5
import okhttp3.HttpUrl
6
import okhttp3.HttpUrl.Companion.toHttpUrl
7
import okhttp3.OkHttpClient
8
import okhttp3.Request
9
import okhttp3.RequestBody.Companion.toRequestBody
10
import java.io.IOException
11
12
/**
13
 * What the app asks of a hub, signed in. The view models see only this, so
14
 * their tests give them a fake. Later screens (oxygenhub-android#3–#5) add
15
 * to it: a repository's tree, history, proposals, runs.
16
 */
17
interface HubApi {
18
    /** The signed-in account (`GET /user`, scope `user:read`). */
19
    suspend fun user(): User
20
21
    /** Every repository the caller can read (`GET /repos`). */
22
    suspend fun repositories(): List<Repository>
23
24
    /** A repository's issues (`GET /repos/{owner}/{name}/issues?state=`). */
25
    suspend fun issues(owner: String, name: String, state: IssueState = IssueState.Open): List<Issue>
26
27
    /** The inbox: the unread count and the newest entries (`GET /notifications`). */
28
    suspend fun inbox(limit: Int = 50): Inbox
29
30
    /** Marks one notification read (`POST /notifications/{id}/read`). */
31
    suspend fun markRead(id: Long)
32
33
    /** Marks the whole inbox read (`POST /notifications/read`). */
34
    suspend fun markAllRead()
35
36
    /** Revokes the token this client signs in with (`DELETE /user/token`). */
37
    suspend fun revokeToken()
38
39
    /**
40
     * The proposals the caller should act on, across repositories (the hub's
41
     * dashboard); with [landable], also those it can land now (#20, oxygenhub#311).
42
     */
43
    suspend fun userProposals(landable: Boolean = false): UserProposals
44
45
    /** A repository's proposals: open (with drafts), landed, closed or all. */
46
    suspend fun proposals(owner: String, name: String, state: ProposalState = ProposalState.Open): List<Proposal>
47
48
    suspend fun proposal(owner: String, name: String, number: Long): Proposal
49
50
    /** The checks on a proposal's projected state. */
51
    suspend fun checks(owner: String, name: String, number: Long): List<Check>
52
53
    /**
54
     * Land it (`repo:write`); the proposal as landed. The hub refuses with 409
55
     * and its reason when it may not land, and 403 when the token lacks the scope.
56
     */
57
    suspend fun land(owner: String, name: String, number: Long): Proposal
58
59
    /** Land it by itself when its checks pass, or not (`repo:write`); the proposal after. */
60
    suspend fun setAutoLand(owner: String, name: String, number: Long, on: Boolean): Proposal
61
62
    /** A one-time token registering this phone as a runner for the account's repositories (`repo:write`). */
63
    suspend fun runnerToken(): RunnerToken
64
65
    /** An account's picture (`/avatar/{name}`, public); null when the hub has none this app can draw. */
66
    suspend fun avatar(name: String): Avatar?
67
}
68
69
/**
70
 * The hub's JSON API over OkHttp. [onUnauthorized] runs when the hub
71
 * refuses the token (revoked on the web, or 90 days unused), so the app
72
 * can drop the session and ask for a new sign-in.
73
 */
74
class HubClient(
75
    hub: String,
76
    private val token: String,
77
    private val http: OkHttpClient,
78
    private val onUnauthorized: () -> Unit = {},
79
) : HubApi {
80
    private val base: HttpUrl = "${hub.trimEnd('/')}/api/v1/".toHttpUrl()
81
    private val root: HttpUrl = "${hub.trimEnd('/')}/".toHttpUrl()
82
83
    override suspend fun user(): User = get("user", User.serializer())
84
85
    override suspend fun repositories(): List<Repository> = get("repos", ListSerializer(Repository.serializer()))
86
87
    override suspend fun issues(owner: String, name: String, state: IssueState): List<Issue> =
88
        get(url("repos", owner, name, "issues") { addQueryParameter("state", state.query) }, ListSerializer(Issue.serializer()))
89
90
    override suspend fun inbox(limit: Int): Inbox =
91
        get(url("notifications") { addQueryParameter("limit", limit.coerceIn(1, 200).toString()) }, Inbox.serializer())
92
93
    override suspend fun runnerToken(): RunnerToken = send("POST", url("user", "runner-token"), RunnerToken.serializer())
94
95
    override suspend fun userProposals(landable: Boolean): UserProposals =
96
        get(url("user", "proposals") { if (landable) addQueryParameter("landable", "true") }, UserProposals.serializer())
97
98
    override suspend fun proposals(owner: String, name: String, state: ProposalState): List<Proposal> =
99
        get(url("repos", owner, name, "proposals") { addQueryParameter("state", state.query) }, ListSerializer(Proposal.serializer()))
100
101
    override suspend fun proposal(owner: String, name: String, number: Long): Proposal =
102
        get(url("repos", owner, name, "proposals", number.toString()), Proposal.serializer())
103
104
    override suspend fun checks(owner: String, name: String, number: Long): List<Check> =
105
        get(url("repos", owner, name, "proposals", number.toString(), "checks"), ListSerializer(Check.serializer()))
106
107
    override suspend fun land(owner: String, name: String, number: Long): Proposal =
108
        send("POST", url("repos", owner, name, "proposals", number.toString(), "land"), Proposal.serializer())
109
110
    override suspend fun setAutoLand(owner: String, name: String, number: Long, on: Boolean): Proposal =
111
        send(if (on) "POST" else "DELETE", url("repos", owner, name, "proposals", number.toString(), "auto-land"), Proposal.serializer())
112
113
    override suspend fun markRead(id: Long) {
114
        send("POST", url("notifications", id.toString(), "read"))
115
    }
116
117
    override suspend fun markAllRead() {
118
        send("POST", url("notifications", "read"))
119
    }
120
121
    override suspend fun revokeToken() {
122
        send("DELETE", url("user", "token"), notifyUnauthorized = false)
123
    }
124
125
    override suspend fun avatar(name: String): Avatar? {
126
        val url = root.newBuilder().addPathSegment("avatar").addPathSegment(name).build()
127
        // A public picture: no token goes with it.
128
        val response = try {
129
            http.newCall(Request.Builder().url(url).get().build()).await()
130
        } catch (e: IOException) {
131
            throw HubException.Network(e)
132
        }
133
        response.use { r ->
134
            if (r.code == 404) return null
135
            val bytes = try {
136
                r.body?.bytes() ?: ByteArray(0)
137
            } catch (e: IOException) {
138
                throw HubException.Network(e)
139
            }
140
            if (!r.isSuccessful) throw HubException.Http(r.code, errorDetail(bytes.decodeToString()))
141
            return Avatar.parse(r.header("Content-Type"), bytes)
142
        }
143
    }
144
145
    // The bearer token is never part of a string anything prints.
146
    override fun toString(): String = "HubClient(${base})"
147
148
    private fun url(vararg segments: String, query: HttpUrl.Builder.() -> Unit = {}): HttpUrl {
149
        val b = base.newBuilder()
150
        // Each segment is encoded on its own: an owner or name never adds a path level.
151
        segments.forEach { b.addPathSegment(it) }
152
        b.query()
153
        return b.build()
154
    }
155
156
    private suspend fun <T> get(path: String, serializer: KSerializer<T>): T = get(url(*path.split('/').toTypedArray()), serializer)
157
158
    private suspend fun <T> get(url: HttpUrl, serializer: KSerializer<T>): T {
159
        val body = call(request(url).get().build(), notifyUnauthorized = true)
160
        return try {
161
            HubJson.decodeFromString(serializer, body)
162
        } catch (e: IllegalArgumentException) {
163
            // kotlinx.serialization's errors are IllegalArgumentExceptions.
164
            throw HubException.Unreadable("GET ${url.encodedPath}", e)
165
        }
166
    }
167
168
    private suspend fun send(method: String, url: HttpUrl, notifyUnauthorized: Boolean = true): String {
169
        val body = if (method == "DELETE") null else "{}".toRequestBody(JsonMedia)
170
        return call(request(url).method(method, body).build(), notifyUnauthorized)
171
    }
172
173
    private suspend fun <T> send(method: String, url: HttpUrl, serializer: KSerializer<T>): T {
174
        val body = send(method, url)
175
        return try {
176
            HubJson.decodeFromString(serializer, body)
177
        } catch (e: IllegalArgumentException) {
178
            throw HubException.Unreadable("$method ${url.encodedPath}", e)
179
        }
180
    }
181
182
    private fun request(url: HttpUrl): Request.Builder = Request.Builder()
183
        .url(url)
184
        .header("Accept", "application/json")
185
        .header("Authorization", "Bearer $token")
186
187
    private suspend fun call(request: Request, notifyUnauthorized: Boolean): String {
188
        val response = try {
189
            http.newCall(request).await()
190
        } catch (e: IOException) {
191
            throw HubException.Network(e)
192
        }
193
        response.use { r ->
194
            val text = try {
195
                r.body?.string().orEmpty()
196
            } catch (e: IOException) {
197
                throw HubException.Network(e)
198
            }
199
            if (!r.isSuccessful) {
200
                if (r.code == 401 && notifyUnauthorized) onUnauthorized()
201
                throw HubException.Http(r.code, errorDetail(text))
202
            }
203
            return text
204
        }
205
    }
206
}