Skip to content

Commit 71334bc

Browse files
committed
Handle all errors from Google Tasks API using a Ktor HttpResponseValidator with expectSuccess
1 parent 8318ac8 commit 71334bc

File tree

10 files changed

+205
-120
lines changed

10 files changed

+205
-120
lines changed

google/tasks/src/commonMain/kotlin/net/opatry/google/tasks/TaskListsApi.kt

Lines changed: 6 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ package net.opatry.google.tasks
2424

2525
import io.ktor.client.HttpClient
2626
import io.ktor.client.call.body
27-
import io.ktor.client.plugins.ClientRequestException
2827
import io.ktor.client.plugins.compression.compress
2928
import io.ktor.client.request.delete
3029
import io.ktor.client.request.get
@@ -33,10 +32,8 @@ import io.ktor.client.request.patch
3332
import io.ktor.client.request.post
3433
import io.ktor.client.request.put
3534
import io.ktor.client.request.setBody
36-
import io.ktor.client.statement.bodyAsText
3735
import io.ktor.http.ContentType
3836
import io.ktor.http.contentType
39-
import io.ktor.http.isSuccess
4037
import net.opatry.google.tasks.model.ResourceListResponse
4138
import net.opatry.google.tasks.model.ResourceType
4239
import net.opatry.google.tasks.model.TaskList
@@ -114,24 +111,14 @@ class HttpTaskListsApi(
114111
) : TaskListsApi {
115112
override suspend fun delete(taskListId: String) {
116113
val response = httpClient.delete("tasks/v1/users/@me/lists/${taskListId}")
117-
118-
if (response.status.isSuccess()) {
119-
return response.body()
120-
} else {
121-
throw ClientRequestException(response, response.bodyAsText())
122-
}
114+
return response.body()
123115
}
124116

125117
override suspend fun default() = get("@default")
126118

127119
override suspend fun get(taskListId: String): TaskList {
128120
val response = httpClient.get("tasks/v1/users/@me/lists/${taskListId}")
129-
130-
if (response.status.isSuccess()) {
131-
return response.body()
132-
} else {
133-
throw ClientRequestException(response, response.bodyAsText())
134-
}
121+
return response.body()
135122
}
136123

137124
override suspend fun insert(taskList: TaskList): TaskList {
@@ -140,12 +127,7 @@ class HttpTaskListsApi(
140127
compress("gzip")
141128
setBody(taskList)
142129
}
143-
144-
if (response.status.isSuccess()) {
145-
return response.body()
146-
} else {
147-
throw ClientRequestException(response, response.bodyAsText())
148-
}
130+
return response.body()
149131
}
150132

151133
override suspend fun list(maxResults: Int, pageToken: String?): ResourceListResponse<TaskList> {
@@ -155,37 +137,23 @@ class HttpTaskListsApi(
155137
parameter("pageToken", pageToken)
156138
}
157139
}
158-
if (response.status.isSuccess()) {
159-
return response.body()
160-
} else {
161-
throw ClientRequestException(response, response.bodyAsText())
162-
}
140+
return response.body()
163141
}
164142

165143
override suspend fun patch(taskListId: String, taskList: TaskList): TaskList {
166144
val response = httpClient.patch("tasks/v1/users/@me/lists/${taskListId}") {
167145
contentType(ContentType.Application.Json)
168146
setBody(taskList)
169147
}
170-
171-
if (response.status.isSuccess()) {
172-
return response.body()
173-
} else {
174-
throw ClientRequestException(response, response.bodyAsText())
175-
}
148+
return response.body()
176149
}
177150

178151
override suspend fun update(taskListId: String, taskList: TaskList): TaskList {
179152
val response = httpClient.put("tasks/v1/users/@me/lists/${taskListId}") {
180153
contentType(ContentType.Application.Json)
181154
setBody(taskList)
182155
}
183-
184-
if (response.status.isSuccess()) {
185-
return response.body()
186-
} else {
187-
throw ClientRequestException(response, response.bodyAsText())
188-
}
156+
return response.body()
189157
}
190158
}
191159

google/tasks/src/commonMain/kotlin/net/opatry/google/tasks/TasksApi.kt

Lines changed: 8 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ package net.opatry.google.tasks
2424

2525
import io.ktor.client.HttpClient
2626
import io.ktor.client.call.body
27-
import io.ktor.client.plugins.ClientRequestException
2827
import io.ktor.client.plugins.compression.compress
2928
import io.ktor.client.request.delete
3029
import io.ktor.client.request.get
@@ -33,10 +32,8 @@ import io.ktor.client.request.patch
3332
import io.ktor.client.request.post
3433
import io.ktor.client.request.put
3534
import io.ktor.client.request.setBody
36-
import io.ktor.client.statement.bodyAsText
3735
import io.ktor.http.ContentType
3836
import io.ktor.http.contentType
39-
import io.ktor.http.isSuccess
4037
import kotlinx.datetime.Instant
4138
import net.opatry.google.tasks.model.ResourceListResponse
4239
import net.opatry.google.tasks.model.ResourceType
@@ -164,34 +161,19 @@ class HttpTasksApi(
164161
) : TasksApi {
165162
override suspend fun clear(taskListId: String) {
166163
val response = httpClient.post("tasks/v1/lists/${taskListId}/clear")
167-
168-
if (response.status.isSuccess()) {
169-
return response.body()
170-
} else {
171-
throw ClientRequestException(response, response.bodyAsText())
172-
}
164+
return response.body()
173165
}
174166

175167
override suspend fun delete(taskListId: String, taskId: String) {
176168
val response = httpClient.delete("tasks/v1/lists/${taskListId}/tasks/${taskId}")
177-
178-
if (response.status.isSuccess()) {
179-
return response.body()
180-
} else {
181-
throw ClientRequestException(response, response.bodyAsText())
182-
}
169+
return response.body()
183170
}
184171

185172
override suspend fun get(taskListId: String, taskId: String): Task {
186173
val response = httpClient.get("tasks/v1/lists/${taskListId}/tasks/${taskId}") {
187174
contentType(ContentType.Application.Json)
188175
}
189-
190-
if (response.status.isSuccess()) {
191-
return response.body()
192-
} else {
193-
throw ClientRequestException(response, response.bodyAsText())
194-
}
176+
return response.body()
195177
}
196178

197179
override suspend fun insert(taskListId: String, task: Task, parentTaskId: String?, previousTaskId: String?): Task {
@@ -206,12 +188,7 @@ class HttpTasksApi(
206188
compress("gzip")
207189
setBody(task)
208190
}
209-
210-
if (response.status.isSuccess()) {
211-
return response.body()
212-
} else {
213-
throw ClientRequestException(response, response.bodyAsText())
214-
}
191+
return response.body()
215192
}
216193

217194
override suspend fun list(
@@ -253,12 +230,7 @@ class HttpTasksApi(
253230
}
254231
parameter("showAssigned", showAssigned.toString())
255232
}
256-
257-
if (response.status.isSuccess()) {
258-
return response.body()
259-
} else {
260-
throw ClientRequestException(response, response.bodyAsText())
261-
}
233+
return response.body()
262234
}
263235

264236
override suspend fun move(
@@ -276,42 +248,26 @@ class HttpTasksApi(
276248
parameter("previous", previousTaskId)
277249
}
278250
if (destinationTaskListId != null) {
279-
@Suppress("SpellCheckingInspection")
280251
parameter("destinationTasklist", destinationTaskListId)
281252
}
282253
}
283-
284-
if (response.status.isSuccess()) {
285-
return response.body()
286-
} else {
287-
throw ClientRequestException(response, response.bodyAsText())
288-
}
254+
return response.body()
289255
}
290256

291257
override suspend fun patch(taskListId: String, taskId: String, task: Task): Task {
292258
val response = httpClient.patch("tasks/v1/lists/${taskListId}/tasks/${taskId}") {
293259
contentType(ContentType.Application.Json)
294260
setBody(task)
295261
}
296-
297-
if (response.status.isSuccess()) {
298-
return response.body()
299-
} else {
300-
throw ClientRequestException(response, response.bodyAsText())
301-
}
262+
return response.body()
302263
}
303264

304265
override suspend fun update(taskListId: String, taskId: String, task: Task): Task {
305266
val response = httpClient.put("tasks/v1/lists/${taskListId}/tasks/${taskId}") {
306267
contentType(ContentType.Application.Json)
307268
setBody(task)
308269
}
309-
310-
if (response.status.isSuccess()) {
311-
return response.body()
312-
} else {
313-
throw ClientRequestException(response, response.bodyAsText())
314-
}
270+
return response.body()
315271
}
316272
}
317273

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright (c) 2025 Olivier Patry
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining
5+
* a copy of this software and associated documentation files (the "Software"),
6+
* to deal in the Software without restriction, including without limitation
7+
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
8+
* and/or sell copies of the Software, and to permit persons to whom the Software
9+
* is furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in
12+
* all copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15+
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
16+
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17+
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18+
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19+
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
20+
* OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21+
*/
22+
23+
package net.opatry.google.tasks
24+
25+
import net.opatry.google.tasks.model.ErrorResponse
26+
27+
class TasksApiException(val errorResponse: ErrorResponse) : Exception()
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright (c) 2025 Olivier Patry
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining
5+
* a copy of this software and associated documentation files (the "Software"),
6+
* to deal in the Software without restriction, including without limitation
7+
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
8+
* and/or sell copies of the Software, and to permit persons to whom the Software
9+
* is furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in
12+
* all copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15+
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
16+
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17+
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18+
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19+
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
20+
* OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21+
*/
22+
23+
package net.opatry.google.tasks
24+
25+
import io.ktor.client.plugins.HttpCallValidatorConfig
26+
import io.ktor.client.plugins.ResponseException
27+
import io.ktor.client.statement.bodyAsText
28+
import kotlinx.serialization.json.Json
29+
import net.opatry.google.tasks.model.ErrorResponse
30+
31+
class TasksApiHttpResponseValidator(private val host: String) : (HttpCallValidatorConfig) -> Unit {
32+
override fun invoke(config: HttpCallValidatorConfig) {
33+
config.handleResponseExceptionWithRequest { exception, request ->
34+
when {
35+
request.url.host == host && exception is ResponseException -> {
36+
// can't rely on default ktor deserialization for error responses
37+
// the ContentEncoding plugin is short-circuited for error responses
38+
// need to manually decode the error response body
39+
val errorBody = exception.response.bodyAsText()
40+
val error = Json.decodeFromString<ErrorResponse>(errorBody)
41+
throw TasksApiException(error)
42+
}
43+
44+
else -> throw exception
45+
}
46+
}
47+
}
48+
}

google/tasks/src/commonMain/kotlin/net/opatry/google/tasks/model/ErrorResponse.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2024 Olivier Patry
2+
* Copyright (c) 2025 Olivier Patry
33
*
44
* Permission is hereby granted, free of charge, to any person obtaining
55
* a copy of this software and associated documentation files (the "Software"),
@@ -28,7 +28,7 @@ import kotlinx.serialization.Serializable
2828
@Serializable
2929
data class ErrorResponse(
3030
@SerialName("error")
31-
val error: Error
31+
val error: Error,
3232
) {
3333
@Serializable
3434
data class Error(

0 commit comments

Comments
 (0)