libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit a43fd507072d18eb559420e271587db5690b3d8d
parent 51d8d79ab4683ef406f6445ffa627958028ddf51
Author: Antoine A <>
Date:   Fri, 20 Oct 2023 15:34:53 +0000

Add /monitor endpoint and start testing stats

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt | 21++++++++++++++++++---
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 17+++++------------
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 10++++++++++
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 42+++++++++++++++++++++++++++++++++++++++++-
Mbank/src/test/kotlin/CoreBankApiTest.kt | 17++++++++++++++++-
Abank/src/test/kotlin/StatsTest.kt | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbank/src/test/kotlin/helpers.kt | 2+-
Mdatabase-versioning/libeufin-bank-0001.sql | 18+++++++++++-------
Mdatabase-versioning/procedures.sql | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
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 $$;