Coverage Summary for Class: HelixHttpClient (io.github.captnblubber.twitchkt.helix.internal)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| HelixHttpClient |
72.2%
(26/36)
|
57.9%
(44/76)
|
94.7%
(162/171)
|
96%
(1884/1962)
|
| HelixHttpClient$Companion |
|
| HelixHttpClient$deleteNoContent$1 |
|
| HelixHttpClient$handleNoContentResponse$1 |
|
| HelixHttpClient$mapException$1 |
|
| HelixHttpClient$paginate$1 |
100%
(1/1)
|
100%
(6/6)
|
100%
(11/11)
|
100%
(134/134)
|
| HelixHttpClient$patchNoContent$1 |
|
| HelixHttpClient$postNoContent$1 |
|
| HelixHttpClient$putNoContent$1 |
|
| HelixHttpClient$validateAnyScope$1 |
|
| HelixHttpClient$validateScopes$1 |
|
| Total |
73%
(27/37)
|
61%
(50/82)
|
95.1%
(173/182)
|
96.3%
(2018/2096)
|
package io.github.captnblubber.twitchkt.helix.internal
import io.github.captnblubber.twitchkt.TwitchKtConfig
import io.github.captnblubber.twitchkt.auth.TwitchScope
import io.github.captnblubber.twitchkt.error.TwitchApiException
import io.github.captnblubber.twitchkt.error.mapTwitchApiError
import io.github.captnblubber.twitchkt.helix.Page
import io.github.captnblubber.twitchkt.logging.LogLevel
import io.ktor.client.HttpClient
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.patch
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.content.TextContent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
internal class HelixHttpClient(
private val httpClient: HttpClient,
private val config: TwitchKtConfig,
) {
@PublishedApi
internal val json: Json =
Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
@PublishedApi
internal inline fun <reified T> encodeBody(value: T): TextContent = TextContent(json.encodeToString(value), ContentType.Application.Json)
suspend inline fun <reified T> get(
endpoint: String,
params: List<Pair<String, String>> = emptyList(),
): TwitchResponse<T> {
val token = config.tokenProvider.token()
val response =
httpClient.get("${config.helixBaseUrl}/$endpoint") {
header("Authorization", "Bearer $token")
header("Client-Id", config.clientId)
params.forEach { (key, value) -> url.parameters.append(key, value) }
}
return handleResponse(response)
}
suspend inline fun <reified T> post(
endpoint: String,
body: TextContent? = null,
params: List<Pair<String, String>> = emptyList(),
): TwitchResponse<T> {
val token = config.tokenProvider.token()
val response =
httpClient.post("${config.helixBaseUrl}/$endpoint") {
header("Authorization", "Bearer $token")
header("Client-Id", config.clientId)
params.forEach { (key, value) -> url.parameters.append(key, value) }
if (body != null) {
setBody(body)
}
}
return handleResponse(response)
}
suspend fun postNoContent(
endpoint: String,
body: TextContent? = null,
params: List<Pair<String, String>> = emptyList(),
) {
val token = config.tokenProvider.token()
val response =
httpClient.post("${config.helixBaseUrl}/$endpoint") {
header("Authorization", "Bearer $token")
header("Client-Id", config.clientId)
params.forEach { (key, value) -> url.parameters.append(key, value) }
if (body != null) {
setBody(body)
}
}
handleNoContentResponse(response)
}
suspend fun patchNoContent(
endpoint: String,
body: TextContent? = null,
params: List<Pair<String, String>> = emptyList(),
) {
val token = config.tokenProvider.token()
val response =
httpClient.patch("${config.helixBaseUrl}/$endpoint") {
header("Authorization", "Bearer $token")
header("Client-Id", config.clientId)
params.forEach { (key, value) -> url.parameters.append(key, value) }
if (body != null) {
setBody(body)
}
}
handleNoContentResponse(response)
}
suspend inline fun <reified T> patch(
endpoint: String,
body: TextContent? = null,
params: List<Pair<String, String>> = emptyList(),
): TwitchResponse<T> {
val token = config.tokenProvider.token()
val response =
httpClient.patch("${config.helixBaseUrl}/$endpoint") {
header("Authorization", "Bearer $token")
header("Client-Id", config.clientId)
params.forEach { (key, value) -> url.parameters.append(key, value) }
if (body != null) {
setBody(body)
}
}
return handleResponse(response)
}
suspend inline fun <reified T> put(
endpoint: String,
body: TextContent? = null,
params: List<Pair<String, String>> = emptyList(),
): TwitchResponse<T> {
val token = config.tokenProvider.token()
val response =
httpClient.put("${config.helixBaseUrl}/$endpoint") {
header("Authorization", "Bearer $token")
header("Client-Id", config.clientId)
params.forEach { (key, value) -> url.parameters.append(key, value) }
if (body != null) {
setBody(body)
}
}
return handleResponse(response)
}
suspend fun putNoContent(
endpoint: String,
body: TextContent? = null,
params: List<Pair<String, String>> = emptyList(),
) {
val token = config.tokenProvider.token()
val response =
httpClient.put("${config.helixBaseUrl}/$endpoint") {
header("Authorization", "Bearer $token")
header("Client-Id", config.clientId)
params.forEach { (key, value) -> url.parameters.append(key, value) }
if (body != null) {
setBody(body)
}
}
handleNoContentResponse(response)
}
suspend inline fun <reified R> getTyped(
endpoint: String,
params: List<Pair<String, String>> = emptyList(),
): R {
val token = config.tokenProvider.token()
val response =
httpClient.get("${config.helixBaseUrl}/$endpoint") {
header("Authorization", "Bearer $token")
header("Client-Id", config.clientId)
params.forEach { (key, value) -> url.parameters.append(key, value) }
}
return handleTypedResponse(response)
}
suspend inline fun <reified R> putTyped(
endpoint: String,
body: TextContent? = null,
params: List<Pair<String, String>> = emptyList(),
): R {
val token = config.tokenProvider.token()
val response =
httpClient.put("${config.helixBaseUrl}/$endpoint") {
header("Authorization", "Bearer $token")
header("Client-Id", config.clientId)
params.forEach { (key, value) -> url.parameters.append(key, value) }
if (body != null) {
setBody(body)
}
}
return handleTypedResponse(response)
}
suspend inline fun <reified T> delete(
endpoint: String,
params: List<Pair<String, String>> = emptyList(),
): TwitchResponse<T> {
val token = config.tokenProvider.token()
val response =
httpClient.delete("${config.helixBaseUrl}/$endpoint") {
header("Authorization", "Bearer $token")
header("Client-Id", config.clientId)
params.forEach { (key, value) -> url.parameters.append(key, value) }
}
return handleResponse(response)
}
suspend fun deleteNoContent(
endpoint: String,
params: List<Pair<String, String>> = emptyList(),
) {
val token = config.tokenProvider.token()
val response =
httpClient.delete("${config.helixBaseUrl}/$endpoint") {
header("Authorization", "Bearer $token")
header("Client-Id", config.clientId)
params.forEach { (key, value) -> url.parameters.append(key, value) }
}
handleNoContentResponse(response)
}
inline fun <reified T> paginate(
endpoint: String,
params: List<Pair<String, String>> = emptyList(),
pageSize: Int = 100,
): Flow<T> =
flow {
var cursor: String? = null
do {
val paginatedParams =
buildList {
addAll(params)
add("first" to pageSize.toString())
cursor?.let { add("after" to it) }
}
val response = get<T>(endpoint, paginatedParams)
response.data.forEach { emit(it) }
cursor = response.pagination?.cursor
} while (cursor != null)
}
/**
* Fetches a single page of results from a paginated Helix endpoint.
*
* @param endpoint the Helix endpoint path (e.g. `"channels/followers"`).
* @param params query parameters to include in the request.
* @param pageSize the maximum number of items to return. When `null`, the Twitch API
* uses its own default for the endpoint. Must be positive if provided.
* @return a [Page] containing the items on this page and the cursor for the next page,
* or `null` cursor if this is the last page.
*/
suspend inline fun <reified T> getPage(
endpoint: String,
params: List<Pair<String, String>> = emptyList(),
pageSize: Int? = null,
): Page<T> {
require(pageSize == null || pageSize > 0) { "pageSize must be positive, was $pageSize" }
val fullParams =
buildList {
addAll(params)
pageSize?.let { add("first" to it.toString()) }
}
val response = get<T>(endpoint, fullParams)
return Page(data = response.data, cursor = response.pagination?.cursor)
}
@PublishedApi
internal suspend inline fun <reified T> handleResponse(response: HttpResponse): TwitchResponse<T> {
if (response.status.value in 200..299) {
return json.decodeFromString<TwitchResponse<T>>(response.bodyAsText())
}
throw mapException(response)
}
@PublishedApi
internal suspend inline fun <reified R> handleTypedResponse(response: HttpResponse): R {
if (response.status.value in 200..299) {
return json.decodeFromString<R>(response.bodyAsText())
}
throw mapException(response)
}
@PublishedApi
internal suspend fun handleNoContentResponse(response: HttpResponse) {
if (response.status.value in 200..299) {
return
}
throw mapException(response)
}
@OptIn(ExperimentalTime::class)
private suspend fun mapException(response: HttpResponse): TwitchApiException {
val errorBody = response.bodyAsText()
config.logger?.log(LogLevel.ERROR, TAG) {
"Helix API error ${response.status.value}: $errorBody"
}
val retryAfterMs =
if (response.status.value == 429) {
val resetEpoch = response.headers["Ratelimit-Reset"]?.toLongOrNull() ?: 0L
val nowEpoch = Clock.System.now().epochSeconds
((resetEpoch - nowEpoch) * 1000).coerceAtLeast(0L)
} else {
null
}
return mapTwitchApiError(response.status.value, errorBody, retryAfterMs)
}
suspend fun validateScopes(vararg required: TwitchScope) {
val provider = config.scopeProvider ?: return
val granted = provider.scopes()
val missing = required.filterNot { TwitchScope.isSatisfied(it, granted) }
if (missing.isNotEmpty()) {
throw TwitchApiException.MissingScope(missing)
}
}
suspend fun validateAnyScope(vararg anyOf: TwitchScope) {
val provider = config.scopeProvider ?: return
val granted = provider.scopes()
val satisfied = anyOf.any { TwitchScope.isSatisfied(it, granted) }
if (!satisfied) {
throw TwitchApiException.MissingScope(anyOf.toList())
}
}
companion object {
private const val TAG = "HelixHttpClient"
}
}