Skip to content

Instantly share code, notes, and snippets.

@baconz
Created May 15, 2023 05:29
Show Gist options
  • Save baconz/778e8a0b359267292d6e05ad81c19b90 to your computer and use it in GitHub Desktop.
Save baconz/778e8a0b359267292d6e05ad81c19b90 to your computer and use it in GitHub Desktop.
Simple JS Performant Kotlin Multiplatform HTTP Client
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
)
}
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()
)
}
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