diff --git a/build.gradle.kts b/build.gradle.kts index 9e6cb606..97a458d0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,6 +38,7 @@ plugins { alias(libs.plugins.firebase.crashlytics) apply false alias(libs.plugins.about.libraries) apply false alias(libs.plugins.kover) + alias(libs.plugins.ktorfit) apply false } val koverProjects = listOf( diff --git a/google/tasks/build.gradle.kts b/google/tasks/build.gradle.kts index 63f6a890..605f20e8 100644 --- a/google/tasks/build.gradle.kts +++ b/google/tasks/build.gradle.kts @@ -23,6 +23,8 @@ plugins { alias(libs.plugins.jetbrains.kotlin.multiplatform) alias(libs.plugins.jetbrains.kotlin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.ktorfit) } kotlin { @@ -34,6 +36,8 @@ kotlin { commonMain.dependencies { implementation(libs.kotlinx.datetime) implementation(libs.bundles.ktor.client) + + implementation(libs.ktorfit) } commonTest.dependencies { diff --git a/google/tasks/src/commonMain/kotlin/net/opatry/google/tasks/ClientRequestExceptionConverter.kt b/google/tasks/src/commonMain/kotlin/net/opatry/google/tasks/ClientRequestExceptionConverter.kt new file mode 100644 index 00000000..93110484 --- /dev/null +++ b/google/tasks/src/commonMain/kotlin/net/opatry/google/tasks/ClientRequestExceptionConverter.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2024 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.google.tasks + +import de.jensklingenberg.ktorfit.Ktorfit +import de.jensklingenberg.ktorfit.converter.Converter +import de.jensklingenberg.ktorfit.converter.KtorfitResult +import de.jensklingenberg.ktorfit.converter.TypeData +import io.ktor.client.call.body +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.isSuccess + +object ClientRequestExceptionConverterFactory : Converter.Factory { + + override fun suspendResponseConverter( + typeData: TypeData, + ktorfit: Ktorfit + ): Converter.SuspendResponseConverter { + return object : Converter.SuspendResponseConverter { + override suspend fun convert(result: KtorfitResult): Any { + when (result) { + is KtorfitResult.Success -> { + val response = result.response + if (response.status.isSuccess()) { + return response.body(typeData.typeInfo) + } else { + throw ClientRequestException(response, response.bodyAsText()) + } + } + is KtorfitResult.Failure -> { + throw result.throwable + } + } + } + } + } +} \ No newline at end of file diff --git a/google/tasks/src/commonMain/kotlin/net/opatry/google/tasks/TaskListsApi.kt b/google/tasks/src/commonMain/kotlin/net/opatry/google/tasks/TaskListsApi.kt index c6382c19..b3f9953f 100644 --- a/google/tasks/src/commonMain/kotlin/net/opatry/google/tasks/TaskListsApi.kt +++ b/google/tasks/src/commonMain/kotlin/net/opatry/google/tasks/TaskListsApi.kt @@ -22,21 +22,15 @@ package net.opatry.google.tasks -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.plugins.ClientRequestException -import io.ktor.client.plugins.compression.compress -import io.ktor.client.request.delete -import io.ktor.client.request.get -import io.ktor.client.request.parameter -import io.ktor.client.request.patch -import io.ktor.client.request.post -import io.ktor.client.request.put -import io.ktor.client.request.setBody -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.http.contentType -import io.ktor.http.isSuccess +import de.jensklingenberg.ktorfit.http.Body +import de.jensklingenberg.ktorfit.http.DELETE +import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.Headers +import de.jensklingenberg.ktorfit.http.PATCH +import de.jensklingenberg.ktorfit.http.POST +import de.jensklingenberg.ktorfit.http.PUT +import de.jensklingenberg.ktorfit.http.Path +import de.jensklingenberg.ktorfit.http.Query import net.opatry.google.tasks.model.ResourceListResponse import net.opatry.google.tasks.model.ResourceType import net.opatry.google.tasks.model.TaskList @@ -45,12 +39,8 @@ import net.opatry.google.tasks.model.TaskList * Service for interacting with the [Google Task Lists REST API](https://developers.google.com/tasks/reference/rest/v1/tasklists). */ interface TaskListsApi { - /** - * [Deletes the authenticated user's specified task list](https://developers.google.com/tasks/reference/rest/v1/tasklists/delete). If the list contains assigned tasks, both the assigned tasks and the original tasks in the assignment surface (Docs, Chat Spaces) are deleted. - * - * @param taskListId Task list identifier. - */ - suspend fun delete(taskListId: String) + @DELETE("tasks/v1/users/@me/lists/{taskListId}") + suspend fun delete(@Path("taskListId") taskListId: String) /** * [Returns the authenticated user's default task list](https://developers.google.com/tasks/reference/rest/v1/tasklists/get). @@ -58,7 +48,7 @@ interface TaskListsApi { * * @return the instance of [TaskList] of the default task list. */ - suspend fun default(): TaskList + suspend fun default() = get("@default") /** * [Returns the authenticated user's specified task list](https://developers.google.com/tasks/reference/rest/v1/tasklists/get). @@ -67,7 +57,8 @@ interface TaskListsApi { * * @return an instance of [TaskList]. */ - suspend fun get(taskListId: String): TaskList + @GET("tasks/v1/users/@me/lists/{taskListId}") + suspend fun get(@Path("taskListId") taskListId: String): TaskList /** * [Creates a new task list](https://developers.google.com/tasks/reference/rest/v1/tasklists/insert) and adds it to the authenticated user's task lists. A user can have up to 2000 lists at a time. @@ -76,7 +67,12 @@ interface TaskListsApi { * * @return a newly created instance of [TaskList]. */ - suspend fun insert(taskList: TaskList): TaskList + @Headers( + "Content-Type: application/json", + "Content-Encoding: gzip", + ) + @POST("tasks/v1/users/@me/lists") + suspend fun insert(@Body taskList: TaskList): TaskList /** * [Returns all the authenticated user's task lists](https://developers.google.com/tasks/reference/rest/v1/tasklists/list). A user can have up to 2000 lists at a time. @@ -86,7 +82,11 @@ interface TaskListsApi { * * @return an instance of [ResourceListResponse] of type [TaskList], whose type is always [ResourceType.TaskLists]. */ - suspend fun list(maxResults: Int = 20, pageToken: String? = null): ResourceListResponse + @GET("tasks/v1/users/@me/lists") + suspend fun list( + @Query("maxResults") maxResults: Int = 20, + @Query("pageToken") pageToken: String? = null + ): ResourceListResponse /** * [Updates the authenticated user's specified task list](https://developers.google.com/tasks/reference/rest/v1/tasklists/patch). This method supports patch semantics. @@ -96,7 +96,12 @@ interface TaskListsApi { * * @return an instance of [TaskList]. */ - suspend fun patch(taskListId: String, taskList: TaskList): TaskList + @Headers("Content-Type: application/json") + @PATCH("tasks/v1/users/@me/lists/{taskListId}") + suspend fun patch( + @Path("taskListId") taskListId: String, + @Body taskList: TaskList + ): TaskList /** * [Updates the authenticated user's specified task list](https://developers.google.com/tasks/reference/rest/v1/tasklists/update). @@ -106,87 +111,12 @@ interface TaskListsApi { * * @return an instance of [TaskList]. */ - suspend fun update(taskListId: String, taskList: TaskList): TaskList -} - -class HttpTaskListsApi( - private val httpClient: HttpClient -) : TaskListsApi { - override suspend fun delete(taskListId: String) { - val response = httpClient.delete("tasks/v1/users/@me/lists/${taskListId}") - - if (response.status.isSuccess()) { - return response.body() - } else { - throw ClientRequestException(response, response.bodyAsText()) - } - } - - override suspend fun default() = get("@default") - - override suspend fun get(taskListId: String): TaskList { - val response = httpClient.get("tasks/v1/users/@me/lists/${taskListId}") - - if (response.status.isSuccess()) { - return response.body() - } else { - throw ClientRequestException(response, response.bodyAsText()) - } - } - - override suspend fun insert(taskList: TaskList): TaskList { - val response = httpClient.post("tasks/v1/users/@me/lists") { - contentType(ContentType.Application.Json) - compress("gzip") - setBody(taskList) - } - - if (response.status.isSuccess()) { - return response.body() - } else { - throw ClientRequestException(response, response.bodyAsText()) - } - } - - override suspend fun list(maxResults: Int, pageToken: String?): ResourceListResponse { - val response = httpClient.get("tasks/v1/users/@me/lists") { - parameter("maxResults", maxResults.coerceIn(0, 100)) - if (pageToken != null) { - parameter("pageToken", pageToken) - } - } - if (response.status.isSuccess()) { - return response.body() - } else { - throw ClientRequestException(response, response.bodyAsText()) - } - } - - override suspend fun patch(taskListId: String, taskList: TaskList): TaskList { - val response = httpClient.patch("tasks/v1/users/@me/lists/${taskListId}") { - contentType(ContentType.Application.Json) - setBody(taskList) - } - - if (response.status.isSuccess()) { - return response.body() - } else { - throw ClientRequestException(response, response.bodyAsText()) - } - } - - override suspend fun update(taskListId: String, taskList: TaskList): TaskList { - val response = httpClient.put("tasks/v1/users/@me/lists/${taskListId}") { - contentType(ContentType.Application.Json) - setBody(taskList) - } - - if (response.status.isSuccess()) { - return response.body() - } else { - throw ClientRequestException(response, response.bodyAsText()) - } - } + @Headers("Content-Type: application/json") + @PUT("tasks/v1/users/@me/lists/{taskListId}") + suspend fun update( + @Path("taskListId") taskListId: String, + @Body taskList: TaskList + ): TaskList } /** diff --git a/google/tasks/src/commonMain/kotlin/net/opatry/google/tasks/TasksApi.kt b/google/tasks/src/commonMain/kotlin/net/opatry/google/tasks/TasksApi.kt index 1c17c342..d5e21d24 100644 --- a/google/tasks/src/commonMain/kotlin/net/opatry/google/tasks/TasksApi.kt +++ b/google/tasks/src/commonMain/kotlin/net/opatry/google/tasks/TasksApi.kt @@ -22,21 +22,15 @@ package net.opatry.google.tasks -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.plugins.ClientRequestException -import io.ktor.client.plugins.compression.compress -import io.ktor.client.request.delete -import io.ktor.client.request.get -import io.ktor.client.request.parameter -import io.ktor.client.request.patch -import io.ktor.client.request.post -import io.ktor.client.request.put -import io.ktor.client.request.setBody -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.http.contentType -import io.ktor.http.isSuccess +import de.jensklingenberg.ktorfit.http.Body +import de.jensklingenberg.ktorfit.http.DELETE +import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.Headers +import de.jensklingenberg.ktorfit.http.PATCH +import de.jensklingenberg.ktorfit.http.POST +import de.jensklingenberg.ktorfit.http.PUT +import de.jensklingenberg.ktorfit.http.Path +import de.jensklingenberg.ktorfit.http.Query import kotlinx.datetime.Instant import net.opatry.google.tasks.model.ResourceListResponse import net.opatry.google.tasks.model.ResourceType @@ -51,7 +45,8 @@ interface TasksApi { * * @param taskListId Task list identifier. */ - suspend fun clear(taskListId: String) + @POST("tasks/v1/lists/{taskListId}/clear") + suspend fun clear(@Path("taskListId") taskListId: String) /** * [Deletes the specified task](https://developers.google.com/tasks/reference/rest/v1/tasks/delete) from the task list. If the task is assigned, both the assigned task and the original task (in Docs, Chat Spaces) are deleted. To delete the assigned task only, navigate to the assignment surface and unassign the task from there. @@ -59,7 +54,11 @@ interface TasksApi { * @param taskListId Task list identifier. * @param taskId Task identifier. */ - suspend fun delete(taskListId: String, taskId: String) + @DELETE("tasks/v1/lists/{taskListId}/tasks/{taskId}") + suspend fun delete( + @Path("taskListId") taskListId: String, + @Path("taskId") taskId: String + ) /** * [Returns the specified task](https://developers.google.com/tasks/reference/rest/v1/tasks/get). @@ -69,7 +68,11 @@ interface TasksApi { * * @return an instance of [Task]. */ - suspend fun get(taskListId: String, taskId: String): Task + @GET("tasks/v1/lists/{taskListId}/tasks/{taskId}") + suspend fun get( + @Path("taskListId") taskListId: String, + @Path("taskId") taskId: String + ): Task /** * [Creates a new task](https://developers.google.com/tasks/reference/rest/v1/tasks/insert) on the specified task list. Tasks assigned from Docs or Chat Spaces cannot be inserted from Tasks Public API; they can only be created by assigning them from Docs or Chat Spaces. A user can have up to 20,000 non-hidden tasks per list and up to 100,000 tasks in total at a time. @@ -81,7 +84,17 @@ interface TasksApi { * * @return a newly created instance of [Task]. */ - suspend fun insert(taskListId: String, task: Task, parentTaskId: String? = null, previousTaskId: String? = null): Task + @Headers( + "Content-Type: application/json", + "Content-Encoding: gzip", + ) + @POST("tasks/v1/lists/{taskListId}/tasks") + suspend fun insert( + @Path("taskListId") taskListId: String, + @Body task: Task, + @Query("parent") parentTaskId: String? = null, + @Query("previous") previousTaskId: String? = null + ): Task /** * Returns [all tasks in the specified task list](https://developers.google.com/tasks/reference/rest/v1/tasks/list). Does not return assigned tasks be default (from Docs, Chat Spaces). A user can have up to 20,000 non-hidden tasks per list and up to 100,000 tasks in total at a time. @@ -101,19 +114,20 @@ interface TasksApi { * * @return an instance of [ResourceListResponse] of type [Task], whose type is always [ResourceType.Tasks]. */ + @GET("tasks/v1/lists/{taskListId}/tasks") suspend fun list( - taskListId: String, - completedMin: Instant? = null, - completedMax: Instant? = null, - dueMin: Instant? = null, - dueMax: Instant? = null, - maxResults: Int = 20, - pageToken: String? = null, - showCompleted: Boolean = true, - showDeleted: Boolean = false, - showHidden: Boolean = false, - updatedMin: Instant? = null, - showAssigned: Boolean = false + @Path("taskListId") taskListId: String, + @Query("completedMin") completedMin: Instant? = null, + @Query("completedMax") completedMax: Instant? = null, + @Query("dueMin") dueMin: Instant? = null, + @Query("dueMax") dueMax: Instant? = null, + @Query("maxResults") maxResults: Int = 20, + @Query("pageToken") pageToken: String? = null, + @Query("showCompleted") showCompleted: Boolean = true, + @Query("showDeleted") showDeleted: Boolean = false, + @Query("showHidden") showHidden: Boolean = false, + @Query("updatedMin") updatedMin: Instant? = null, + @Query("showAssigned") showAssigned: Boolean = false ): ResourceListResponse /** @@ -127,12 +141,14 @@ interface TasksApi { * * @return an instance of [Task]. */ + @POST("tasks/v1/lists/{taskListId}/tasks/{taskId}/move") suspend fun move( - taskListId: String, - taskId: String, - parentTaskId: String? = null, - previousTaskId: String? = null, - destinationTaskListId: String? = null + @Path("taskListId") taskListId: String, + @Path("taskId") taskId: String, + @Query("parent") parentTaskId: String? = null, + @Query("previous") previousTaskId: String? = null, + @Suppress("SpellCheckingInspection") + @Query("destinationTasklist") destinationTaskListId: String? = null ): Task /** @@ -144,7 +160,13 @@ interface TasksApi { * * @return an instance of [Task]. */ - suspend fun patch(taskListId: String, taskId: String, task: Task): Task + @Headers("Content-Type: application/json") + @PATCH("tasks/v1/lists/{taskListId}/tasks/{taskId}") + suspend fun patch( + @Path("taskListId") taskListId: String, + @Path("taskId") taskId: String, + @Body task: Task + ): Task /** * [Updates the specified task](https://developers.google.com/tasks/reference/rest/v1/tasks/update). @@ -155,164 +177,13 @@ interface TasksApi { * * @return an instance of [Task]. */ - suspend fun update(taskListId: String, taskId: String, task: Task): Task -} - - -class HttpTasksApi( - private val httpClient: HttpClient -) : TasksApi { - override suspend fun clear(taskListId: String) { - val response = httpClient.post("tasks/v1/lists/${taskListId}/clear") - - if (response.status.isSuccess()) { - return response.body() - } else { - throw ClientRequestException(response, response.bodyAsText()) - } - } - - override suspend fun delete(taskListId: String, taskId: String) { - val response = httpClient.delete("tasks/v1/lists/${taskListId}/tasks/${taskId}") - - if (response.status.isSuccess()) { - return response.body() - } else { - throw ClientRequestException(response, response.bodyAsText()) - } - } - - override suspend fun get(taskListId: String, taskId: String): Task { - val response = httpClient.get("tasks/v1/lists/${taskListId}/tasks/${taskId}") { - contentType(ContentType.Application.Json) - } - - if (response.status.isSuccess()) { - return response.body() - } else { - throw ClientRequestException(response, response.bodyAsText()) - } - } - - override suspend fun insert(taskListId: String, task: Task, parentTaskId: String?, previousTaskId: String?): Task { - val response = httpClient.post("tasks/v1/lists/${taskListId}/tasks") { - if (parentTaskId != null) { - parameter("parent", parentTaskId) - } - if (previousTaskId != null) { - parameter("previous", previousTaskId) - } - contentType(ContentType.Application.Json) - compress("gzip") - setBody(task) - } - - if (response.status.isSuccess()) { - return response.body() - } else { - throw ClientRequestException(response, response.bodyAsText()) - } - } - - override suspend fun list( - taskListId: String, - completedMin: Instant?, - completedMax: Instant?, - dueMin: Instant?, - dueMax: Instant?, - maxResults: Int, - pageToken: String?, - showCompleted: Boolean, - showDeleted: Boolean, - showHidden: Boolean, - updatedMin: Instant?, - showAssigned: Boolean - ): ResourceListResponse { - val response = httpClient.get("tasks/v1/lists/${taskListId}/tasks") { - if (completedMin != null) { - parameter("completedMin", completedMin.toString()) - } - if (completedMax != null) { - parameter("completedMax", completedMax.toString()) - } - if (dueMin != null) { - parameter("dueMin", dueMin.toString()) - } - if (dueMax != null) { - parameter("dueMax", dueMax.toString()) - } - parameter("maxResults", maxResults.toString()) - if (pageToken != null) { - parameter("pageToken", pageToken) - } - parameter("showCompleted", showCompleted.toString()) - parameter("showDeleted", showDeleted.toString()) - parameter("showHidden", showHidden.toString()) - if (updatedMin != null) { - parameter("updatedMin", updatedMin.toString()) - } - parameter("showAssigned", showAssigned.toString()) - } - - if (response.status.isSuccess()) { - return response.body() - } else { - throw ClientRequestException(response, response.bodyAsText()) - } - } - - override suspend fun move( - taskListId: String, - taskId: String, - parentTaskId: String?, - previousTaskId: String?, - destinationTaskListId: String? - ): Task { - val response = httpClient.post("tasks/v1/lists/${taskListId}/tasks/${taskId}/move") { - if (parentTaskId != null) { - parameter("parent", parentTaskId) - } - if (previousTaskId != null) { - parameter("previous", previousTaskId) - } - if (destinationTaskListId != null) { - @Suppress("SpellCheckingInspection") - parameter("destinationTasklist", destinationTaskListId) - } - } - - if (response.status.isSuccess()) { - return response.body() - } else { - throw ClientRequestException(response, response.bodyAsText()) - } - } - - override suspend fun patch(taskListId: String, taskId: String, task: Task): Task { - val response = httpClient.patch("tasks/v1/lists/${taskListId}/tasks/${taskId}") { - contentType(ContentType.Application.Json) - setBody(task) - } - - if (response.status.isSuccess()) { - return response.body() - } else { - throw ClientRequestException(response, response.bodyAsText()) - } - } - - override suspend fun update(taskListId: String, taskId: String, task: Task): Task { - val response = httpClient.put("tasks/v1/lists/${taskListId}/tasks/${taskId}") { - contentType(ContentType.Application.Json) - setBody(task) - } - - if (response.status.isSuccess()) { - return response.body() - } else { - throw ClientRequestException(response, response.bodyAsText()) - } - } + @Headers("Content-Type: application/json") + @PUT("tasks/v1/lists/{taskListId}/tasks/{taskId}") + suspend fun update( + @Path("taskListId") taskListId: String, + @Path("taskId") taskId: String, + @Body task: Task + ): Task } /** diff --git a/google/tasks/src/commonTest/kotlin/net/opatry/google/tasks/api/HttpTaskListsApiTest.kt b/google/tasks/src/commonTest/kotlin/net/opatry/google/tasks/api/HttpTaskListsApiTest.kt index 8ee5cdd4..a552caf0 100644 --- a/google/tasks/src/commonTest/kotlin/net/opatry/google/tasks/api/HttpTaskListsApiTest.kt +++ b/google/tasks/src/commonTest/kotlin/net/opatry/google/tasks/api/HttpTaskListsApiTest.kt @@ -22,6 +22,7 @@ package net.opatry.google.tasks.api +import de.jensklingenberg.ktorfit.Ktorfit import io.ktor.client.HttpClient import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.mock.MockEngine @@ -35,7 +36,7 @@ import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest -import net.opatry.google.tasks.HttpTaskListsApi +import net.opatry.google.tasks.ClientRequestExceptionConverterFactory import net.opatry.google.tasks.TaskListsApi import net.opatry.google.tasks.model.ResourceType import net.opatry.google.tasks.model.TaskList @@ -56,7 +57,14 @@ class HttpTaskListsApiTest { } }.use { httpClient -> runTest { - test(HttpTaskListsApi(httpClient)) + test( + Ktorfit.Builder() + .httpClient(httpClient) + .baseUrl("http://localhost/") + .converterFactories(ClientRequestExceptionConverterFactory) + .build() + .create() + ) } } } diff --git a/google/tasks/src/commonTest/kotlin/net/opatry/google/tasks/api/HttpTasksApiTest.kt b/google/tasks/src/commonTest/kotlin/net/opatry/google/tasks/api/HttpTasksApiTest.kt index e527002f..1e6ea56e 100644 --- a/google/tasks/src/commonTest/kotlin/net/opatry/google/tasks/api/HttpTasksApiTest.kt +++ b/google/tasks/src/commonTest/kotlin/net/opatry/google/tasks/api/HttpTasksApiTest.kt @@ -22,6 +22,7 @@ package net.opatry.google.tasks.api +import de.jensklingenberg.ktorfit.Ktorfit import io.ktor.client.HttpClient import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.mock.MockEngine @@ -35,7 +36,7 @@ import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest -import net.opatry.google.tasks.HttpTasksApi +import net.opatry.google.tasks.ClientRequestExceptionConverterFactory import net.opatry.google.tasks.TasksApi import net.opatry.google.tasks.model.ResourceType import net.opatry.google.tasks.model.Task @@ -56,7 +57,14 @@ class HttpTasksApiTest { } }.use { httpClient -> runTest { - test(HttpTasksApi(httpClient)) + test( + Ktorfit.Builder() + .httpClient(httpClient) + .baseUrl("http://localhost/") + .converterFactories(ClientRequestExceptionConverterFactory) + .build() + .create() + ) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d84efe19..000728ab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ kotlin = "2.2.0-RC" ksp = "2.2.0-RC-2.0.1" coroutines = "1.10.2" ktor = "3.1.3" +ktorfit = "2.5.1" ktor-monitor-logging = "1.7.1" activity-compose = "1.11.0-rc01" compose-plugin = "1.8.1" @@ -47,6 +48,8 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } +ktorfit = { module = "de.jensklingenberg.ktorfit:ktorfit-lib", version.ref = "ktorfit" } + ktor-monitor-logging = { module = "ro.cosminmihu.ktor:ktor-monitor-logging", version.ref = "ktor-monitor-logging" } ktor-monitor-logging-no-op = { module = "ro.cosminmihu.ktor:ktor-monitor-logging-no-op", version.ref = "ktor-monitor-logging" } @@ -128,3 +131,4 @@ google-services = { id = "com.google.gms.google-services", version = "4.4.2" } firebase-crashlytics = { id = "com.google.firebase.crashlytics", version = "3.0.3" } about-libraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "about-libraries" } kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } +ktorfit = { id = "de.jensklingenberg.ktorfit", version.ref = "ktorfit" } diff --git a/tasks-app-android/src/main/assets/licenses_android.json b/tasks-app-android/src/main/assets/licenses_android.json index 53eb3a45..3465a33a 100644 --- a/tasks-app-android/src/main/assets/licenses_android.json +++ b/tasks-app-android/src/main/assets/licenses_android.json @@ -1834,6 +1834,34 @@ "Apache-2.0" ] }, + { + "uniqueId": "com.squareup.okhttp3:okhttp", + "developers": [ + { + "name": "Square, Inc." + } + ], + "artifactVersion": "4.12.0", + "description": "Square\u2019s meticulous HTTP client for Java and Kotlin.", + "name": "okhttp", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "com.squareup.okhttp3:okhttp-sse", + "developers": [ + { + "name": "Square, Inc." + } + ], + "artifactVersion": "4.12.0", + "description": "Square\u2019s meticulous HTTP client for Java and Kotlin.", + "name": "okhttp-sse", + "licenses": [ + "Apache-2.0" + ] + }, { "uniqueId": "com.squareup.okio:okio", "developers": [ @@ -1848,6 +1876,20 @@ "Apache-2.0" ] }, + { + "uniqueId": "de.jensklingenberg.ktorfit:ktorfit-lib", + "developers": [ + { + "name": "Jens Klingenberg" + } + ], + "artifactVersion": "2.5.1", + "description": "Ktorfit", + "name": "Ktorfit", + "licenses": [ + "Apache-2.0" + ] + }, { "uniqueId": "io.coil-kt.coil3:coil", "developers": [ @@ -2030,6 +2072,20 @@ "Apache-2.0" ] }, + { + "uniqueId": "io.ktor:ktor-client-okhttp", + "developers": [ + { + "name": "Jetbrains Team" + } + ], + "artifactVersion": "3.1.2", + "description": "Ktor is a framework for quickly creating web applications in Kotlin with minimal effort.", + "name": "ktor-client-okhttp", + "licenses": [ + "Apache-2.0" + ] + }, { "uniqueId": "io.ktor:ktor-events", "developers": [ diff --git a/tasks-app-desktop/src/main/resources/licenses_desktop.json b/tasks-app-desktop/src/main/resources/licenses_desktop.json index 435f527b..f681c1eb 100644 --- a/tasks-app-desktop/src/main/resources/licenses_desktop.json +++ b/tasks-app-desktop/src/main/resources/licenses_desktop.json @@ -260,6 +260,20 @@ "name": "com.typesafe" } }, + { + "uniqueId": "de.jensklingenberg.ktorfit:ktorfit-lib", + "developers": [ + { + "name": "Jens Klingenberg" + } + ], + "artifactVersion": "2.5.1", + "description": "Ktorfit", + "name": "Ktorfit", + "licenses": [ + "Apache-2.0" + ] + }, { "uniqueId": "io.coil-kt.coil3:coil", "developers": [ diff --git a/tasks-app-shared/build.gradle.kts b/tasks-app-shared/build.gradle.kts index 1b80cbd5..ded38115 100644 --- a/tasks-app-shared/build.gradle.kts +++ b/tasks-app-shared/build.gradle.kts @@ -34,6 +34,7 @@ plugins { alias(libs.plugins.compose.hot.reload) alias(libs.plugins.androidx.room) alias(libs.plugins.android.library) + alias(libs.plugins.ktorfit) } compose.resources { @@ -75,6 +76,7 @@ kotlin { api(libs.kotlinx.datetime) implementation(libs.bundles.ktor.client) + implementation(libs.ktorfit) implementation(libs.slf4j.nop) implementation(projects.google.oauth) implementation(projects.google.tasks) diff --git a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/di/networkModule.kt b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/di/networkModule.kt index 64f4687f..91ab095e 100644 --- a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/di/networkModule.kt +++ b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/di/networkModule.kt @@ -22,6 +22,7 @@ package net.opatry.tasks.app.di +import de.jensklingenberg.ktorfit.Ktorfit import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.CurlUserAgent @@ -31,19 +32,14 @@ import io.ktor.client.plugins.auth.providers.bearer import io.ktor.client.plugins.cache.HttpCache import io.ktor.client.plugins.compression.ContentEncoding import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging import io.ktor.http.HttpHeaders -import io.ktor.http.URLBuilder -import io.ktor.http.encodedPath -import io.ktor.http.takeFrom import io.ktor.serialization.kotlinx.json.json import kotlinx.datetime.Clock import net.opatry.google.auth.GoogleAuthenticator -import net.opatry.google.tasks.HttpTaskListsApi -import net.opatry.google.tasks.HttpTasksApi +import net.opatry.google.tasks.ClientRequestExceptionConverterFactory import net.opatry.google.tasks.TaskListsApi import net.opatry.google.tasks.TasksApi import net.opatry.tasks.CredentialsStorage @@ -121,26 +117,26 @@ val networkModule = module { } } } - defaultRequest { - if (url.host.isEmpty()) { - val defaultUrl = URLBuilder().takeFrom("https://tasks.googleapis.com") - url.host = defaultUrl.host - url.port = defaultUrl.port - url.protocol = defaultUrl.protocol - if (!url.encodedPath.startsWith('/')) { - val basePath = defaultUrl.encodedPath - url.encodedPath = "$basePath/${url.encodedPath}" - } - } - } } } - single { - HttpTaskListsApi(get(named(HttpClientName.Tasks))) + single { + Ktorfit.Builder() + .httpClient(get(named(HttpClientName.Tasks))) + .baseUrl("https://tasks.googleapis.com/") + .converterFactories(ClientRequestExceptionConverterFactory) + .build() + } + + single { + // FIXME why ktorfit.createTaskListsApi() isn't generated? + // It appears to be generated in jvmMain build dir of :google:tasks module, not being reachable from :tasks-app-shared + get().create() } - single { - HttpTasksApi(get(named(HttpClientName.Tasks))) + single { + // FIXME why ktorfit.createTasksApi() isn't generated? + // It appears to be generated in jvmMain build dir of :google:tasks module, not being reachable from :tasks-app-shared + get().create() } } \ No newline at end of file