commit d7cecd35a5f7ab3ab2491e4c4ad4f07e041e9944 parent 1602c0b6cd3cad8d8b8f14d68f509842e72146b6 Author: MS <ms@taler.net> Date: Sun, 16 Apr 2023 09:19:46 +0200 Conversion service. Implementing the cash-out monitor. The monitor watches one particular bank account (the admin's by default) and submits a fiat payment initiation to Nexus upon every new incoming transaction. Also implementing idempotence for payment initiations at Nexus. This helps in case the cash-out monitor fails at keeping track of the submitted payments and accidentally submits multiple times the same payment. Diffstat:
17 files changed, 423 insertions(+), 42 deletions(-)
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml @@ -3,4 +3,7 @@ <component name="Kotlin2JvmCompilerArguments"> <option name="jvmTarget" value="1.8" /> </component> + <component name="KotlinJpsPluginSettings"> + <option name="version" value="1.7.22" /> + </component> </project> \ No newline at end of file diff --git a/.idea/libeufin.iml b/.idea/libeufin.iml @@ -1,13 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<module external.linked.project.id="libeufin" external.linked.project.path="$MODULE_DIR$" external.root.project.path="$MODULE_DIR$" external.system.id="GRADLE" external.system.module.group="" external.system.module.version="0.0.1-dev.3" type="JAVA_MODULE" version="4"> - <component name="NewModuleRootManager" LANGUAGE_LEVEL="JDK_16" inherit-compiler-output="true"> - <exclude-output /> - <content url="file://$MODULE_DIR$"> - <excludeFolder url="file://$MODULE_DIR$/.gradle" /> - <excludeFolder url="file://$MODULE_DIR$/build" /> - <excludeFolder url="file://$MODULE_DIR$/frontend" /> - </content> - <orderEntry type="inheritedJdk" /> - <orderEntry type="sourceFolder" forTests="false" /> - </component> -</module> -\ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml @@ -1,8 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<project version="4"> - <component name="ProjectModuleManager"> - <modules> - <module fileurl="file://$PROJECT_DIR$/.idea/libeufin.iml" filepath="$PROJECT_DIR$/.idea/libeufin.iml" /> - </modules> - </component> -</project> -\ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml @@ -3,6 +3,7 @@ <component name="VcsDirectoryMappings"> <mapping directory="" vcs="Git" /> <mapping directory="$PROJECT_DIR$/build-system/taler-build-scripts" vcs="Git" /> + <mapping directory="$PROJECT_DIR$/contrib/wallet-core" vcs="Git" /> <mapping directory="$PROJECT_DIR$/parsing-tests/samples" vcs="Git" /> </component> </project> \ No newline at end of file diff --git a/nexus/build.gradle b/nexus/build.gradle @@ -96,6 +96,7 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter:5.7.1' testImplementation 'org.jetbrains.kotlin:kotlin-test:1.5.21' testImplementation 'org.jetbrains.kotlin:kotlin-test-junit:1.5.21' + testImplementation 'io.ktor:ktor-client-mock:2.2.4' testImplementation project(":sandbox") } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/bankaccount/BankAccount.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/bankaccount/BankAccount.kt @@ -331,7 +331,10 @@ fun addPaymentInitiation(paymentData: Pain001Data, debtorAccount: String): Payme * it will be the account whose money will pay the wire transfer being defined * by this pain document. */ -fun addPaymentInitiation(paymentData: Pain001Data, debtorAccount: NexusBankAccountEntity): PaymentInitiationEntity { +fun addPaymentInitiation( + paymentData: Pain001Data, + debtorAccount: NexusBankAccountEntity +): PaymentInitiationEntity { return transaction { val now = Instant.now().toEpochMilli() val nowHex = now.toString(16) @@ -349,7 +352,7 @@ fun addPaymentInitiation(paymentData: Pain001Data, debtorAccount: NexusBankAccou preparationDate = now endToEndId = "leuf-e-$nowHex-$painHex-$acctHex" messageId = "leuf-mp1-$nowHex-$painHex-$acctHex" - paymentInformationId = "leuf-p-$nowHex-$painHex-$acctHex" + paymentInformationId = paymentData.pmtInfId ?: "leuf-p-$nowHex-$painHex-$acctHex" instructionId = "leuf-i-$nowHex-$painHex-$acctHex" } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt @@ -289,7 +289,9 @@ data class CreatePaymentInitiationRequest( val bic: String, val name: String, val amount: String, - val subject: String + val subject: String, + // When it's null, the client doesn't expect/need idempotence. + val uid: String? = null ) /** Response type of "POST /prepared-payments" */ @@ -390,7 +392,8 @@ data class Pain001Data( val creditorName: String, val sum: String, val currency: String, - val subject: String + val subject: String, + val pmtInfId: String? = null ) data class AccountTask( diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt @@ -690,11 +690,41 @@ val nexusApp: Application.() -> Unit = { if (!validateBic(body.bic)) { throw NexusError(HttpStatusCode.BadRequest, "invalid BIC (${body.bic})") } - val res = transaction { - val bankAccount = NexusBankAccountEntity.findByName(accountId) - if (bankAccount == null) { - throw unknownBankAccount(accountId) + // Handle first idempotence. + if (body.uid != null) { + val maybeExists: PaymentInitiationEntity? = transaction { + PaymentInitiationEntity.find { + PaymentInitiationsTable.paymentInformationId eq body.uid + }.firstOrNull() } + // If submitted payment looks exactly the same as the one + // found in the database, then respond 200 OK. Otherwise, + // it's 409 Conflict. + if (maybeExists != null && + maybeExists.creditorIban == body.iban && + maybeExists.creditorName == body.name && + maybeExists.subject == body.subject && + maybeExists.creditorBic == body.bic && + "${maybeExists.currency}:${maybeExists.sum}" == body.amount + ) { + call.respond( + HttpStatusCode.OK, + PaymentInitiationResponse(uuid = maybeExists.id.value.toString()) + ) + return@post + } + // The payment was found, but it didn't fulfill the previous check, + // conflict. + if (maybeExists != null) + throw conflict( + "Payment initiation with UID '${body.uid}' " + + "was found already, with different details." + ) + // If the flow reaches here, then the payment wasn't found + // => proceed to create one. + } + val res = transaction { + val bankAccount = getBankAccount(accountId) val amount = parseAmount(body.amount) val paymentEntity = addPaymentInitiation( Pain001Data( @@ -703,7 +733,8 @@ val nexusApp: Application.() -> Unit = { creditorName = body.name, sum = amount.amount, currency = amount.currency, - subject = body.subject + subject = body.subject, + pmtInfId = body.uid ), bankAccount ) diff --git a/nexus/src/test/kotlin/ConversionServiceTest.kt b/nexus/src/test/kotlin/ConversionServiceTest.kt @@ -0,0 +1,76 @@ +import io.ktor.client.* +import io.ktor.client.engine.mock.* +import io.ktor.server.testing.* +import kotlinx.coroutines.* +import org.jetbrains.exposed.sql.transactions.transaction +import org.junit.Test +import tech.libeufin.nexus.server.nexusApp +import tech.libeufin.sandbox.* + +class ConversionServiceTest { + /** + * Tests whether the conversion service is able to skip + * submissions that had problems and proceed to new ones. + */ + @Test + fun testWrongSubmissionSkip() { + withTestDatabase { + prepSandboxDb(); prepNexusDb() + val engine400 = MockEngine { respondBadRequest() } + val mockedClient = HttpClient(engine400) + runBlocking { + val monitorJob = async(Dispatchers.IO) { cashoutMonitor(mockedClient) } + launch { + wireTransfer( + debitAccount = "foo", + creditAccount = "admin", + subject = "fiat", + amount = "TESTKUDOS:3" + ) + // Give enough time to let a flawed monitor submit the request twice. + delay(6000) + transaction { + // The request was submitted only once. + assert(CashoutSubmissionEntity.all().count() == 1L) + // The monitor marked it as failed. + assert(CashoutSubmissionEntity.all().first().hasErrors) + // The submission pointer got advanced by one. + assert(getBankAccountFromLabel("admin").lastFiatSubmission?.id?.value == 1L) + } + monitorJob.cancel() + } + } + } + } + + /** + * Checks that the cash-out monitor reacts after + * a CRDT transaction arrives at the designated account. + */ + @Test + fun cashoutTest() { + withTestDatabase { + prepSandboxDb(); prepNexusDb() + wireTransfer( + debitAccount = "foo", + creditAccount = "admin", + subject = "fiat", + amount = "TESTKUDOS:3" + ) + testApplication { + application(nexusApp) + runBlocking { + val monitorJob = launch(Dispatchers.IO) { cashoutMonitor(client) } + launch { + delay(4000L) + transaction { + assert(CashoutSubmissionEntity.all().count() == 1L) + assert(CashoutSubmissionEntity.all().first().isSubmitted) + } + monitorJob.cancel() + } + } + } + } + } +} +\ No newline at end of file diff --git a/nexus/src/test/kotlin/MakeEnv.kt b/nexus/src/test/kotlin/MakeEnv.kt @@ -203,7 +203,11 @@ fun prepSandboxDb(usersDebtLimit: Int = 1000) { demobankName = "default", withSignupBonus = false, captchaUrl = "http://example.com/", - suggestedExchangePayto = "payto://iban/${BAR_USER_IBAN}" + suggestedExchangePayto = "payto://iban/${BAR_USER_IBAN}", + nexusBaseUrl = "http://localhost/", + usernameAtNexus = "foo", + passwordAtNexus = "foo", + enableConversionService = true ) insertConfigPairs(config) val demoBank = DemobankConfigEntity.new { name = "default" } diff --git a/nexus/src/test/kotlin/NexusApiTest.kt b/nexus/src/test/kotlin/NexusApiTest.kt @@ -1,14 +1,18 @@ +import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import io.ktor.client.plugins.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.server.testing.* +import io.netty.handler.codec.http.HttpResponseStatus import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.runBlocking +import org.jetbrains.exposed.sql.transactions.transaction import org.junit.Test +import tech.libeufin.nexus.PaymentInitiationEntity import tech.libeufin.nexus.server.nexusApp /** @@ -16,6 +20,7 @@ import tech.libeufin.nexus.server.nexusApp * documented here: https://docs.taler.net/libeufin/api-nexus.html */ class NexusApiTest { + private val jMapper = ObjectMapper() // Testing long-polling on GET /transactions @Test fun getTransactions() { @@ -102,4 +107,59 @@ class NexusApiTest { } } } + /** + * Testing the idempotence of payment submissions. That + * helps Sandbox not to create multiple payment initiations + * in case it fails at keeping track of what it submitted + * already. + */ + @Test + fun paymentInitIdempotence() { + withTestDatabase { + prepNexusDb() + testApplication { + application(nexusApp) + // Check no pay. ini. exist. + transaction { PaymentInitiationEntity.all().count() == 0L } + // Create one. + fun f(futureThis: HttpRequestBuilder, subject: String = "idempotence pay. init. test") { + futureThis.basicAuth("foo", "foo") + futureThis.expectSuccess = true + futureThis.contentType(ContentType.Application.Json) + futureThis.setBody(""" + {"iban": "TESTIBAN", + "bic": "SANDBOXX", + "name": "TEST NAME", + "amount": "TESTKUDOS:3", + "subject": "$subject", + "uid": "salt" + } + """.trimIndent()) + } + val R = client.post("/bank-accounts/foo/payment-initiations") { f(this) } + println(jMapper.readTree(R.bodyAsText()).get("uuid")) + // Submit again + client.post("/bank-accounts/foo/payment-initiations") { f(this) } + // Checking that Nexus serves it. + client.get("/bank-accounts/foo/payment-initiations/1") { + basicAuth("foo", "foo") + expectSuccess = true + } + // Checking that the database has only one, despite the double submission. + transaction { + assert(PaymentInitiationEntity.all().count() == 1L) + } + /** + * Causing a conflict by changing one payment detail + * (the subject in this case) but not the "uid". + */ + val maybeConflict = client.post("/bank-accounts/foo/payment-initiations") { + f(this, "different-subject") + expectSuccess = false + } + assert(maybeConflict.status.value == HttpStatusCode.Conflict.value) + + } + } + } } \ No newline at end of file diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt @@ -1,7 +1,17 @@ package tech.libeufin.sandbox +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.ktor.client.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.transactions.transaction +import tech.libeufin.util.* /** * This file contains the logic for downloading/submitting incoming/outgoing @@ -23,9 +33,18 @@ import kotlinx.coroutines.runBlocking * transactions via its JSON API. */ -// Temporarily hard-coded. According to fiat times, these values could be WAY higher. -val longPollMs = 30000L // 30s long-polling. -val loopNewReqMs = 2000L // 2s for the next request. +/** + * Timeout the HTTP client waits for the server to respond, + * after the request is made. + */ +val waitTimeout = 30000L + +/** + * Time to wait before HTTP requesting again to the server. + * This helps to avoid tight cycles in case the server responds + * quickly or the client doesn't long-poll. + */ +val newIterationTimeout = 2000L /** * Executes the 'block' function every 'loopNewReqMs' milliseconds. @@ -44,7 +63,7 @@ fun downloadLoop(block: () -> Unit) { */ logger.error("Sandbox fiat-incoming monitor excepted: ${e.message}") } - delay(loopNewReqMs) + delay(newIterationTimeout) } } } @@ -64,9 +83,161 @@ fun downloadLoop(block: () -> Unit) { */ // creditAdmin() +// DB query helper. The List return type (instead of SizedIterable) lets +// the caller NOT open a transaction block to access the values -- although +// some operations _on the values_ may be forbidden. +private fun getUnsubmittedTransactions(bankAccountLabel: String): List<BankAccountTransactionEntity> { + return transaction { + val bankAccount = getBankAccountFromLabel(bankAccountLabel) + val lowerExclusiveLimit = bankAccount.lastFiatSubmission?.id?.value ?: 0 + BankAccountTransactionEntity.find { + BankAccountTransactionsTable.id greater lowerExclusiveLimit and ( + BankAccountTransactionsTable.direction eq "CRDT" + ) + }.sortedBy { it.id }.map { it } + // The latest payment must occupy the highest index, + // to reliably update the bank account row with the last + // submitted cash-out. + } +} + /** - * This function listens for regio-incoming events (LIBEUFIN_REGIO_INCOMING) + * This function listens for regio-incoming events (LIBEUFIN_REGIO_TX) * and submits the related cash-out payment to Nexus. The fiat payment will * then take place ENTIRELY on Nexus' responsibility. */ -// issueCashout() +suspend fun cashoutMonitor( + httpClient: HttpClient, + bankAccountLabel: String = "admin", + demobankName: String = "default" // used to get config values. +) { + // Register for a REGIO_TX event. + val eventChannel = buildChannelName( + NotificationsChannelDomains.LIBEUFIN_REGIO_TX, + bankAccountLabel + ) + val objectMapper = jacksonObjectMapper() + val demobank = getDemobank(demobankName) + val bankAccount = getBankAccountFromLabel(bankAccountLabel) + val config = demobank?.config ?: throw internalServerError( + "Demobank '$demobankName' has no configuration." + ) + val nexusBaseUrl = getConfigValueOrThrow(config::nexusBaseUrl) + val usernameAtNexus = getConfigValueOrThrow(config::usernameAtNexus) + val passwordAtNexus = getConfigValueOrThrow(config::passwordAtNexus) + val paymentInitEndpoint = nexusBaseUrl.run { + var ret = this + if (!ret.endsWith('/')) + ret += '/' + /** + * WARNING: Nexus gives the possibility to have bank account names + * DIFFERENT from their owner's username. Sandbox however MUST have + * its Nexus bank account named THE SAME as its username (until the + * config will allow to change). + */ + ret + "bank-accounts/$usernameAtNexus/payment-initiations" + } + while (true) { + // delaying here avoids to delay in multiple places (errors, + // lack of action, success) + delay(2000) + val listenHandle = PostgresListenHandle(eventChannel) + // pessimistically LISTEN + listenHandle.postgresListen() + // but optimistically check for data, case some + // arrived _before_ the LISTEN. + var newTxs = getUnsubmittedTransactions(bankAccountLabel) + // Data found, UNLISTEN. + if (newTxs.isNotEmpty()) + listenHandle.postgresUnlisten() + // Data not found, wait. + else { + // OK to block, because the next event is going to + // be _this_ one. The caller should however execute + // this whole logic in a thread other than the main + // HTTP server. + val isNotificationArrived = listenHandle.postgresGetNotifications(waitTimeout) + if (isNotificationArrived && listenHandle.receivedPayload == "CRDT") + newTxs = getUnsubmittedTransactions(bankAccountLabel) + } + if (newTxs.isEmpty()) + continue + newTxs.forEach { + val body = object { + /** + * This field is UID of the request _as assigned by the + * client_. That helps to reconcile transactions or lets + * Nexus implement idempotency. It will NOT identify the created + * resource at the server side. The ID of the created resource is + * assigned _by Nexus_ and communicated in the (successful) response. + */ + val uid = it.accountServicerReference + val iban = it.creditorIban + val bic = it.debtorBic + val amount = "${it.currency}:${it.amount}" + val subject = it.subject + val name = it.creditorName + } + val resp = try { + httpClient.post(paymentInitEndpoint) { + expectSuccess = false // Avoid excepting on !2xx + basicAuth(usernameAtNexus, passwordAtNexus) + contentType(ContentType.Application.Json) + setBody(objectMapper.writeValueAsString(body)) + } + } + // Hard-error, response did not even arrive. + catch (e: Exception) { + logger.error(e.message) + // mark as failed and proceed to the next one. + transaction { + CashoutSubmissionEntity.new { + this.localTransaction = it.id + this.hasErrors = true + } + bankAccount.lastFiatSubmission = it + } + return@forEach + } + // Handle the non 2xx error case. Here we try + // to store the response from Nexus. + if (resp.status.value != HttpStatusCode.OK.value) { + val maybeResponseBody = resp.bodyAsText() + logger.error( + "Fiat submission response was: $maybeResponseBody," + + " status: ${resp.status.value}" + ) + transaction { + CashoutSubmissionEntity.new { + localTransaction = it.id + this.hasErrors = true + if (maybeResponseBody.length > 0) + this.maybeNexusResposnse = maybeResponseBody + } + bankAccount.lastFiatSubmission = it + } + return@forEach + } + // Successful case, mark the wire transfer as submitted, + // and advance the pointer to the last submitted payment. + val responseBody = resp.bodyAsText() + transaction { + CashoutSubmissionEntity.new { + localTransaction = it.id + hasErrors = false + submissionTime = resp.responseTime.timestamp + isSubmitted = true + // Expectedly is > 0 and contains the submission + // unique identifier _as assigned by Nexus_. Not + // currently used by Sandbox, but may help to resolve + // disputes. + if (responseBody.length > 0) + maybeNexusResposnse = responseBody + } + // Advancing the 'last submitted bookmark', to avoid + // handling the same transaction multiple times. + bankAccount.lastFiatSubmission = it + } + } + } +} diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt @@ -508,6 +508,13 @@ object BankAccountsTable : IntIdTable() { * history results that start from / depend on the last transaction. */ val lastTransaction = reference("lastTransaction", BankAccountTransactionsTable).nullable() + + /** + * Points to the transaction that was last submitted by the conversion + * service to Nexus, in order to initiate a fiat payment related to a + * cash-out operation. + */ + val lastFiatSubmission = reference("lastFiatSubmission", BankAccountTransactionsTable).nullable() } class BankAccountEntity(id: EntityID<Int>) : IntEntity(id) { @@ -520,6 +527,7 @@ class BankAccountEntity(id: EntityID<Int>) : IntEntity(id) { var isPublic by BankAccountsTable.isPublic var demoBank by DemobankConfigEntity referencedOn BankAccountsTable.demoBank var lastTransaction by BankAccountTransactionEntity optionalReferencedOn BankAccountsTable.lastTransaction + var lastFiatSubmission by BankAccountTransactionEntity optionalReferencedOn BankAccountsTable.lastFiatSubmission } object BankAccountStatementsTable : IntIdTable() { @@ -620,10 +628,36 @@ object BankAccountReportsTable : IntIdTable() { val bankAccount = reference("bankAccount", BankAccountsTable) } +/** + * This table tracks the submissions of fiat payment instructions + * that Sandbox sends to Nexus. Every fiat payment instruction is + * related to a confirmed cash-out operation. The cash-out confirmation + * is effective once the customer sends a local wire transfer to the + * "admin" bank account. Such wire transfer is tracked by the 'localTransaction' + * column. + */ +object CashoutSubmissionsTable: LongIdTable() { + val localTransaction = reference("localTransaction", BankAccountTransactionsTable).uniqueIndex() + val isSubmitted = bool("isSubmitted").default(false) + val hasErrors = bool("hasErrors") + val maybeNexusResponse = text("maybeNexusResponse").nullable() + val submissionTime = long("submissionTime").nullable() // failed don't have it. +} + +class CashoutSubmissionEntity(id: EntityID<Long>) : LongEntity(id) { + companion object : LongEntityClass<CashoutSubmissionEntity>(CashoutSubmissionsTable) + var localTransaction by CashoutSubmissionsTable.localTransaction + var isSubmitted by CashoutSubmissionsTable.isSubmitted + var hasErrors by CashoutSubmissionsTable.hasErrors + var maybeNexusResposnse by CashoutSubmissionsTable.maybeNexusResponse + var submissionTime by CashoutSubmissionsTable.submissionTime +} + fun dbDropTables(dbConnectionString: String) { Database.connect(dbConnectionString) transaction { SchemaUtils.drop( + CashoutSubmissionsTable, EbicsSubscribersTable, EbicsHostsTable, EbicsDownloadTransactionsTable, @@ -649,6 +683,7 @@ fun dbCreateTables(dbConnectionString: String) { TransactionManager.manager.defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE transaction { SchemaUtils.create( + CashoutSubmissionsTable, DemobankConfigsTable, DemobankConfigPairsTable, EbicsSubscribersTable, diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt @@ -31,6 +31,7 @@ import tech.libeufin.util.* import java.security.interfaces.RSAPublicKey import java.util.* import java.util.zip.DeflaterInputStream +import kotlin.reflect.KProperty data class DemobankConfig( val allowRegistrations: Boolean, @@ -43,9 +44,17 @@ data class DemobankConfig( val smsTan: String? = null, // fixme: move the config subcommand val emailTan: String? = null, // fixme: same as above. val suggestedExchangeBaseUrl: String? = null, - val suggestedExchangePayto: String? = null + val suggestedExchangePayto: String? = null, + val nexusBaseUrl: String? = null, + val usernameAtNexus: String? = null, + val passwordAtNexus: String? = null, + val enableConversionService: Boolean = false ) +fun <T>getConfigValueOrThrow(configKey: KProperty<T?>): T { + return configKey.getter.call() ?: throw nullConfigValueError(configKey.name) +} + /** * Helps to communicate Camt values without having * to parse the XML each time one is needed. diff --git a/sandbox/src/test/kotlin/DBTest.kt b/sandbox/src/test/kotlin/DBTest.kt @@ -24,6 +24,7 @@ import tech.libeufin.sandbox.* import tech.libeufin.util.millis import java.io.File import java.time.LocalDateTime +import kotlin.reflect.KProperty /** * Run a block after connecting to the test database. diff --git a/util/src/main/kotlin/DB.kt b/util/src/main/kotlin/DB.kt @@ -157,7 +157,7 @@ class PostgresListenHandle(val channelName: String) { "'$channelName' for $timeoutMs millis.") val maybeNotifications = this.conn.getNotifications(timeoutMs.toInt()) if (maybeNotifications == null || maybeNotifications.isEmpty()) { - logger.debug("DB notification channel $channelName was found empty.") + logger.debug("DB notifications not found on channel $channelName.") this.likelyCloseConnection() return false } diff --git a/util/src/main/kotlin/HTTP.kt b/util/src/main/kotlin/HTTP.kt @@ -9,7 +9,6 @@ import io.ktor.server.util.* import io.ktor.util.* import logger import java.net.URLDecoder -import kotlin.reflect.typeOf fun unauthorized(msg: String): UtilError { return UtilError( @@ -62,6 +61,12 @@ fun forbidden(msg: String): UtilError { ) } +fun nullConfigValueError( + configKey: String, + demobankName: String = "default" +): Throwable { + return internalServerError("Configuration value for '$configKey' at demobank '$demobankName' is null.") +} fun internalServerError( reason: String, libeufinErrorCode: LibeufinErrorCode? = LibeufinErrorCode.LIBEUFIN_EC_NONE