Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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" }
Expand Down
4 changes: 4 additions & 0 deletions integrations/ktor/api/ktor.api
Original file line number Diff line number Diff line change
@@ -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;
}

10 changes: 10 additions & 0 deletions integrations/ktor/api/ktor.klib.api
Original file line number Diff line number Diff line change
@@ -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: <com.slack.eithernet:ktor>
final fun <#A: kotlin/Any> (kotlin/Exception).com.slack.eithernet.integration.ktor/asKtorApiResult(): com.slack.eithernet/ApiResult<kotlin/Nothing, #A> // com.slack.eithernet.integration.ktor/asKtorApiResult|[email protected](){0§<kotlin.Any>}[0]
final suspend inline fun <#A: reified kotlin/Any, #B: kotlin/Any> (io.ktor.client/HttpClient).com.slack.eithernet.integration.ktor/apiResultOf(kotlin/Function1<io.ktor.client/HttpClient, io.ktor.client.statement/HttpResponse>): com.slack.eithernet/ApiResult<#A, #B> // com.slack.eithernet.integration.ktor/apiResultOf|[email protected](kotlin.Function1<io.ktor.client.HttpClient,io.ktor.client.statement.HttpResponse>){0§<kotlin.Any>;1§<kotlin.Any>}[0]
60 changes: 60 additions & 0 deletions integrations/ktor/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
3 changes: 3 additions & 0 deletions integrations/ktor/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
POM_ARTIFACT_ID=eithernet-integration-ktor
POM_NAME=EitherNet Ktor Integration
POM_DESCRIPTION=Ktor integration for EitherNet ApiResult types
Original file line number Diff line number Diff line change
@@ -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 <reified T : Any, E : Any> HttpClient.apiResultOf(
block: HttpClient.() -> HttpResponse
): ApiResult<T, E> {
return try {
val response = block()
ApiResult.success(response.body<T>())
} catch (e: Exception) {
e.asKtorApiResult()
}
}

@PublishedApi
internal fun <E : Any> Exception.asKtorApiResult(): ApiResult<Nothing, E> {
// 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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Nothing, Any> = exception.asKtorApiResult()

assertIs<ApiResult.Failure.NetworkFailure>(result)
assertIs<IOException>(result.error)
assertEquals(exception, result.error.cause)
}

@Test
fun `asKtorApiResult converts SocketTimeoutException to networkFailure`() {
val exception = SocketTimeoutException("Socket timeout")

val result: ApiResult<Nothing, Any> = exception.asKtorApiResult()

assertIs<ApiResult.Failure.NetworkFailure>(result)
assertIs<IOException>(result.error)
assertEquals(exception, result.error.cause)
}

@Test
fun `asKtorApiResult converts UnresolvedAddressException to networkFailure`() {
val exception = UnresolvedAddressException()

val result: ApiResult<Nothing, Any> = exception.asKtorApiResult()

assertIs<ApiResult.Failure.NetworkFailure>(result)
assertIs<IOException>(result.error)
assertEquals(exception, result.error.cause)
}

@Test
fun `asKtorApiResult converts unknown exceptions to unknownFailure`() {
val exception = RuntimeException("Unknown error")

val result: ApiResult<Nothing, Any> = exception.asKtorApiResult()

assertIs<ApiResult.Failure.UnknownFailure>(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<Nothing, Any> = connectTimeout.asKtorApiResult()
val socketResult: ApiResult<Nothing, Any> = socketTimeout.asKtorApiResult()
val addressResult: ApiResult<Nothing, Any> = unresolvedAddress.asKtorApiResult()

assertIs<ApiResult.Failure.NetworkFailure>(connectResult)
assertIs<ApiResult.Failure.NetworkFailure>(socketResult)
assertIs<ApiResult.Failure.NetworkFailure>(addressResult)

assertTrue(connectResult.error.cause is ConnectTimeoutException)
assertTrue(socketResult.error.cause is SocketTimeoutException)
assertTrue(addressResult.error.cause is UnresolvedAddressException)
}
}
Loading
Loading