This commit is contained in:
2026-07-29 16:21:59 +03:00
parent b83a40f4bd
commit 6485f4ec26
41 changed files with 797 additions and 224 deletions
@@ -0,0 +1,30 @@
package ru.shadowsparky.http.data
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.auth.providers.BearerAuthProvider
import org.koin.core.annotation.Factory
import ru.shadowsparky.http.domain.HTTP_TIMEOUT_SECS
import ru.shadowsparky.http.domain.HttpClientFactory
import java.util.concurrent.TimeUnit
@Factory
class AndroidHttpClientFactory(private val settings: HttpClientSettings) : HttpClientFactory {
override fun create(
bearerAuthProvider: BearerAuthProvider?,
setupWebSocket: Boolean,
setupValidator: Boolean
): HttpClient {
return HttpClient(OkHttp) {
settings.setup(this, bearerAuthProvider, setupWebSocket, setupValidator)
engine {
config {
connectTimeout(HTTP_TIMEOUT_SECS, TimeUnit.SECONDS)
readTimeout(HTTP_TIMEOUT_SECS, TimeUnit.SECONDS)
writeTimeout(HTTP_TIMEOUT_SECS, TimeUnit.SECONDS)
}
}
}
}
}
@@ -1,26 +0,0 @@
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()
}
}
}
}
@@ -9,9 +9,8 @@ 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.HttpClientFactory
import ru.shadowsparky.http.domain.HttpException
import ru.shadowsparky.http.domain.LogoutAction
import ru.shadowsparky.http.domain.RefreshTokenAction
@@ -25,8 +24,15 @@ import ru.shadowsparky.koin
class HttpModule {
@Single
fun createHttpClient(settings: HttpClientSettings): HttpClient {
return HttpClientFactory(settings).create()
fun createHttpClient(
httpClientFactory: HttpClientFactory,
bearerAuthProvider: BearerAuthProvider
): HttpClient {
return httpClientFactory.create(
bearerAuthProvider,
setupWebSocket = true,
setupValidator = true
)
}
@Single
@@ -1,7 +0,0 @@
package ru.shadowsparky.http.data
import io.ktor.client.HttpClient
expect class HttpClientFactory(settings: HttpClientSettings) {
fun create(): HttpClient
}
@@ -24,10 +24,13 @@ 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.HTTP_PING_TIMEOUT_SECS
import ru.shadowsparky.http.domain.HTTP_TIMEOUT_SECS
import ru.shadowsparky.http.domain.HttpException
import ru.shadowsparky.http.domain.UserAgentProvider
import ru.shadowsparky.koin
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
private const val MESSAGE_LONG = "message"
private const val MESSAGE = "msg"
@@ -36,39 +39,42 @@ private const val MESSAGE = "msg"
class HttpClientSettings(
private val json: Json,
private val userAgentProvider: UserAgentProvider?,
private val bearerAuthProvider: BearerAuthProvider,
private val appenderList: List<ClientKtorExtension>,
private val log: Log,
private val appConfig: AppConfig?
) {
fun setup(
clientConfig: HttpClientConfig<*>
clientConfig: HttpClientConfig<*>,
bearerAuthProvider: BearerAuthProvider?,
setupWebSocket: Boolean,
setupValidator: Boolean
) = with(clientConfig) {
install(Auth) {
providers.add(bearerAuthProvider)
if (bearerAuthProvider != null) {
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()
if (setupValidator) {
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())
}
throw HttpException(code, msg)
} else {
throw HttpException(code, body.toString())
}
}
}
}
install(Logging) {
val isDebug = koin.getOrNull<AppConfig>()?.debug ?: true
if (isDebug) {
@@ -85,9 +91,10 @@ class HttpClientSettings(
json(json)
}
install(WebSockets) {
// как часто слать ping кадры
pingIntervalMillis = 30_000 // или: pingInterval = 20.seconds (в новых API)
if (setupWebSocket) {
install(WebSockets) {
pingIntervalMillis = HTTP_PING_TIMEOUT_SECS.seconds.toLong(DurationUnit.MILLISECONDS)
}
}
defaultRequest {
@@ -98,14 +105,11 @@ class HttpClientSettings(
}
}
// Таймауты на уровне Ktor
install(HttpTimeout) {
requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
connectTimeoutMillis = 20_000
connectTimeoutMillis = HTTP_TIMEOUT_SECS.seconds.toLong(DurationUnit.MILLISECONDS)
}
appenderList.forEach { it.append(this) }
}
private val AppConfig.userAgent: String get() = buildString {
@@ -1,39 +1,38 @@
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.CompletableDeferred
import kotlinx.coroutines.completeWith
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)
class RefreshTokenRunner {
private val lock = Mutex()
private var inFlight: Deferred<BearerTokens?>? = null
private var inFlight: CompletableDeferred<BearerTokens?>? = null
suspend fun runOrJoin(block: suspend () -> BearerTokens?): BearerTokens? {
val deferred = lock.withLock {
inFlight?.takeIf { it.isActive } ?: startNew(block).also { inFlight = it }
val myDeferred = lock.withLock {
val current = inFlight
if (current != null) {
return current.await()
}
CompletableDeferred<BearerTokens?>().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
try {
val tokens = block()
myDeferred.complete(tokens)
return tokens
} catch (e: Throwable) {
myDeferred.completeWith(Result.failure(e))
throw e
} finally {
lock.withLock {
if (inFlight === myDeferred) {
inFlight = null
}
}
}
return d
}
}
@@ -1,7 +0,0 @@
package ru.shadowsparky.http.domain
import io.ktor.client.HttpClientConfig
interface ClientKtorExtension {
fun append(clientConfig: HttpClientConfig<*>)
}
@@ -0,0 +1,15 @@
package ru.shadowsparky.http.domain
import io.ktor.client.HttpClient
import io.ktor.client.plugins.auth.providers.BearerAuthProvider
internal const val HTTP_TIMEOUT_SECS = 30L
internal const val HTTP_PING_TIMEOUT_SECS = 30L
interface HttpClientFactory {
fun create(
bearerAuthProvider: BearerAuthProvider? = null,
setupWebSocket: Boolean = true,
setupValidator: Boolean = true
): HttpClient
}
@@ -1,23 +0,0 @@
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,107 @@
package ru.shadowsparky.http.data
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.auth.providers.BearerAuthProvider
import org.koin.core.annotation.Factory
import ru.shadowsparky.http.domain.HTTP_TIMEOUT_SECS
import ru.shadowsparky.http.domain.HttpClientFactory
import java.io.ByteArrayInputStream
import java.security.KeyStore
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
@Factory
class JvmHttpClientFactory(private val settings: HttpClientSettings) : HttpClientFactory {
override fun create(
bearerAuthProvider: BearerAuthProvider?,
setupWebSocket: Boolean,
setupValidator: Boolean
): HttpClient {
return HttpClient(OkHttp) {
settings.setup(this, bearerAuthProvider, setupWebSocket, setupValidator)
engine {
config {
connectTimeout(HTTP_TIMEOUT_SECS, TimeUnit.SECONDS)
readTimeout(HTTP_TIMEOUT_SECS, TimeUnit.SECONDS)
writeTimeout(HTTP_TIMEOUT_SECS, TimeUnit.SECONDS)
val (sslSocketFactory, trustManager) = createSslConfig()
sslSocketFactory(sslSocketFactory, trustManager)
}
}
}
}
private fun createSslConfig(): Pair<SSLSocketFactory, X509TrustManager> {
val defaultTmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm()
val defaultTmf = TrustManagerFactory.getInstance(defaultTmfAlgorithm).apply {
init(null as KeyStore?)
}
val defaultX509TrustManager = defaultTmf.trustManagers
.first { it is X509TrustManager } as X509TrustManager
val keyStoreType = KeyStore.getDefaultType()
val combinedKeyStore = KeyStore.getInstance(keyStoreType).apply {
load(null, null)
}
defaultX509TrustManager.acceptedIssuers.forEachIndexed { index, cert ->
combinedKeyStore.setCertificateEntry("system_ca_$index", cert)
}
val cf = CertificateFactory.getInstance("X.509")
val certInputStream = ByteArrayInputStream(CUSTOM_PEM_CERTIFICATE.trimIndent().toByteArray(Charsets.UTF_8))
val customCertificate = cf.generateCertificate(certInputStream) as X509Certificate
combinedKeyStore.setCertificateEntry("russian_trusted_root_ca", customCertificate)
val combinedTmf = TrustManagerFactory.getInstance(defaultTmfAlgorithm).apply {
init(combinedKeyStore)
}
val combinedTrustManagers = combinedTmf.trustManagers
val finalX509TrustManager = combinedTrustManagers.first { it is X509TrustManager } as X509TrustManager
val sslContext = SSLContext.getInstance("TLS").apply {
init(null, combinedTrustManagers, null)
}
return Pair(sslContext.socketFactory, finalX509TrustManager)
}
companion object {
private const val CUSTOM_PEM_CERTIFICATE = """
-----BEGIN CERTIFICATE-----
MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v
dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n
qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q
XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U
zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX
YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y
Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD
U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD
4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9
G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH
BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX
ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa
OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf
BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS
BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH
tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq
W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+
/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS
AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj
C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV
4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d
WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ
D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC
EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq
391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4=
-----END CERTIFICATE-----
"""
}
}
@@ -1,12 +0,0 @@
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)
}
}
}
@@ -0,0 +1,20 @@
package ru.shadowsparky.http.data
import io.ktor.client.HttpClient
import io.ktor.client.engine.js.Js
import io.ktor.client.plugins.auth.providers.BearerAuthProvider
import org.koin.core.annotation.Factory
import ru.shadowsparky.http.domain.HttpClientFactory
@Factory
class WasmHttpClientFactory(private val settings: HttpClientSettings) : HttpClientFactory {
override fun create(
bearerAuthProvider: BearerAuthProvider?,
setupWebSocket: Boolean,
setupValidator: Boolean
): HttpClient {
return HttpClient(Js) {
settings.setup(this, bearerAuthProvider, setupWebSocket, setupValidator)
}
}
}