Created
May 15, 2023 05:29
-
-
Save baconz/778e8a0b359267292d6e05ad81c19b90 to your computer and use it in GitHub Desktop.
Simple JS Performant Kotlin Multiplatform HTTP Client
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import com.apollographql.apollo3.api.ApolloResponse | |
import com.apollographql.apollo3.api.Operation | |
import com.apollographql.apollo3.api.composeJsonRequest | |
import com.apollographql.apollo3.api.json.BufferedSourceJsonReader | |
import com.apollographql.apollo3.api.json.JsonReader | |
import com.apollographql.apollo3.api.json.buildJsonString | |
import com.apollographql.apollo3.api.parseJsonResponse | |
import io.ktor.client.* | |
import io.ktor.client.call.* | |
import io.ktor.client.request.* | |
import io.ktor.http.* | |
import io.ktor.serialization.kotlinx.json.* | |
import kotlinx.serialization.decodeFromString | |
import kotlinx.serialization.json.Json | |
import okio.Buffer | |
/** | |
* A response that comes back from an HTTP request whose body can be of any type | |
* | |
*/ | |
internal interface HttpResponse { | |
val statusCode: Int | |
val body: Any | |
} | |
/** | |
* An HTTP response with a [ByteArray] as its boy | |
* | |
* @property body | |
* @property statusCode | |
*/ | |
data class BytesHttpResponse( | |
override val body: ByteArray, | |
override val statusCode: Int | |
) : HttpResponse | |
/** | |
* For JS we use JSON.parse to parse JSON and return a dynamic. | |
* Everywhere else we use [kotlinx.serialization]. | |
* | |
* We've found JSON.parse to be more performant since generating a dynamic doesn't require any | |
* introspection, or constructing any objects. | |
* | |
* @param T | |
* @param json | |
* @return | |
*/ | |
internal expect inline fun <reified T> HttpResponse.parseJson(json: Json = DefaultJson): T | |
/** | |
* Default implementation of [parseJson] which uses [kotlinx.serialization] | |
* | |
* @param T | |
* @param json | |
* @return | |
*/ | |
internal inline fun <reified T> HttpResponse.defaultParseJson(json: Json = DefaultJson): T { | |
return when (this) { | |
is BytesHttpResponse -> json.decodeFromString(body.decodeToString()) | |
else -> throw RuntimeException("parseJson() no parser for http response type: $this") | |
} | |
} | |
/** | |
* A thin wrapper that optimally performs HTTP requests for JS, and wraps [io.ktor] on other | |
* platforms. We've seen the performance of ktor to be quite bad on JS platforms because | |
* | |
* * ktor uses [kotlinx.serialization] instead of parsing into dynamics and doing unsafe casts | |
* * ktor uses a lot of Kotlin datastructures internally, and they tend to be slow. | |
* | |
* @constructor | |
* | |
* @param defaultUrl | |
* @param httpClientProvider | |
*/ | |
internal expect class JsonHttpClient(defaultUrl: String, httpClientProvider: () -> HttpClient) { | |
val httpClient: HttpClient | |
val defaultUrl: String | |
suspend fun execute( | |
url: String, | |
body: String? = null, | |
headers: Array<Array<String>> = emptyArray(), | |
method: HttpMethod = HttpMethod.Get | |
): HttpResponse | |
} | |
/** | |
* The default JsonReader reads from a [ByteArray] | |
* | |
* @return | |
*/ | |
internal fun HttpResponse.defaultJsonReader(): JsonReader { | |
return when (this) { | |
is BytesHttpResponse -> BufferedSourceJsonReader(Buffer().write(body)) | |
else -> throw RuntimeException("jsonReader() no parser for http response type: $this") | |
} | |
} | |
/** | |
* Take an Apollo operation, and return an [HttpResponse]. | |
* | |
* @param D | |
* @param operation | |
* @param headers | |
* @param method | |
* @return | |
*/ | |
internal suspend fun <D : Operation.Data> JsonHttpClient.executeApolloOperationRaw( | |
operation: Operation<D>, | |
headers: Array<Array<String>> = emptyArray(), | |
method: HttpMethod = HttpMethod.Post | |
): HttpResponse { | |
val body = buildJsonString { | |
operation.composeJsonRequest(this) | |
} | |
val finalHeaders: Array<Array<String>> = | |
if (headers.firstOrNull { it[0] == HttpHeaders.ContentType } == null) { | |
headers.plusElement(arrayOf(HttpHeaders.ContentType, "application/json")) | |
} else { | |
headers | |
} | |
return execute(defaultUrl, body, finalHeaders, method) | |
} | |
data class SimpleApolloResponse<D : Operation.Data>( | |
val data: D?, | |
val errors: Array<Error> | |
) { | |
data class Error(val message: String, val nonStandardFields: Map<String, Any?>) | |
fun dataOrThrow(): D { | |
if (errors.isNotEmpty()) { | |
// TODO: Better exception | |
throw RuntimeException("errors was not empty") | |
} | |
return data!! | |
} | |
fun hasErrors(): Boolean { | |
return errors.isNotEmpty() | |
} | |
} | |
/** | |
* Fully execute an apollo operation, returning an [ApolloResponse] with data when complete. | |
* | |
* @param D | |
* @param operation | |
* @param headers | |
* @param method | |
* @return | |
*/ | |
internal expect suspend fun <D : Operation.Data> JsonHttpClient.executeApolloOperation( | |
operation: Operation<D>, | |
headers: Array<Array<String>> = emptyArray(), | |
method: HttpMethod = HttpMethod.Post | |
): SimpleApolloResponse<D> | |
internal suspend fun <D : Operation.Data> JsonHttpClient.defaultExecuteApolloOperation( | |
operation: Operation<D>, | |
headers: Array<Array<String>> = emptyArray(), | |
method: HttpMethod = HttpMethod.Post | |
): SimpleApolloResponse<D> { | |
val rawResponse = executeApolloOperationRaw(operation, headers, method) | |
val response = operation.parseJsonResponse(rawResponse.defaultJsonReader()) | |
// TODO Errors | |
return SimpleApolloResponse(response.data, emptyArray()) | |
} | |
/** | |
* By default, this will use byte-based parsing, on JS we will parse a dynamic | |
* | |
* @param url | |
* @param body | |
* @param headers | |
* @param method | |
* @return | |
*/ | |
internal suspend fun JsonHttpClient.defaultExecute( | |
url: String, | |
body: String? = null, | |
headers: Array<Array<String>> = emptyArray(), | |
method: HttpMethod = HttpMethod.Get | |
): BytesHttpResponse { | |
httpClient.get(url) | |
val response = httpClient.request { | |
url(url) | |
body?.let { setBody(it) } | |
this.method = method | |
headers.forEach { | |
header(it[0], it[1]) | |
} | |
} | |
return BytesHttpResponse( | |
body = response.body(), | |
statusCode = response.status.value | |
) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import com.apollographql.apollo3.api.ApolloResponse | |
import com.apollographql.apollo3.api.Operation | |
import com.apollographql.apollo3.api.json.DynamicJsJsonReader | |
import com.apollographql.apollo3.api.json.JsonReader | |
import io.ktor.client.* | |
import io.ktor.client.fetch.* | |
import io.ktor.http.* | |
import io.ktor.http.auth.* | |
import io.ktor.serialization.kotlinx.json.* | |
import io.ktor.util.* | |
import kotlinx.coroutines.CancellationException | |
import kotlinx.coroutines.await | |
import kotlinx.serialization.ExperimentalSerializationApi | |
import kotlinx.serialization.json.Json | |
import kotlinx.serialization.json.decodeFromDynamic | |
data class DynamicHttpResponse( | |
override val body: dynamic, | |
override val statusCode: Int | |
) : HttpResponse | |
@OptIn(ExperimentalSerializationApi::class) | |
internal actual inline fun <reified T> HttpResponse.parseJson( | |
json: Json | |
): T { | |
return when (this) { | |
is DynamicHttpResponse -> json.decodeFromDynamic(body) | |
else -> throw RuntimeException("parseJson() no parser for http response type: $this") | |
} | |
} | |
private fun <T> buildObject(block: T.() -> Unit): T = (js("{}") as T).apply(block) | |
@Suppress("UNCHECKED_CAST_TO_EXTERNAL_INTERFACE") | |
private fun AbortController(): AbortController { | |
return if (PlatformUtils.IS_BROWSER) { | |
js("new AbortController()") as AbortController | |
} else { | |
@Suppress("UNUSED_VARIABLE") | |
val controller = js("eval('require')('abort-controller')") | |
js("new controller()") as AbortController | |
} | |
} | |
internal actual class JsonHttpClient actual constructor( | |
actual val defaultUrl: String, | |
httpClientProvider: () -> HttpClient | |
) { | |
actual val httpClient: HttpClient by lazy { httpClientProvider.invoke() } | |
init { | |
if (!PlatformUtils.IS_BROWSER) { | |
throw RuntimeException("FastHttpEngine is not available outside of the browser.") | |
} | |
} | |
actual suspend fun execute( | |
url: String, | |
body: String?, | |
headers: Array<Array<String>>, | |
method: HttpMethod | |
): HttpResponse { | |
val controller = AbortController() | |
try { | |
val fetchResponse = fetch(url, toRequestInit(body, headers, method, controller)).await() | |
val json = fetchResponse.json().await() | |
return DynamicHttpResponse( | |
body = json, | |
statusCode = fetchResponse.status.toInt() | |
) | |
} catch (e: CancellationException) { | |
controller.abort() | |
throw e | |
} | |
} | |
private fun toRequestInit( | |
body: String?, | |
headers: Array<Array<String>>, | |
method: HttpMethod, | |
controller: AbortController | |
): RequestInit { | |
return buildObject { | |
body?.let { this.body = body } | |
this.headers = headers | |
this.method = when (method) { | |
HttpMethod.Get -> HttpMethod.Get.value | |
HttpMethod.Post -> HttpMethod.Post.value | |
else -> { | |
throw RuntimeException("Unsupported method: $method") | |
} | |
} | |
signal = controller.signal | |
} | |
} | |
} | |
/** | |
* Fully execute an apollo operation, returning an [ApolloResponse] with data when complete. | |
* | |
* @param D | |
* @param operation | |
* @param headers | |
* @param method | |
* @return | |
*/ | |
internal actual suspend fun <D : Operation.Data> JsonHttpClient.executeApolloOperation( | |
operation: Operation<D>, | |
headers: Array<Array<String>>, | |
method: HttpMethod | |
): SimpleApolloResponse<D> { | |
val dynamicResponse = executeApolloOperationRaw(operation, headers, method) as DynamicHttpResponse | |
val data = dynamicResponse.body["data"] | |
// TODO Errors | |
return SimpleApolloResponse( | |
data = data.unsafeCast<D>(), | |
errors = emptyArray() | |
) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import com.apollographql.apollo3.api.ApolloResponse | |
import com.apollographql.apollo3.api.Operation | |
import com.apollographql.apollo3.api.json.JsonReader | |
import io.ktor.client.* | |
import io.ktor.http.* | |
import kotlinx.serialization.json.Json | |
internal actual class JsonHttpClient actual constructor( | |
actual val defaultUrl: String, | |
httpClientProvider: () -> HttpClient | |
) { | |
actual val httpClient: HttpClient by lazy { httpClientProvider.invoke() } | |
actual suspend fun execute( | |
url: String, | |
body: String?, | |
headers: Array<Array<String>>, | |
method: HttpMethod | |
): HttpResponse { | |
return defaultExecute(url, body, headers, method) | |
} | |
} | |
internal actual inline fun <reified T> HttpResponse.parseJson(json: Json): T { | |
return defaultParseJson(json) | |
} | |
/** | |
* Fully execute an apollo operation, returning an [ApolloResponse] with data when complete. | |
* | |
* @param D | |
* @param operation | |
* @param headers | |
* @param method | |
* @return | |
*/ | |
internal actual suspend fun <D : Operation.Data> JsonHttpClient.executeApolloOperation( | |
operation: Operation<D>, | |
headers: Array<Array<String>>, | |
method: HttpMethod | |
): SimpleApolloResponse<D> { | |
return defaultExecuteApolloOperation(operation, headers, method) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment