diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 30eaf80..1d9b450 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ jdk = "23" jvmTarget = "11" ksp = "2.2.20-2.0.3" ktfmt = "0.54" +ktor = "3.3.0" moshi = "1.15.1" okhttp = "5.1.0" retrofit = "2.9.0" @@ -29,6 +30,7 @@ junit = "junit:junit:4.13.2" kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } ktfmt = { module = "com.facebook:ktfmt", version.ref = "ktfmt" } +ktor-client = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } diff --git a/integrations/ktor/api/ktor.api b/integrations/ktor/api/ktor.api new file mode 100644 index 0000000..70b394b --- /dev/null +++ b/integrations/ktor/api/ktor.api @@ -0,0 +1,4 @@ +public final class com/slack/eithernet/integration/ktor/KtorEitherNetExtensionsKt { + public static final fun asKtorApiResult (Ljava/lang/Exception;)Lcom/slack/eithernet/ApiResult; +} + diff --git a/integrations/ktor/api/ktor.klib.api b/integrations/ktor/api/ktor.klib.api new file mode 100644 index 0000000..cf7584d --- /dev/null +++ b/integrations/ktor/api/ktor.klib.api @@ -0,0 +1,10 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64, js, wasmJs] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final fun <#A: kotlin/Any> (kotlin/Exception).com.slack.eithernet.integration.ktor/asKtorApiResult(): com.slack.eithernet/ApiResult // com.slack.eithernet.integration.ktor/asKtorApiResult|asKtorApiResult@kotlin.Exception(){0§}[0] +final suspend inline fun <#A: reified kotlin/Any, #B: kotlin/Any> (io.ktor.client/HttpClient).com.slack.eithernet.integration.ktor/apiResultOf(kotlin/Function1): com.slack.eithernet/ApiResult<#A, #B> // com.slack.eithernet.integration.ktor/apiResultOf|apiResultOf@io.ktor.client.HttpClient(kotlin.Function1){0§;1§}[0] diff --git a/integrations/ktor/build.gradle.kts b/integrations/ktor/build.gradle.kts new file mode 100644 index 0000000..d01dfbc --- /dev/null +++ b/integrations/ktor/build.gradle.kts @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2025 Slack Technologies, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.dokka) + alias(libs.plugins.mavenPublish) +} + +kotlin { + // region KMP Targets + jvm() + iosX64() + iosArm64() + iosSimulatorArm64() + js(IR) { + outputModuleName.set(property("POM_ARTIFACT_ID").toString()) + browser() + } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + outputModuleName.set(property("POM_ARTIFACT_ID").toString()) + browser() + } + // endregion + + applyDefaultHierarchyTemplate() + + sourceSets { + commonMain { + dependencies { + api(project(":eithernet")) + api(libs.ktor.client) + implementation(libs.coroutines.core) + implementation(libs.okio) + } + } + commonTest { + dependencies { + implementation(libs.coroutines.core) + implementation(libs.coroutines.test) + implementation(libs.kotlin.test) + } + } + } +} diff --git a/integrations/ktor/gradle.properties b/integrations/ktor/gradle.properties new file mode 100644 index 0000000..eb15690 --- /dev/null +++ b/integrations/ktor/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=eithernet-integration-ktor +POM_NAME=EitherNet Ktor Integration +POM_DESCRIPTION=Ktor integration for EitherNet ApiResult types \ No newline at end of file diff --git a/integrations/ktor/src/commonMain/kotlin/com/slack/eithernet/integration/ktor/ktorEitherNetExtensions.kt b/integrations/ktor/src/commonMain/kotlin/com/slack/eithernet/integration/ktor/ktorEitherNetExtensions.kt new file mode 100644 index 0000000..8a7dba9 --- /dev/null +++ b/integrations/ktor/src/commonMain/kotlin/com/slack/eithernet/integration/ktor/ktorEitherNetExtensions.kt @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2025 Slack Technologies, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.slack.eithernet.integration.ktor + +import com.slack.eithernet.ApiResult +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.network.sockets.ConnectTimeoutException +import io.ktor.client.network.sockets.SocketTimeoutException +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.plugins.ServerResponseException +import io.ktor.client.statement.HttpResponse +import io.ktor.util.network.UnresolvedAddressException +import okio.IOException + +/** + * Converts an [HttpResponse] returned by [block] to an [ApiResult] with the response body as the + * success value. + */ +public suspend inline fun HttpClient.apiResultOf( + block: HttpClient.() -> HttpResponse +): ApiResult { + return try { + val response = block() + ApiResult.success(response.body()) + } catch (e: Exception) { + e.asKtorApiResult() + } +} + +@PublishedApi +internal fun Exception.asKtorApiResult(): ApiResult { + // For some reason the smart cast here fails + return when (this) { + is ClientRequestException -> { + // 4xx errors + ApiResult.httpFailure(response.status.value) + } + is ServerResponseException -> { + // 5xx errors + ApiResult.httpFailure(response.status.value) + } + is ConnectTimeoutException -> { + ApiResult.networkFailure(IOException("", this as Throwable)) + } + is SocketTimeoutException -> { + ApiResult.networkFailure(IOException("", this as Throwable)) + } + is UnresolvedAddressException -> { + ApiResult.networkFailure(IOException("", this as Throwable)) + } + else -> { + ApiResult.unknownFailure(this) + } + } +} diff --git a/integrations/ktor/src/commonTest/kotlin/com/slack/eithernet/integration/ktor/KtorEitherNetExtensionsTest.kt b/integrations/ktor/src/commonTest/kotlin/com/slack/eithernet/integration/ktor/KtorEitherNetExtensionsTest.kt new file mode 100644 index 0000000..0f09482 --- /dev/null +++ b/integrations/ktor/src/commonTest/kotlin/com/slack/eithernet/integration/ktor/KtorEitherNetExtensionsTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2025 Slack Technologies, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.slack.eithernet.integration.ktor + +import com.slack.eithernet.ApiResult +import io.ktor.client.network.sockets.ConnectTimeoutException +import io.ktor.client.network.sockets.SocketTimeoutException +import io.ktor.util.network.UnresolvedAddressException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import okio.IOException + +class KtorEitherNetExtensionsTest { + + @Test + fun `asKtorApiResult converts ConnectTimeoutException to networkFailure`() { + val exception = ConnectTimeoutException("Connection timeout") + + val result: ApiResult = exception.asKtorApiResult() + + assertIs(result) + assertIs(result.error) + assertEquals(exception, result.error.cause) + } + + @Test + fun `asKtorApiResult converts SocketTimeoutException to networkFailure`() { + val exception = SocketTimeoutException("Socket timeout") + + val result: ApiResult = exception.asKtorApiResult() + + assertIs(result) + assertIs(result.error) + assertEquals(exception, result.error.cause) + } + + @Test + fun `asKtorApiResult converts UnresolvedAddressException to networkFailure`() { + val exception = UnresolvedAddressException() + + val result: ApiResult = exception.asKtorApiResult() + + assertIs(result) + assertIs(result.error) + assertEquals(exception, result.error.cause) + } + + @Test + fun `asKtorApiResult converts unknown exceptions to unknownFailure`() { + val exception = RuntimeException("Unknown error") + + val result: ApiResult = exception.asKtorApiResult() + + assertIs(result) + assertEquals(exception, result.error) + } + + @Test + fun `asKtorApiResult preserves exception types for network errors`() { + val connectTimeout = ConnectTimeoutException("Connect timeout") + val socketTimeout = SocketTimeoutException("Socket timeout") + val unresolvedAddress = UnresolvedAddressException() + + val connectResult: ApiResult = connectTimeout.asKtorApiResult() + val socketResult: ApiResult = socketTimeout.asKtorApiResult() + val addressResult: ApiResult = unresolvedAddress.asKtorApiResult() + + assertIs(connectResult) + assertIs(socketResult) + assertIs(addressResult) + + assertTrue(connectResult.error.cause is ConnectTimeoutException) + assertTrue(socketResult.error.cause is SocketTimeoutException) + assertTrue(addressResult.error.cause is UnresolvedAddressException) + } +} diff --git a/kotlin-js-store/package-lock.json b/kotlin-js-store/package-lock.json index 8b622e2..7abd51b 100644 --- a/kotlin-js-store/package-lock.json +++ b/kotlin-js-store/package-lock.json @@ -11,7 +11,10 @@ "packages/eithernet", "packages/eithernet-test", "packages/eithernet-test-fixtures", - "packages/eithernet-test-fixtures-test" + "packages/eithernet-test-fixtures-test", + "packages/eithernet-integration-ktor", + "packages/eithernet-integration-ktor-test", + "packages_imported/ktor-ktor-client-core/3.3.0" ], "devDependencies": {} }, @@ -1164,6 +1167,14 @@ "resolved": "packages/eithernet", "link": true }, + "node_modules/eithernet-integration-ktor": { + "resolved": "packages/eithernet-integration-ktor", + "link": true + }, + "node_modules/eithernet-integration-ktor-test": { + "resolved": "packages/eithernet-integration-ktor-test", + "link": true + }, "node_modules/eithernet-test": { "resolved": "packages/eithernet-test", "link": true @@ -2196,6 +2207,10 @@ "format-util": "^1.0.5" } }, + "node_modules/ktor-ktor-client-core": { + "resolved": "packages_imported/ktor-ktor-client-core/3.3.0", + "link": true + }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -3900,10 +3915,103 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages_imported/ktor-ktor-client-core/3.3.0": { + "name": "ktor-ktor-client-core", + "version": "3.3.0", + "dependencies": { + "ws": "8.18.3" + }, + "devDependencies": {} + }, + "packages_imported/ktor-ktor-client-core/3.3.0/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "packages/eithernet": { "version": "2.1.0-SNAPSHOT", "devDependencies": {} }, + "packages/eithernet-integration-ktor": { + "version": "2.1.0-SNAPSHOT", + "dependencies": { + "ws": "8.18.3" + }, + "devDependencies": {} + }, + "packages/eithernet-integration-ktor-test": { + "version": "2.1.0-SNAPSHOT", + "dependencies": { + "ws": "8.18.3" + }, + "devDependencies": { + "karma": "6.4.4", + "karma-chrome-launcher": "3.2.0", + "karma-mocha": "2.0.1", + "karma-sourcemap-loader": "0.4.0", + "karma-webpack": "5.0.1", + "kotlin-web-helpers": "2.1.0", + "mocha": "11.7.1", + "source-map-loader": "5.0.0", + "webpack": "5.100.2", + "webpack-cli": "6.0.1" + } + }, + "packages/eithernet-integration-ktor-test/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "packages/eithernet-integration-ktor/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "packages/eithernet-test": { "version": "2.1.0-SNAPSHOT", "devDependencies": { diff --git a/kotlin-js-store/wasm/package-lock.json b/kotlin-js-store/wasm/package-lock.json index ed6962d..64cee4b 100644 --- a/kotlin-js-store/wasm/package-lock.json +++ b/kotlin-js-store/wasm/package-lock.json @@ -11,7 +11,10 @@ "packages/eithernet", "packages/eithernet-test", "packages/eithernet-test-fixtures", - "packages/eithernet-test-fixtures-test" + "packages/eithernet-test-fixtures-test", + "packages/eithernet-integration-ktor", + "packages/eithernet-integration-ktor-test", + "packages_imported/ktor-ktor-client-core/3.3.0" ], "devDependencies": {} }, @@ -19,6 +22,14 @@ "resolved": "packages/eithernet", "link": true }, + "node_modules/eithernet-integration-ktor": { + "resolved": "packages/eithernet-integration-ktor", + "link": true + }, + "node_modules/eithernet-integration-ktor-test": { + "resolved": "packages/eithernet-integration-ktor-test", + "link": true + }, "node_modules/eithernet-test": { "resolved": "packages/eithernet-test", "link": true @@ -31,10 +42,56 @@ "resolved": "packages/eithernet-test-fixtures-test", "link": true }, + "node_modules/ktor-ktor-client-core": { + "resolved": "packages_imported/ktor-ktor-client-core/3.3.0", + "link": true + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "packages_imported/ktor-ktor-client-core/3.3.0": { + "name": "ktor-ktor-client-core", + "version": "3.3.0", + "dependencies": { + "ws": "8.18.3" + }, + "devDependencies": {} + }, "packages/eithernet": { "version": "2.1.0-SNAPSHOT", "devDependencies": {} }, + "packages/eithernet-integration-ktor": { + "version": "2.1.0-SNAPSHOT", + "dependencies": { + "ws": "8.18.3" + }, + "devDependencies": {} + }, + "packages/eithernet-integration-ktor-test": { + "version": "2.1.0-SNAPSHOT", + "dependencies": { + "ws": "8.18.3" + }, + "devDependencies": {} + }, "packages/eithernet-test": { "version": "2.1.0-SNAPSHOT", "devDependencies": {} diff --git a/settings.gradle.kts b/settings.gradle.kts index 0c24344..aeb2850 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,3 +32,5 @@ include(":eithernet") include(":eithernet:test-fixtures") include(":integrations:retrofit") + +include(":integrations:ktor")