Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ plugins {
}

group = "eu.kevin"
version = "0.2.7"
version = "0.2.9"

repositories {
mavenCentral()
Expand All @@ -22,6 +22,7 @@ dependencies {
implementation("io.ktor:ktor-client-core:${Versions.KTOR}")
implementation("io.ktor:ktor-client-cio:${Versions.KTOR}")
implementation("io.ktor:ktor-client-serialization:${Versions.KTOR}")
implementation("io.micrometer:micrometer-registry-prometheus:${Versions.MICROMETER}")

testImplementation(kotlin("test"))
testImplementation("io.ktor:ktor-client-mock:${Versions.KTOR}")
Expand Down
1 change: 1 addition & 0 deletions buildSrc/src/main/kotlin/Dependencies.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ object Versions {
const val KTOR = "1.6.5"
const val KTLINT = "10.2.0"
const val NEXUS_PUBLISH = "1.1.0"
const val MICROMETER = "1.9.1"
}
2 changes: 1 addition & 1 deletion src/main/kotlin/eu/kevin/api/models/ErrorCode.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ enum class ErrorCode(val code: Int) {
@SerialName("20016") INVALID_DEBTOR_ACCOUNT(20016),
@SerialName("20017") INSUFFICIENT_FUNDS(20017),
@SerialName("20020") CHOSEN_SCA_METHOD_REQUIRE_VALID_PSU_PERSON_ID(20020),
@SerialName("20017") CHOSEN_SCA_METHOD_REQUIRE_VALID_PSU_PHONE_NUMBER(20021),
@SerialName("20021") CHOSEN_SCA_METHOD_REQUIRE_VALID_PSU_PHONE_NUMBER(20021),
@SerialName("20022") CREDITOR_ACCOUNT_NUMBER_INVALID_OR_MISSING(20022),
@SerialName("20023") THE_ACCOUNT_NUMBER_AND_THE_NAME_DO_NOT_COINCIDE(20023),
@SerialName("20024") TRANSACTION_CURRENCY_IS_INVALID_OR_MISSING(20024),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package eu.kevin.api.plugins

import io.ktor.client.*
import io.ktor.client.features.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.client.utils.*
import io.ktor.http.*
import io.ktor.util.*
import io.ktor.util.pipeline.*
import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.Tag
import io.micrometer.core.instrument.Tags
import io.micrometer.core.instrument.Timer
import java.net.URLDecoder
import java.nio.charset.StandardCharsets

/**
* Feature to get statistics from KTor client. Partially lifted off https://youtrack.jetbrains.com/issue/KTOR-1004
*/
class KtorClientMicrometerMetrics internal constructor(val meterRegistry: MeterRegistry) {
private fun stopTimer(sample: Timer.Sample, url: Url, path: String, method: String, status: Int, throwable: Throwable?) {
val parameters = mutableMapOf(
"host" to url.host,
"path" to path,
"method" to method,
"status" to status.toString()
)

throwable?.let {
parameters["throwable"] = it.javaClass.simpleName
}

sample.stop(
meterRegistry.timer(
"http.client.requests",
Tags.of((parameters).map { Tag.of(it.key, it.value) })
)
)
}

class Config {
lateinit var meterRegistry: MeterRegistry
}

companion object Feature : HttpClientFeature<Config, KtorClientMicrometerMetrics> {
override val key: AttributeKey<KtorClientMicrometerMetrics> = AttributeKey("ClientMetrics")

val pathAttributeKey: AttributeKey<String> = AttributeKey("Path")
private val sampleAttributeKey: AttributeKey<Timer.Sample> = AttributeKey("TimerSample")
private val monitoringPhase = PipelinePhase("Monitoring")

override fun prepare(block: Config.() -> Unit): KtorClientMicrometerMetrics {
val config = Config().apply(block)
return KtorClientMicrometerMetrics(config.meterRegistry)
}

@OptIn(InternalAPI::class)
override fun install(plugin: KtorClientMicrometerMetrics, scope: HttpClient) {
scope.receivePipeline.insertPhaseAfter(HttpReceivePipeline.Before, monitoringPhase)

scope.sendPipeline.intercept(HttpSendPipeline.Monitoring) {
val sample = Timer.start(plugin.meterRegistry)
try {
context.attributes.put(sampleAttributeKey, sample)
proceed()
} catch (cause: Throwable) {
val url = context.url.build()

plugin.stopTimer(
sample = sample,
url = url,
path = context.attributes.getOrNull(pathAttributeKey)
?: URLDecoder.decode(url.encodedPath, StandardCharsets.UTF_8),
method = context.method.value,
status = 500,
throwable = cause
)
throw cause.unwrapCancellationException()
}
}

scope.receivePipeline.intercept(monitoringPhase) {
val url = it.call.request.url

plugin.stopTimer(
sample = it.request.attributes[sampleAttributeKey],
url = url,
path = it.request.attributes.getOrNull(pathAttributeKey)
?: URLDecoder.decode(url.encodedPath, StandardCharsets.UTF_8),
method = it.request.method.value,
status = it.call.response.status.value,
throwable = null
)
proceed()
}
}
}
}
21 changes: 14 additions & 7 deletions src/main/kotlin/eu/kevin/api/services/Client.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package eu.kevin.api.services
import eu.kevin.api.Dependencies
import eu.kevin.api.Endpoint
import eu.kevin.api.models.Authorization
import eu.kevin.api.plugins.KtorClientMicrometerMetrics
import eu.kevin.api.services.account.AccountClient
import eu.kevin.api.services.auth.AuthClient
import eu.kevin.api.services.general.GeneralClient
Expand All @@ -11,31 +12,32 @@ import io.ktor.client.*
import io.ktor.client.features.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.serialization.json.Json
import io.micrometer.core.instrument.MeterRegistry
import java.net.URI

class Client internal constructor(
private val authorization: Authorization,
private val apiUrl: String,
private val httpClient: HttpClient,
private val serializer: Json,
private val customHeaders: Map<String, String>
private val customHeaders: Map<String, String>,
private val micrometerRegistry: MeterRegistry?
) {
val paymentClient by lazy { PaymentClient(httpClient = httpClient.withAuthorization()) }
val authClient by lazy { AuthClient(httpClient = httpClient.withAuthorization()) }
val generalClient by lazy { GeneralClient(httpClient = httpClient.withAuthorization()) }
val accountClient by lazy { AccountClient(httpClient = httpClient.withAuthorization(), serializer = serializer) }
val accountClient by lazy { AccountClient(httpClient = httpClient.withAuthorization()) }

constructor(
authorization: Authorization,
apiUrl: String = Endpoint.BASE,
customHeaders: Map<String, String> = mapOf()
customHeaders: Map<String, String> = mapOf(),
micrometerRegistry: MeterRegistry? = null
) : this(
authorization = authorization,
apiUrl = apiUrl,
httpClient = Dependencies.httpClient,
serializer = Dependencies.serializer,
customHeaders = customHeaders
customHeaders = customHeaders,
micrometerRegistry = micrometerRegistry
)

private fun HttpClient.withAuthorization() = this.config {
Expand All @@ -51,5 +53,10 @@ class Client internal constructor(
header("Client-Secret", authorization.clientSecret)
customHeaders.forEach { header(it.key, it.value) }
}
if (micrometerRegistry != null) {
install(KtorClientMicrometerMetrics) {
meterRegistry = micrometerRegistry
}
}
}
}
10 changes: 7 additions & 3 deletions src/main/kotlin/eu/kevin/api/services/account/AccountClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,16 @@ import eu.kevin.api.models.account.detail.GetAccountDetailsRequest
import eu.kevin.api.models.account.list.AccountResponse
import eu.kevin.api.models.account.transaction.request.GetAccountTransactionsRequest
import eu.kevin.api.models.account.transaction.response.AccountTransactionResponse
import eu.kevin.api.plugins.KtorClientMicrometerMetrics
import io.ktor.client.*
import io.ktor.client.request.*
import kotlinx.serialization.json.Json
import io.ktor.util.*

/**
* Implements API Methods of the [Account information service](https://docs.kevin.eu/public/platform/v0.3#tag/Account-Information-Service)
*/
class AccountClient internal constructor(
private val httpClient: HttpClient,
private val serializer: Json
private val httpClient: HttpClient
) {

/**
Expand All @@ -33,6 +33,7 @@ class AccountClient internal constructor(
path = Endpoint.Paths.Account.getAccountsList()
) {
appendAccountRequestHeaders(headers = request)
attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Account.getAccountsList())
}.data

/**
Expand All @@ -44,6 +45,7 @@ class AccountClient internal constructor(
path = Endpoint.Paths.Account.getAccountDetails(accountId = request.accountId)
) {
appendAccountRequestHeaders(headers = request.headers)
attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Account.getAccountDetails("$"))
}

/**
Expand All @@ -57,6 +59,7 @@ class AccountClient internal constructor(
appendAccountRequestHeaders(headers = request.headers)
parameter("dateFrom", request.dateFrom)
parameter("dateTo", request.dateTo)
attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Account.getAccountTransactions("$"))
}.data

/**
Expand All @@ -68,6 +71,7 @@ class AccountClient internal constructor(
path = Endpoint.Paths.Account.getAccountBalance(accountId = request.accountId)
) {
appendAccountRequestHeaders(headers = request.headers)
attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Account.getAccountBalance("$"))
}.data

private fun HttpRequestBuilder.appendAccountRequestHeaders(headers: AccountRequestHeaders) {
Expand Down
11 changes: 9 additions & 2 deletions src/main/kotlin/eu/kevin/api/services/auth/AuthClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import eu.kevin.api.models.auth.token.request.RefreshTokenRequest
import eu.kevin.api.models.auth.token.response.ReceiveTokenResponse
import eu.kevin.api.models.auth.tokenContent.ReceiveTokenContentRequest
import eu.kevin.api.models.auth.tokenContent.ReceiveTokenContentResponse
import eu.kevin.api.plugins.KtorClientMicrometerMetrics
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.http.*
Expand Down Expand Up @@ -47,6 +48,7 @@ class AuthClient internal constructor(
webhookUrl?.let { append("Webhook-URL", it) }
}
}
attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Auth.startAuthentication())
}.run {
copy(
authorizationLink = Url(authorizationLink)
Expand All @@ -63,7 +65,9 @@ class AuthClient internal constructor(
httpClient.post(
path = Endpoint.Paths.Auth.receiveToken(),
body = request
)
) {
attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Auth.receiveToken())
}

/**
* API Method: [Receive token](https://docs.kevin.eu/public/platform/v0.3#operation/receiveToken), with `grantType` set to `refreshToken`
Expand All @@ -73,7 +77,9 @@ class AuthClient internal constructor(
httpClient.post(
path = Endpoint.Paths.Auth.receiveToken(),
body = request
)
) {
attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Auth.receiveToken())
}

/**
* API Method: [Receive token content](https://docs.kevin.eu/public/platform/v0.3#operation/receiveTokenContent)
Expand All @@ -86,5 +92,6 @@ class AuthClient internal constructor(
headers {
append(HttpHeaders.Authorization, request.accessToken.appendAtStartIfNotExist("Bearer "))
}
attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Auth.receiveTokenContent())
}
}
22 changes: 17 additions & 5 deletions src/main/kotlin/eu/kevin/api/services/general/GeneralClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import eu.kevin.api.exceptions.KevinApiErrorException
import eu.kevin.api.models.ResponseArray
import eu.kevin.api.models.general.bank.BankResponse
import eu.kevin.api.models.general.projectSettings.GetProjectSettingsResponse
import eu.kevin.api.plugins.KtorClientMicrometerMetrics
import io.ktor.client.*
import io.ktor.client.request.*

Expand All @@ -21,7 +22,9 @@ class GeneralClient internal constructor(
suspend fun getSupportedCountries(): List<String> =
httpClient.get<ResponseArray<String>>(
path = Endpoint.Paths.General.getSupportedCountries()
).data
) {
attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.General.getSupportedCountries())
}.data

/**
* API Method: [Get supported banks](https://docs.kevin.eu/public/platform/v0.3#operation/getBanks)
Expand All @@ -33,6 +36,7 @@ class GeneralClient internal constructor(
path = Endpoint.Paths.General.getSupportedBanks()
) {
parameter("countryCode", countryCode)
attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.General.getSupportedBanks())
}.data

/**
Expand All @@ -42,7 +46,9 @@ class GeneralClient internal constructor(
suspend fun getSupportedBank(bankId: String): BankResponse =
httpClient.get(
path = Endpoint.Paths.General.getSupportedBank(bankId = bankId)
)
) {
attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.General.getSupportedBank("$"))
}

/**
* API Method: [Get supported bank by card number piece](https://docs.kevin.eu/public/platform/v0.3#operation/getBankByCardNumberPiece)
Expand All @@ -51,7 +57,9 @@ class GeneralClient internal constructor(
suspend fun getSupportedBankByCardNumberPiece(cardNumberPiece: String): BankResponse =
httpClient.get(
path = Endpoint.Paths.General.getSupportedBankByCardNumberPiece(cardNumberPiece = cardNumberPiece)
)
) {
attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.General.getSupportedBankByCardNumberPiece("$"))
}

/**
* API Method: [Get payment methods](https://docs.kevin.eu/public/platform/v0.3#operation/getPaymentMethods)
Expand All @@ -60,7 +68,9 @@ class GeneralClient internal constructor(
suspend fun getPaymentMethods(): List<String> =
httpClient.get<ResponseArray<String>>(
path = Endpoint.Paths.General.getPaymentMethods()
).data
) {
attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.General.getPaymentMethods())
}.data

/**
* API Method: [Get project settings](https://docs.kevin.eu/public/platform/v0.3#operation/getProjectSettings)
Expand All @@ -69,5 +79,7 @@ class GeneralClient internal constructor(
suspend fun getProjectSettings(): GetProjectSettingsResponse =
httpClient.get(
path = Endpoint.Paths.General.getProjectSettings()
)
) {
attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.General.getProjectSettings())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import eu.kevin.api.models.payment.paymentStatus.GetPaymentStatusResponse
import eu.kevin.api.models.payment.refund.InitiatePaymentRefundRequest
import eu.kevin.api.models.payment.refund.InitiatePaymentRefundRequestBody
import eu.kevin.api.models.payment.refund.InitiatePaymentRefundResponse
import eu.kevin.api.plugins.KtorClientMicrometerMetrics
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.http.*
Expand Down Expand Up @@ -51,6 +52,7 @@ class PaymentClient internal constructor(
webhookUrl?.let { append("Webhook-URL", it) }
}
}
attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Payment.initiatePayment())
}.run {
copy(
confirmLink = confirmLink?.let {
Expand All @@ -66,7 +68,9 @@ class PaymentClient internal constructor(
suspend fun getPaymentStatus(request: GetPaymentStatusRequest): GetPaymentStatusResponse =
httpClient.get(
path = Endpoint.Paths.Payment.getPaymentStatus(paymentId = request.paymentId)
)
) {
attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Payment.getPaymentStatus("$"))
}

/**
* API Method: [Initiate payment refund](https://docs.kevin.eu/public/platform/v0.3#operation/initiatePaymentRefund)
Expand All @@ -82,5 +86,6 @@ class PaymentClient internal constructor(
webhookUrl?.let { append("Webhook-URL", it) }
}
}
attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Payment.initiatePaymentRefund("$"))
}
}