init projects
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/build
|
||||
/bin
|
||||
@@ -0,0 +1,28 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.kmp)
|
||||
alias(libs.plugins.convention.android.lib)
|
||||
alias(libs.plugins.convention.serialization)
|
||||
alias(libs.plugins.convention.koin)
|
||||
alias(libs.plugins.convention.ktor.client)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
val commonMain by getting {
|
||||
dependencies {
|
||||
implementation(project(":libs:base"))
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.kotlinx.rpc.krpc.client)
|
||||
// Server API
|
||||
implementation(libs.kotlinx.rpc.krpc.server)
|
||||
// Serialization module. Also, protobuf and cbor are provided
|
||||
implementation(libs.kotlinx.rpc.krpc.serialization.json)
|
||||
|
||||
// Transport implementation for Ktor
|
||||
implementation(libs.kotlinx.rpc.krpc.ktor.client)
|
||||
implementation(libs.kotlinx.rpc.krpc.ktor.server)
|
||||
}
|
||||
}
|
||||
}
|
||||
android { namespace = "ru.shadowsparky.http" }
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package ru.shadowsparky.http.data
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
actual class HttpClientFactory actual constructor(private val settings: HttpClientSettings) {
|
||||
|
||||
actual fun create(): HttpClient {
|
||||
return HttpClient(OkHttp) {
|
||||
settings.setup(this)
|
||||
engine {
|
||||
config {
|
||||
retryOnConnectionFailure(true)
|
||||
connectTimeout(15, TimeUnit.SECONDS)
|
||||
readTimeout(15, TimeUnit.SECONDS)
|
||||
writeTimeout(15, TimeUnit.SECONDS)
|
||||
}
|
||||
preconfigured = OkHttpClient.Builder()
|
||||
.pingInterval(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package ru.shadowsparky.http
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.auth.providers.BearerAuthProvider
|
||||
import io.ktor.client.plugins.auth.providers.BearerTokens
|
||||
import io.ktor.client.plugins.auth.providers.RefreshTokensParams
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Configuration
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import ru.shadowsparky.http.data.HttpClientFactory
|
||||
import ru.shadowsparky.http.data.HttpClientSettings
|
||||
import ru.shadowsparky.http.data.RefreshTokenRunner
|
||||
import ru.shadowsparky.http.domain.HttpException
|
||||
import ru.shadowsparky.http.domain.LogoutAction
|
||||
import ru.shadowsparky.http.domain.RefreshTokenAction
|
||||
import ru.shadowsparky.http.domain.TokenInfo
|
||||
import ru.shadowsparky.http.domain.TokenStorage
|
||||
import ru.shadowsparky.koin
|
||||
|
||||
@Module
|
||||
@Configuration
|
||||
@ComponentScan
|
||||
class HttpModule {
|
||||
|
||||
@Single
|
||||
fun createHttpClient(settings: HttpClientSettings): HttpClient {
|
||||
return HttpClientFactory(settings).create()
|
||||
}
|
||||
|
||||
@Single
|
||||
fun createBearerAuthProvider(
|
||||
refreshTokenRunner: RefreshTokenRunner
|
||||
): BearerAuthProvider {
|
||||
val tokenStorage = koin.getOrNull<TokenStorage>()
|
||||
return BearerAuthProvider(
|
||||
refreshTokens = {
|
||||
refreshTokenRunner.runOrJoin {
|
||||
refreshTokenInternal(tokenStorage)
|
||||
}
|
||||
},
|
||||
loadTokens = {
|
||||
tokenStorage?.token?.firstOrNull()?.toKtor()
|
||||
},
|
||||
realm = tokenStorage?.realm
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun RefreshTokensParams.refreshTokenInternal(tokenStorage: TokenStorage?): BearerTokens? {
|
||||
val token = tokenStorage?.token?.firstOrNull()?.refresh ?: return null
|
||||
return try {
|
||||
val newToken = koin.getOrNull<RefreshTokenAction>()
|
||||
?.refresh(this.client, token)
|
||||
?: return null
|
||||
tokenStorage.updateToken(newToken)
|
||||
newToken.toKtor()
|
||||
} catch (e: Throwable) {
|
||||
if (e is HttpException) {
|
||||
koin.getOrNull<LogoutAction>()?.logout()
|
||||
}
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun TokenInfo.toKtor(): BearerTokens {
|
||||
return BearerTokens(token, refresh)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.http.data
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
|
||||
expect class HttpClientFactory(settings: HttpClientSettings) {
|
||||
fun create(): HttpClient
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package ru.shadowsparky.http.data
|
||||
|
||||
import io.ktor.client.HttpClientConfig
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.plugins.HttpResponseValidator
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.HttpTimeoutConfig
|
||||
import io.ktor.client.plugins.auth.Auth
|
||||
import io.ktor.client.plugins.auth.providers.BearerAuthProvider
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logger
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.domain.AppConfig
|
||||
import ru.shadowsparky.domain.Log
|
||||
import ru.shadowsparky.http.domain.ClientKtorExtension
|
||||
import ru.shadowsparky.http.domain.HttpException
|
||||
import ru.shadowsparky.http.domain.UserAgentProvider
|
||||
import ru.shadowsparky.koin
|
||||
|
||||
const val USE_HTTP_CUSTOM_CERTS = "use_http_custom_certs"
|
||||
|
||||
private const val MESSAGE_LONG = "message"
|
||||
private const val MESSAGE = "msg"
|
||||
|
||||
@Factory
|
||||
class HttpClientSettings(
|
||||
private val json: Json,
|
||||
private val userAgentProvider: UserAgentProvider?,
|
||||
private val bearerAuthProvider: BearerAuthProvider,
|
||||
private val appenderList: List<ClientKtorExtension>,
|
||||
private val log: Log
|
||||
) {
|
||||
|
||||
fun setup(
|
||||
clientConfig: HttpClientConfig<*>
|
||||
) = with(clientConfig) {
|
||||
install(Auth) {
|
||||
providers.add(bearerAuthProvider)
|
||||
}
|
||||
expectSuccess = false
|
||||
|
||||
HttpResponseValidator {
|
||||
validateResponse {
|
||||
val code = it.status.value
|
||||
if (code in 400..599) {
|
||||
val body = it.body<JsonElement>()
|
||||
if (body is JsonObject) {
|
||||
val msg = when {
|
||||
body.containsKey(MESSAGE_LONG) -> body[MESSAGE_LONG]?.jsonPrimitive?.content
|
||||
body.containsKey(MESSAGE) -> body[MESSAGE]?.jsonPrimitive?.content
|
||||
else -> body.toString()
|
||||
}
|
||||
throw HttpException(code, msg)
|
||||
} else {
|
||||
throw HttpException(code, body.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
install(Logging) {
|
||||
val isDebug = koin.getOrNull<AppConfig>()?.isDebug() ?: true
|
||||
if (isDebug) {
|
||||
this.level = LogLevel.ALL
|
||||
this.logger = object : Logger {
|
||||
override fun log(message: String) {
|
||||
log.d("Http", message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
install(ContentNegotiation) {
|
||||
json(json)
|
||||
}
|
||||
|
||||
install(WebSockets) {
|
||||
// как часто слать ping кадры
|
||||
pingIntervalMillis = 30_000 // или: pingInterval = 20.seconds (в новых API)
|
||||
}
|
||||
|
||||
defaultRequest {
|
||||
header(HttpHeaders.Accept, ContentType.Application.Json)
|
||||
header(HttpHeaders.ContentType, ContentType.Application.Json)
|
||||
userAgentProvider?.provide()?.let {
|
||||
header(HttpHeaders.UserAgent, it)
|
||||
}
|
||||
}
|
||||
|
||||
// Таймауты на уровне Ktor
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
|
||||
socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
|
||||
connectTimeoutMillis = 20_000
|
||||
}
|
||||
|
||||
appenderList.forEach { it.append(this) }
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package ru.shadowsparky.http.data
|
||||
|
||||
import io.ktor.client.plugins.auth.providers.BearerTokens
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
|
||||
|
||||
@Factory
|
||||
class RefreshTokenRunner(private val dispatcherProvider: DispatcherProvider) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.io)
|
||||
private val lock = Mutex()
|
||||
private var inFlight: Deferred<BearerTokens?>? = null
|
||||
|
||||
suspend fun runOrJoin(block: suspend () -> BearerTokens?): BearerTokens? {
|
||||
val deferred = lock.withLock {
|
||||
inFlight?.takeIf { it.isActive } ?: startNew(block).also { inFlight = it }
|
||||
}
|
||||
return deferred.await()
|
||||
}
|
||||
|
||||
private fun startNew(block: suspend () -> BearerTokens?): Deferred<BearerTokens?> {
|
||||
val d = scope.async(dispatcherProvider.io) { block() }
|
||||
d.invokeOnCompletion {
|
||||
scope.launch {
|
||||
lock.withLock {
|
||||
if (inFlight === d) inFlight = null
|
||||
}
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.http.domain
|
||||
|
||||
import io.ktor.client.HttpClientConfig
|
||||
|
||||
interface ClientKtorExtension {
|
||||
fun append(clientConfig: HttpClientConfig<*>)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.shadowsparky.http.domain
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
|
||||
open class HttpException(val httpCode: Int, override val message: String?) : RuntimeException()
|
||||
|
||||
class BadRequestException(msg: String) : HttpException(HttpStatusCode.BadRequest.value, msg)
|
||||
open class NotFoundException(msg: String) : HttpException(HttpStatusCode.NotFound.value, msg)
|
||||
@@ -0,0 +1,5 @@
|
||||
package ru.shadowsparky.http.domain
|
||||
|
||||
interface LogoutAction {
|
||||
suspend fun logout()
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.http.domain
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
|
||||
interface RefreshTokenAction {
|
||||
suspend fun refresh(client: HttpClient, refreshToken: String): TokenInfo
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package ru.shadowsparky.http.domain
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class TokenInfo(
|
||||
val token: String,
|
||||
val refresh: String? = null
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package ru.shadowsparky.http.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
|
||||
interface TokenStorage {
|
||||
val realm: String
|
||||
val token: Flow<TokenInfo?>
|
||||
|
||||
suspend fun updateToken(newToken: TokenInfo)
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
suspend fun TokenStorage.hasAccount(): Boolean {
|
||||
return token.firstOrNull()?.token != null
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package ru.shadowsparky.http.domain
|
||||
|
||||
interface UserAgentProvider {
|
||||
fun provide(): String?
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package ru.shadowsparky.http.data
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
actual class HttpClientFactory actual constructor(private val settings: HttpClientSettings) {
|
||||
actual fun create(): HttpClient {
|
||||
return HttpClient(OkHttp) {
|
||||
settings.setup(this)
|
||||
engine {
|
||||
config {
|
||||
retryOnConnectionFailure(true)
|
||||
connectTimeout(15, TimeUnit.SECONDS)
|
||||
readTimeout(15, TimeUnit.SECONDS)
|
||||
writeTimeout(15, TimeUnit.SECONDS)
|
||||
}
|
||||
preconfigured = OkHttpClient.Builder().pingInterval(30, TimeUnit.SECONDS).build()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package ru.shadowsparky.http.data
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.js.Js
|
||||
|
||||
actual class HttpClientFactory actual constructor(private val settings: HttpClientSettings) {
|
||||
actual fun create(): HttpClient {
|
||||
return HttpClient(Js) {
|
||||
settings.setup(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user