commit a43fd507072d18eb559420e271587db5690b3d8d
parent 51d8d79ab4683ef406f6445ffa627958028ddf51
Author: Antoine A <>
Date: Fri, 20 Oct 2023 15:34:53 +0000
Add /monitor endpoint and start testing stats
Diffstat:
10 files changed, 306 insertions(+), 28 deletions(-)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt
@@ -18,7 +18,22 @@ import kotlin.random.Random
private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.accountsMgmtHandlers")
-fun Routing.coreBankTokenApi(db: Database) {
+fun Routing.coreBankApi(db: Database, ctx: BankApplicationContext) {
+ get("/config") {
+ call.respond(Config(ctx.currencySpecification))
+ }
+ get("/monitor") {
+ call.authAdmin(db, TokenScope.readonly)
+ val params = MonitorParams.extract(call.request.queryParameters)
+ call.respond(db.monitor(params))
+ }
+ coreBankTokenApi(db)
+ coreBankAccountsMgmtApi(db, ctx)
+ coreBankTransactionsApi(db, ctx)
+ coreBankWithdrawalApi(db, ctx)
+}
+
+private fun Routing.coreBankTokenApi(db: Database) {
post("/accounts/{USERNAME}/token") {
val (login, _) = call.authCheck(db, TokenScope.refreshable)
val maybeAuthToken = call.getAuthToken()
@@ -95,7 +110,7 @@ fun Routing.coreBankTokenApi(db: Database) {
}
-fun Routing.coreBankAccountsMgmtApi(db: Database, ctx: BankApplicationContext) {
+private fun Routing.coreBankAccountsMgmtApi(db: Database, ctx: BankApplicationContext) {
post("/accounts") {
// check if only admin is allowed to create new accounts
if (ctx.restrictRegistration) {
@@ -325,7 +340,7 @@ fun Routing.coreBankAccountsMgmtApi(db: Database, ctx: BankApplicationContext) {
}
}
-fun Routing.coreBankTransactionsApi(db: Database, ctx: BankApplicationContext) {
+private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankApplicationContext) {
get("/accounts/{USERNAME}/transactions") {
call.authCheck(db, TokenScope.readonly)
val params = getHistoryParams(call.request.queryParameters)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt
@@ -96,7 +96,7 @@ private fun PreparedStatement.executeUpdateViolation(): Boolean {
}
}
-class Database(dbConfig: String, private val bankCurrency: String): java.io.Closeable {
+class Database(dbConfig: String, private val bankCurrency: String, private val fiatCurrency: String): java.io.Closeable {
val dbPool: HikariDataSource
private val notifWatcher: NotificationWatcher
@@ -1471,6 +1471,71 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
}
}
}
+
+ suspend fun monitor(
+ params: MonitorParams
+ ): MonitorResponse = conn { conn ->
+ val stmt = conn.prepareStatement("""
+ SELECT
+ cashin_count
+ ,(cashin_volume_in_fiat).val as cashin_volume_in_fiat_val
+ ,(cashin_volume_in_fiat).frac as cashin_volume_in_fiat_frac
+ ,cashout_count
+ ,(cashout_volume_in_fiat).val as cashout_volume_in_fiat_val
+ ,(cashout_volume_in_fiat).frac as cashout_volume_in_fiat_frac
+ ,internal_taler_payments_count
+ ,(internal_taler_payments_volume).val as internal_taler_payments_volume_val
+ ,(internal_taler_payments_volume).frac as internal_taler_payments_volume_frac
+ FROM stats_get_frame(?::stat_timeframe_enum, ?)
+ """)
+ stmt.setString(1, params.timeframe.name)
+ if (params.which != null) {
+ stmt.setInt(2, params.which)
+ } else {
+ stmt.setNull(2, java.sql.Types.INTEGER)
+ }
+ stmt.oneOrNull {
+ MonitorResponse(
+ cashinCount = it.getLong("cashin_count"),
+ cashinExternalVolume = TalerAmount(
+ value = it.getLong("cashin_volume_in_fiat_val"),
+ frac = it.getInt("cashin_volume_in_fiat_frac"),
+ currency = fiatCurrency
+ ),
+ cashoutCount = it.getLong("cashout_count"),
+ cashoutExternalVolume = TalerAmount(
+ value = it.getLong("cashout_volume_in_fiat_val"),
+ frac = it.getInt("cashout_volume_in_fiat_frac"),
+ currency = fiatCurrency
+ ),
+ talerPayoutCount = it.getLong("internal_taler_payments_count"),
+ talerPayoutInternalVolume = TalerAmount(
+ value = it.getLong("internal_taler_payments_volume_val"),
+ frac = it.getInt("internal_taler_payments_volume_frac"),
+ currency = bankCurrency
+ )
+ )
+ } ?: MonitorResponse(
+ cashinCount = 0,
+ cashinExternalVolume = TalerAmount(
+ value = 0,
+ frac = 0,
+ currency = fiatCurrency
+ ),
+ cashoutCount = 0,
+ cashoutExternalVolume = TalerAmount(
+ value = 0,
+ frac = 0,
+ currency = fiatCurrency
+ ),
+ talerPayoutCount = 0,
+ talerPayoutInternalVolume = TalerAmount(
+ value = 0,
+ frac = 0,
+ currency = bankCurrency
+ )
+ )
+ }
}
/** Result status of customer account deletion */
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
@@ -255,16 +255,9 @@ fun Application.corebankWebApp(db: Database, ctx: BankApplicationContext) {
}
}
routing {
- get("/config") {
- call.respond(Config(ctx.currencySpecification))
- return@get
- }
- this.coreBankTokenApi(db)
- this.coreBankAccountsMgmtApi(db, ctx)
- this.coreBankTransactionsApi(db, ctx)
- this.coreBankWithdrawalApi(db, ctx)
- this.bankIntegrationApi(db, ctx)
- this.wireGatewayApi(db, ctx)
+ coreBankApi(db, ctx)
+ bankIntegrationApi(db, ctx)
+ wireGatewayApi(db, ctx)
}
}
@@ -351,7 +344,7 @@ class ServeBank : CliktCommand("Run libeufin-bank HTTP server", name = "serve")
logger.info("Can only serve libeufin-bank via TCP")
exitProcess(1)
}
- val db = Database(dbCfg.dbConnStr, ctx.currency)
+ val db = Database(dbCfg.dbConnStr, ctx.currency, "TODO")
runBlocking {
if (!maybeCreateAdminAccount(db, ctx)) // logs provided by the helper
exitProcess(1)
@@ -374,7 +367,7 @@ class ChangePw : CliktCommand("Change account password", name = "passwd") {
val cfg = talerConfig(configFile)
val ctx = cfg.loadBankApplicationContext()
val dbCfg = cfg.loadDbConfig()
- val db = Database(dbCfg.dbConnStr, ctx.currency)
+ val db = Database(dbCfg.dbConnStr, ctx.currency, "TODO")
runBlocking {
if (!maybeCreateAdminAccount(db, ctx)) // logs provided by the helper
exitProcess(1)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
@@ -106,6 +106,16 @@ data class TokenRequest(
val refreshable: Boolean = false
)
+@Serializable
+data class MonitorResponse(
+ val cashinCount: Long,
+ val cashinExternalVolume: TalerAmount,
+ val cashoutCount: Long,
+ val cashoutExternalVolume: TalerAmount,
+ val talerPayoutCount: Long,
+ val talerPayoutInternalVolume: TalerAmount
+)
+
/**
* Convenience type to throw errors along the bank activity
* and that is meant to be caught by Ktor and responded to the
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
@@ -24,13 +24,15 @@ import io.ktor.server.application.*
import io.ktor.server.plugins.*
import io.ktor.server.request.*
import io.ktor.server.util.*
+import io.ktor.util.valuesOf
import net.taler.common.errorcodes.TalerErrorCode
import net.taler.wallet.crypto.Base32Crockford
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import tech.libeufin.util.*
import java.net.URL
-import java.time.Instant
+import java.time.*
+import java.time.temporal.*
import java.util.*
private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.helpers")
@@ -181,6 +183,44 @@ suspend fun getWithdrawal(db: Database, opIdParam: String): TalerWithdrawalOpera
return op
}
+enum class Timeframe {
+ hour,
+ day,
+ month,
+ year
+}
+
+data class MonitorParams(
+ val timeframe: Timeframe,
+ val which: Int?
+) {
+ companion object {
+ fun extract(params: Parameters): MonitorParams {
+ val timeframe = Timeframe.valueOf(params["timeframe"] ?: throw MissingRequestParameterException(parameterName = "timeframe"))
+ val which = try {
+ params["which"]?.toInt()
+ } catch (e: Exception) {
+ throw badRequest("Param 'which' not a number")
+ }
+ if (which != null) {
+ val lastDayOfMonth = OffsetDateTime.now(ZoneOffset.UTC).with(TemporalAdjusters.lastDayOfMonth()).dayOfMonth
+ when {
+ timeframe == Timeframe.hour && (0 > which || which > 23) ->
+ throw badRequest("For hour timestamp param 'which' must be between 00 to 23")
+ timeframe == Timeframe.day && (1 > which || which > 23) ->
+ throw badRequest("For day timestamp param 'which' must be between 1 to $lastDayOfMonth")
+ timeframe == Timeframe.month && (1 > which || which > lastDayOfMonth) ->
+ throw badRequest("For month timestamp param 'which' must be between 1 to 12")
+ timeframe == Timeframe.year && (1 > which|| which > 9999) ->
+ throw badRequest("For year timestamp param 'which' must be between 0001 to 9999")
+ else -> {}
+ }
+ }
+ return MonitorParams(timeframe, which)
+ }
+ }
+}
+
data class HistoryParams(
val delta: Int, val start: Long, val poll_ms: Long
)
diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt
@@ -24,10 +24,25 @@ import kotlin.test.*
import kotlinx.coroutines.*
class CoreBankConfigTest {
+ // GET /config
@Test
- fun getConfig() = bankSetup { _ ->
+ fun config() = bankSetup { _ ->
client.get("/config").assertOk()
}
+
+ // GET /monitor
+ @Test
+ fun monitor() = bankSetup { _ ->
+ // Check OK
+ client.get("/monitor?timeframe=hour") {
+ basicAuth("admin", "admin-password")
+ }.assertOk()
+
+ // Check only admin
+ client.get("/monitor") {
+ basicAuth("exchange", "exchange-password")
+ }.assertUnauthorized()
+ }
}
class CoreBankTokenApiTest {
diff --git a/bank/src/test/kotlin/StatsTest.kt b/bank/src/test/kotlin/StatsTest.kt
@@ -0,0 +1,74 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2019 Stanisci and Dold.
+
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+import org.junit.Test
+import io.ktor.server.testing.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.*
+import io.ktor.http.content.*
+import kotlinx.serialization.json.Json
+import tech.libeufin.bank.*
+import tech.libeufin.util.*
+import kotlin.test.*
+import java.time.Instant
+import java.util.*
+
+class StatsTest {
+ @Test
+ fun internalTalerPayment() = bankSetup { db ->
+ db.conn { conn ->
+ val stmt = conn.prepareStatement("CALL stats_register_internal_taler_payment((?, ?)::taler_amount)")
+
+ suspend fun register(amount: TalerAmount) {
+ stmt.setLong(1, amount.value)
+ stmt.setInt(2, amount.frac)
+ stmt.executeUpdate()
+ }
+
+ client.get("/monitor?timeframe=hour") {
+ basicAuth("admin", "admin-password")
+ }.assertOk().run {
+ val resp = Json.decodeFromString<MonitorResponse>(bodyAsText())
+ assertEquals(0, resp.talerPayoutCount)
+ assertEquals(TalerAmount("KUDOS:0"), resp.talerPayoutInternalVolume)
+ }
+
+ register(TalerAmount("KUDOS:10.0"))
+ client.get("/monitor?timeframe=hour") {
+ basicAuth("admin", "admin-password")
+ }.assertOk().run {
+ val resp = Json.decodeFromString<MonitorResponse>(bodyAsText())
+ assertEquals(1, resp.talerPayoutCount)
+ assertEquals(TalerAmount("KUDOS:10"), resp.talerPayoutInternalVolume)
+ }
+
+ register(TalerAmount("KUDOS:30.5"))
+ client.get("/monitor?timeframe=hour") {
+ basicAuth("admin", "admin-password")
+ }.assertOk().run {
+ val resp = Json.decodeFromString<MonitorResponse>(bodyAsText())
+ assertEquals(2, resp.talerPayoutCount)
+ assertEquals(TalerAmount("KUDOS:40.5"), resp.talerPayoutInternalVolume)
+ }
+
+ // TODO Test timeframe logic using now() mocking
+ }
+ }
+}
+\ No newline at end of file
diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt
@@ -58,7 +58,7 @@ fun setup(
resetDatabaseTables(dbCfg, "libeufin-bank")
initializeDatabaseTables(dbCfg, "libeufin-bank")
val ctx = config.loadBankApplicationContext()
- Database(dbCfg.dbConnStr, ctx.currency).use {
+ Database(dbCfg.dbConnStr, ctx.currency, "TODO").use {
runBlocking {
lambda(it, ctx)
}
diff --git a/database-versioning/libeufin-bank-0001.sql b/database-versioning/libeufin-bank-0001.sql
@@ -48,7 +48,7 @@ CREATE TYPE subscriber_state_enum
AS ENUM ('new', 'confirmed');
CREATE TYPE stat_timeframe_enum
- AS ENUM ('hour', 'day', 'month', 'year', 'decade');
+ AS ENUM ('hour', 'day', 'month', 'year');
-- FIXME: comments on types (see exchange for example)!
@@ -400,25 +400,29 @@ COMMENT ON COLUMN taler_withdrawal_operations.confirmation_done
-- end of: Taler integration
+-- start of: Statistics
CREATE TABLE IF NOT EXISTS regional_stats (
- regional_stats_id BIGINT GENERATED BY DEFAULT AS IDENTITY
+ timeframe stat_timeframe_enum NOT NULL
+ ,start_time timestamp NOT NULL
,cashin_count BIGINT NOT NULL
,cashin_volume_in_fiat taler_amount NOT NULL
,cashout_count BIGINT NOT NULL
,cashout_volume_in_fiat taler_amount NOT NULL
,internal_taler_payments_count BIGINT NOT NULL
,internal_taler_payments_volume taler_amount NOT NULL
- ,timeframe stat_timeframe_enum NOT NULL
+ ,PRIMARY KEY (start_time, timeframe)
);
-
-COMMENT ON TABLE regional_stats IS
- 'Stores statistics about the regional currency usage. At any given time, this table stores at most: 24 hour rows, N day rows (with N being the highest day number of the current month), 12 month rows, 9 year rows, and any number of decade rows';
+-- TODO garbage collection
+COMMENT ON TABLE regional_stats IS 'Stores statistics about the regional currency usage.';
+COMMENT ON COLUMN regional_stats.timeframe IS 'particular timeframe that this row accounts for';
+COMMENT ON COLUMN regional_stats.start_time IS 'timestamp of the start of the timeframe that this row accounts for, truncated according to the precision of the timeframe';
COMMENT ON COLUMN regional_stats.cashin_count IS 'how many cashin operations took place in the timeframe';
COMMENT ON COLUMN regional_stats.cashin_volume_in_fiat IS 'how much fiat currency was cashed in in the timeframe';
COMMENT ON COLUMN regional_stats.cashout_count IS 'how many cashout operations took place in the timeframe';
COMMENT ON COLUMN regional_stats.cashout_volume_in_fiat IS 'how much fiat currency was payed by the bank to customers in the timeframe';
COMMENT ON COLUMN regional_stats.internal_taler_payments_count IS 'how many internal payments were made by a Taler exchange';
COMMENT ON COLUMN regional_stats.internal_taler_payments_volume IS 'how much internal currency was paid by a Taler exchange';
-COMMENT ON COLUMN regional_stats.timeframe IS 'particular timeframe that this row accounts for';
+
+-- end of: Statistics
COMMIT;
diff --git a/database-versioning/procedures.sql b/database-versioning/procedures.sql
@@ -1017,6 +1017,67 @@ BEGIN
END IF;
out_already_confirmed=FALSE;
DELETE FROM cashout_operations WHERE cashout_uuid=in_cashout_uuid;
- RETURN;
END $$;
-COMMIT;
+
+CREATE OR REPLACE FUNCTION stats_get_frame(
+ IN timeframe stat_timeframe_enum,
+ IN which INTEGER,
+ OUT cashin_count BIGINT,
+ OUT cashin_volume_in_fiat taler_amount,
+ OUT cashout_count BIGINT,
+ OUT cashout_volume_in_fiat taler_amount,
+ OUT internal_taler_payments_count BIGINT,
+ OUT internal_taler_payments_volume taler_amount
+)
+LANGUAGE sql AS $$
+ SELECT
+ s.cashin_count
+ ,s.cashin_volume_in_fiat
+ ,s.cashout_count
+ ,s.cashout_volume_in_fiat
+ ,s.internal_taler_payments_count
+ ,s.internal_taler_payments_volume
+ FROM regional_stats AS s
+ WHERE s.timeframe = timeframe
+ AND start_time = CASE
+ WHEN which IS NULL THEN date_trunc(timeframe::text, now())
+ WHEN timeframe = 'hour' THEN date_trunc('day', now()) + '1 hour'::interval * which
+ WHEN timeframe = 'day' THEN date_trunc('month', now()) + '1 day'::interval * which
+ WHEN timeframe = 'month' THEN date_trunc('year', now()) + '1 month'::interval * which
+ WHEN timeframe = 'year' THEN make_date(which, 1, 1)::TIMESTAMP
+ END
+$$;
+
+CREATE OR REPLACE PROCEDURE stats_register_internal_taler_payment(
+ IN amount taler_amount
+)
+LANGUAGE plpgsql AS $$
+DECLARE
+ frame stat_timeframe_enum;
+BEGIN
+ FOREACH frame IN ARRAY enum_range(null::stat_timeframe_enum) LOOP
+ INSERT INTO regional_stats AS s (
+ timeframe
+ ,start_time
+ ,cashin_count
+ ,cashin_volume_in_fiat
+ ,cashout_count
+ ,cashout_volume_in_fiat
+ ,internal_taler_payments_count
+ ,internal_taler_payments_volume
+ )
+ VALUES (
+ frame
+ ,date_trunc(frame::text, now())
+ ,0
+ ,(0, 0)::taler_amount
+ ,0
+ ,(0, 0)::taler_amount
+ ,1
+ ,amount
+ )
+ ON CONFLICT (timeframe, start_time) DO UPDATE
+ SET internal_taler_payments_count = s.internal_taler_payments_count+1
+ ,internal_taler_payments_volume = (SELECT amount_add(s.internal_taler_payments_volume, amount));
+ END LOOP;
+END $$;