diff --git a/http/client/src/commonMain/kotlin/org/jetbrains/packagesearch/api/v3/http/PackageSearchApiClient.kt b/http/client/src/commonMain/kotlin/org/jetbrains/packagesearch/api/v3/http/PackageSearchApiClient.kt index 6daac28..ed2f8a9 100644 --- a/http/client/src/commonMain/kotlin/org/jetbrains/packagesearch/api/v3/http/PackageSearchApiClient.kt +++ b/http/client/src/commonMain/kotlin/org/jetbrains/packagesearch/api/v3/http/PackageSearchApiClient.kt @@ -6,6 +6,7 @@ import io.ktor.client.call.body import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.HttpClientEngineConfig import io.ktor.client.engine.HttpClientEngineFactory +import io.ktor.client.plugins.HttpCallValidator import io.ktor.client.plugins.HttpRequestRetry import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.compression.ContentEncoding @@ -14,11 +15,15 @@ import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.get import io.ktor.client.request.request import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.client.statement.request import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode import io.ktor.http.Url +import io.ktor.http.fullPath import io.ktor.http.isSuccess import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.protobuf.protobuf @@ -29,6 +34,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import kotlinx.serialization.protobuf.ProtoBuf import org.jetbrains.packagesearch.api.v3.ApiPackage import org.jetbrains.packagesearch.api.v3.ApiProject @@ -51,6 +58,12 @@ public class PackageSearchApiClient( pollingInterval: Duration = 1.seconds, ) : this(endpoints, httpClient) + @Serializable + private data class Error(val error: Inner) { + @Serializable + data class Inner(val message: String, val stackTrace: List) + } + public companion object { private fun HttpClientConfig<*>.defaultEngineConfig(protobuf: Boolean = true) { @@ -58,6 +71,15 @@ public class PackageSearchApiClient( if (protobuf) protobuf(ProtoBuf { encodeDefaults = false }) json() } + install(HttpCallValidator) { + validateResponse { response -> + if (!response.status.isSuccess()) { + response.bodyAsText() + .runCatching { Json.decodeFromString(this) } + .onSuccess { throw it.toException(response) } + } + } + } install(ContentEncoding) { gzip() } @@ -73,6 +95,13 @@ public class PackageSearchApiClient( } } + private fun Error.toException(response: HttpResponse) = PackageSearchApiException( + serverMessage = error.message, + request = response.request.toSerializable(), + statusCode = response.status.toSerializable(), + remoteStackTrace = error.stackTrace + ) + public fun defaultHttpClient( protobuf: Boolean = true, additionalConfig: HttpClientConfig<*>.() -> Unit = {}, @@ -136,7 +165,7 @@ public class PackageSearchApiClient( override suspend fun getPackageInfoByIds( ids: Set, - requestBuilder: (HttpRequestBuilder.() -> Unit)? + requestBuilder: (HttpRequestBuilder.() -> Unit)?, ): Map = defaultRequest<_, List>( method = HttpMethod.Post, url = endpoints.packageInfoByIds, @@ -146,7 +175,7 @@ public class PackageSearchApiClient( override suspend fun getPackageInfoByIdHashes( ids: Set, - requestBuilder: (HttpRequestBuilder.() -> Unit)? + requestBuilder: (HttpRequestBuilder.() -> Unit)?, ): Map = defaultRequest<_, List>( method = HttpMethod.Post, url = endpoints.packageInfoByIdHashes, @@ -156,7 +185,7 @@ public class PackageSearchApiClient( override suspend fun searchPackages( request: SearchPackagesRequest, - requestBuilder: (HttpRequestBuilder.() -> Unit)? + requestBuilder: (HttpRequestBuilder.() -> Unit)?, ): List = defaultRequest<_, List>( method = HttpMethod.Post, url = endpoints.searchPackages, @@ -166,7 +195,7 @@ public class PackageSearchApiClient( override suspend fun startScroll( request: SearchPackagesStartScrollRequest, - requestBuilder: (HttpRequestBuilder.() -> Unit)? + requestBuilder: (HttpRequestBuilder.() -> Unit)?, ): SearchPackagesScrollResponse = defaultRequest<_, SearchPackagesScrollResponse>( method = HttpMethod.Post, url = endpoints.startScroll, @@ -176,7 +205,7 @@ public class PackageSearchApiClient( override suspend fun nextScroll( request: SearchPackagesNextScrollRequest, - requestBuilder: (HttpRequestBuilder.() -> Unit)? + requestBuilder: (HttpRequestBuilder.() -> Unit)?, ): SearchPackagesScrollResponse = defaultRequest<_, SearchPackagesScrollResponse>( method = HttpMethod.Post, url = endpoints.nextScroll, @@ -186,7 +215,7 @@ public class PackageSearchApiClient( override suspend fun searchProjects( request: SearchProjectRequest, - requestBuilder: (HttpRequestBuilder.() -> Unit)? + requestBuilder: (HttpRequestBuilder.() -> Unit)?, ): List = defaultRequest<_, List>( method = HttpMethod.Post, diff --git a/http/client/src/commonMain/kotlin/org/jetbrains/packagesearch/api/v3/http/PackageSearchApiException.kt b/http/client/src/commonMain/kotlin/org/jetbrains/packagesearch/api/v3/http/PackageSearchApiException.kt new file mode 100644 index 0000000..2d79fc1 --- /dev/null +++ b/http/client/src/commonMain/kotlin/org/jetbrains/packagesearch/api/v3/http/PackageSearchApiException.kt @@ -0,0 +1,23 @@ +package org.jetbrains.packagesearch.api.v3.http + +import kotlinx.serialization.Serializable + +@Serializable +public data class PackageSearchApiException( + val serverMessage: String, + val request: SerializableHttpRequest, + val statusCode: SerializableHttpStatusCode, + val remoteStackTrace: List = emptyList(), +) : Throwable() { + override val message: String + get() = buildString { + append("Error response for endpoint ${request.method} ${request.url}:") + appendLine("- Headers:") + request.headers.forEach { appendLine(" -> ${it.key}: ${it.value.joinToString()}") } + appendLine("- Status code: ${statusCode.value} ${statusCode.description}") + if (remoteStackTrace.isNotEmpty()) { + append("- Remote stack trace:") + remoteStackTrace.forEach { appendLine(" $it") } + } + } +} \ No newline at end of file diff --git a/http/client/src/commonMain/kotlin/org/jetbrains/packagesearch/api/v3/http/Utils.kt b/http/client/src/commonMain/kotlin/org/jetbrains/packagesearch/api/v3/http/Utils.kt index 2c62131..cd05730 100644 --- a/http/client/src/commonMain/kotlin/org/jetbrains/packagesearch/api/v3/http/Utils.kt +++ b/http/client/src/commonMain/kotlin/org/jetbrains/packagesearch/api/v3/http/Utils.kt @@ -1,12 +1,17 @@ package org.jetbrains.packagesearch.api.v3.http -import io.ktor.client.plugins.* +import io.ktor.client.plugins.HttpRequestRetry +import io.ktor.client.plugins.HttpTimeout import io.ktor.client.request.HttpRequest -import io.ktor.client.statement.HttpResponse -import io.ktor.http.* +import io.ktor.http.HttpMessageBuilder +import io.ktor.http.HttpStatusCode +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.util.toMap import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds +import kotlinx.serialization.Serializable import org.jetbrains.packagesearch.api.v3.ApiPackage import org.jetbrains.packagesearch.api.v3.search.NextScrollParametersBuilder import org.jetbrains.packagesearch.api.v3.search.SearchParametersBuilder @@ -23,12 +28,14 @@ internal fun HttpMessageBuilder.header(key: String, vararg values: Any?) { internal var HttpTimeout.HttpTimeoutCapabilityConfiguration.requestTimeout: Duration? get() = requestTimeoutMillis?.milliseconds - set(value) { requestTimeoutMillis = value?.inWholeMilliseconds } + set(value) { + requestTimeoutMillis = value?.inWholeMilliseconds + } internal fun HttpRequestRetry.Configuration.constantDelay( delay: Duration = 1.seconds, randomization: Duration = 1.seconds, - respectRetryAfterHeader: Boolean = true + respectRetryAfterHeader: Boolean = true, ) { constantDelay(delay.inWholeMilliseconds, randomization.inWholeMilliseconds, respectRetryAfterHeader) } @@ -40,4 +47,20 @@ public suspend fun PackageSearchApiClient.startScroll(builder: StartScrollParame startScroll(buildStartScrollParameters(builder)) public suspend fun PackageSearchApiClient.nextScroll(builder: NextScrollParametersBuilder.() -> Unit): SearchPackagesScrollResponse = - nextScroll(buildNextScrollParameters(builder)) \ No newline at end of file + nextScroll(buildNextScrollParameters(builder)) + +@Serializable +public data class SerializableHttpStatusCode(val value: Int, val description: String) + +public fun HttpStatusCode.toSerializable(): SerializableHttpStatusCode = + SerializableHttpStatusCode(value, description) + +@Serializable +public data class SerializableHttpRequest( + val url: String, + val method: String, + val headers: Map>, +) + +public fun HttpRequest.toSerializable(): SerializableHttpRequest = + SerializableHttpRequest(url.toString(), method.value, headers.toMap()) \ No newline at end of file