libeufin

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

commit 5ba63cca00d8b907a04ca8df0a1bb1799ced497a
parent 92f6950d87b04eef772bf87279a09cee9ebab08c
Author: Antoine A <>
Date:   Tue, 18 Jun 2024 19:35:14 +0200

common: rewrite taler config logic
- rewrite parser and value conversion
- new section API with less hardcoded constant
- better error messages
- more tests
- bug fixes

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Config.kt | 145+++++++++++++++++++++++++++----------------------------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 3+--
Mcommon/src/main/kotlin/Cli.kt | 8++++----
Mcommon/src/main/kotlin/Config.kt | 10+++++-----
Mcommon/src/main/kotlin/TalerConfig.kt | 658+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mcommon/src/main/kotlin/helpers.kt | 17++++++++++++++---
Mcommon/src/test/kotlin/ConfigTest.kt | 232++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Dcommon/src/test/kotlin/TalerConfigTest.kt | 64----------------------------------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/Config.kt | 74+++++++++++++++++++++++++++++++++++---------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt | 5++---
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt | 5+++--
Mnexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 18++++++++++--------
Mnexus/src/test/kotlin/CliTest.kt | 5+++--
Mnexus/src/test/kotlin/Iso20022Test.kt | 32+++++++++++++++-----------------
Mtestbench/src/main/kotlin/Main.kt | 9+++++----
Mtestbench/src/test/kotlin/IntegrationTest.kt | 2+-
Mtestbench/src/test/kotlin/Iso20022Test.kt | 5+++--
17 files changed, 696 insertions(+), 596 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt @@ -72,80 +72,76 @@ data class ConversionRate ( fun talerConfig(configPath: Path?): TalerConfig = BANK_CONFIG_SOURCE.fromFile(configPath) fun TalerConfig.loadDbConfig(): DatabaseConfig { + val section = section("libeufin-bankdb-postgres") return DatabaseConfig( - dbConnStr = requireString("libeufin-bankdb-postgres", "config"), - sqlDir = requirePath("libeufin-bankdb-postgres", "sql_dir") + dbConnStr = section.string("config").require(), + sqlDir = section.path("sql_dir").require() ) } -fun TalerConfig.loadBankConfig(): BankConfig { - val regionalCurrency = requireString("libeufin-bank", "currency") +fun TalerConfig.loadBankConfig(): BankConfig = section("libeufin-bank").run { + val regionalCurrency = string("currency").require() var fiatCurrency: String? = null var fiatCurrencySpec: CurrencySpecification? = null - val allowConversion = lookupBoolean("libeufin-bank", "allow_conversion") ?: false + val allowConversion = boolean("allow_conversion").default(false) if (allowConversion) { - fiatCurrency = requireString("libeufin-bank", "fiat_currency") + fiatCurrency = string("fiat_currency").require() fiatCurrencySpec = currencySpecificationFor(fiatCurrency) } val tanChannels = buildMap { for (channel in TanChannel.entries) { - lookupPath("libeufin-bank", "tan_$channel")?.let { - put(channel, Pair(it, jsonMap("libeufin-bank", "tan_${channel}_env") ?: mapOf())) + path("tan_$channel").orNull()?.let { + put(channel, Pair(it, jsonMap("tan_${channel}_env").orNull() ?: mapOf())) } } } - val method = when (val type = lookupString("libeufin-bank", "wire_type")) { - "iban" -> WireMethod.IBAN - "x-taler-bank" -> WireMethod.X_TALER_BANK - null -> { - val err = TalerConfigError.missing("payment target type", "libeufin-bank", "wire_type").message - logger.warn("$err, defaulting to 'iban' but will fail in a future update") - WireMethod.IBAN - } - else -> throw TalerConfigError.invalid("payment target type", "libeufin-bank", "wire_type", "expected 'iban' or 'x-taler-bank' got '$type'") - } - val payto = BankPaytoCtx( - bic = lookupString("libeufin-bank", "iban_payto_bic"), - hostname = lookupString("libeufin-bank", "x_taler_bank_payto_hostname") - ) - when (method) { - WireMethod.IBAN -> if (payto.bic == null) { - logger.warn(TalerConfigError.missing("BIC", "libeufin-bank", "iban_payto_bic").message + " will fail in a future update") - } - WireMethod.X_TALER_BANK -> if (payto.hostname == null) { - logger.warn(TalerConfigError.missing("hostname", "libeufin-bank", "x_taler_bank_payto_hostname").message + " will fail in a future update") - } + + val method = map("wire_type", "payment target type", mapOf( + "iban" to WireMethod.IBAN, + "x-taler-bank" to WireMethod.X_TALER_BANK, + )).default(WireMethod.IBAN, logger, " defaulting to 'iban' but will fail in a future update") + val payto = when (method) { + WireMethod.IBAN -> BankPaytoCtx( + bic = string("iban_payto_bic").orNull(logger, " will fail in a future update"), + hostname = string("x_taler_bank_payto_hostname").orNull() + ) + WireMethod.X_TALER_BANK -> BankPaytoCtx( + bic = string("iban_payto_bic").orNull(), + hostname = string("x_taler_bank_payto_hostname").orNull(logger, " will fail in a future update") + ) } - return BankConfig( - name = lookupString("libeufin-bank", "name") ?: "Taler Bank", + + BankConfig( + name = string("name").default("Taler Bank"), regionalCurrency = regionalCurrency, regionalCurrencySpec = currencySpecificationFor(regionalCurrency), - allowRegistration = lookupBoolean("libeufin-bank", "allow_registration") ?: false, - allowAccountDeletion = lookupBoolean("libeufin-bank", "allow_account_deletion") ?: false, - allowEditName = lookupBoolean("libeufin-bank", "allow_edit_name") ?: false, - allowEditCashout = lookupBoolean("libeufin-bank", "allow_edit_cashout_payto_uri") ?: false, + allowRegistration = boolean("allow_registration").default(false), + allowAccountDeletion = boolean("allow_account_deletion").default(false), + allowEditName = boolean("allow_edit_name").default(false), + allowEditCashout = boolean("allow_edit_cashout_payto_uri").default(false), allowConversion = allowConversion, - defaultDebtLimit = amount("libeufin-bank", "default_debt_limit", regionalCurrency) ?: TalerAmount(0, 0, regionalCurrency), - registrationBonus = amount("libeufin-bank", "registration_bonus", regionalCurrency) ?: TalerAmount(0, 0, regionalCurrency), - wireTransferFees = amount("libeufin-bank", "wire_transfer_fees", regionalCurrency) ?: TalerAmount(0, 0, regionalCurrency), - suggestedWithdrawalExchange = lookupString("libeufin-bank", "suggested_withdrawal_exchange"), - spaPath = lookupPath("libeufin-bank", "spa"), - baseUrl = lookupString("libeufin-bank", "base_url"), + defaultDebtLimit = amount("default_debt_limit", regionalCurrency).default(TalerAmount.zero(regionalCurrency)), + registrationBonus = amount("registration_bonus", regionalCurrency).default(TalerAmount.zero(regionalCurrency)), + wireTransferFees = amount("wire_transfer_fees", regionalCurrency).default(TalerAmount.zero(regionalCurrency)), + suggestedWithdrawalExchange = string("suggested_withdrawal_exchange").orNull(), + spaPath = path("spa").orNull(), + baseUrl = string("base_url").orNull(), fiatCurrency = fiatCurrency, fiatCurrencySpec = fiatCurrencySpec, tanChannels = tanChannels, payto = payto, wireMethod = method, - gcAbortAfter = requireDuration("libeufin-bank", "gc_abort_after"), - gcCleanAfter = requireDuration("libeufin-bank", "gc_clean_after"), - gcDeleteAfter = requireDuration("libeufin-bank", "gc_delete_after"), + gcAbortAfter = duration("gc_abort_after").require(), + gcCleanAfter = duration("gc_clean_after").require(), + gcDeleteAfter = duration("gc_delete_after").require(), ) } fun TalerConfig.currencySpecificationFor(currency: String): CurrencySpecification = sections.find { - it.startsWith("CURRENCY-") && requireBoolean(it, "enabled") && requireString(it, "code") == currency - }?.let { loadCurrencySpecification(it) } ?: run { + val section = section(it) + it.startsWith("CURRENCY-") && section.boolean("enabled").require() && section.string("code").require() == currency + }?.let { section(it).loadCurrencySpecification() } ?: run { logger.warn("Missing currency specification for $currency, using sane defaults") CurrencySpecification( name = currency, @@ -156,53 +152,12 @@ fun TalerConfig.currencySpecificationFor(currency: String): CurrencySpecificatio ) } -private fun TalerConfig.loadCurrencySpecification(section: String): CurrencySpecification { +private fun TalerConfigSection.loadCurrencySpecification(): CurrencySpecification { return CurrencySpecification( - name = requireString(section, "name"), - num_fractional_input_digits = requireNumber(section, "fractional_input_digits"), - num_fractional_normal_digits = requireNumber(section, "fractional_normal_digits"), - num_fractional_trailing_zero_digits = requireNumber(section, "fractional_trailing_zero_digits"), - alt_unit_names = requireJsonMap(section, "alt_unit_names") + name = string("name").require(), + num_fractional_input_digits = number("fractional_input_digits").require(), + num_fractional_normal_digits = number("fractional_normal_digits").require(), + num_fractional_trailing_zero_digits = number("fractional_trailing_zero_digits").require(), + alt_unit_names = jsonMap("alt_unit_names").require() ) -} - -private fun TalerConfig.jsonMap(section: String, option: String): Map<String, String>? { - val raw = lookupString(section, option) ?: return null - try { - return Json.decodeFromString(raw) - } catch (e: Exception) { - throw TalerConfigError.invalid("json key/value map", section, option, "'$raw' is malformed") - } -} - -private fun TalerConfig.requireJsonMap(section: String, option: String): Map<String, String> - = jsonMap(section, option) ?: throw TalerConfigError.missing("json key/value map", section, option) - -private fun TalerConfig.amount(section: String, option: String, currency: String): TalerAmount? { - val raw = lookupString(section, option) ?: return null - val amount = try { - TalerAmount(raw) - } catch (e: Exception) { - throw TalerConfigError.invalid("amount", section, option, "amount '$raw' is malformed") - } - - if (amount.currency != currency) { - throw TalerConfigError.invalid("amount", section, option, "expected currency $currency got ${amount.currency}") - } - return amount -} - -private fun TalerConfig.requireAmount(section: String, option: String, currency: String): TalerAmount = - amount(section, option, currency) ?: throw TalerConfigError.missing("amount", section, option) - -private fun TalerConfig.decimalNumber(section: String, option: String): DecimalNumber? { - val raw = lookupString(section, option) ?: return null - try { - return DecimalNumber(raw) - } catch (e: Exception) { - throw TalerConfigError.invalid("decimal number", section, option, "number '$raw' is malformed") - } -} - -private fun TalerConfig.requireDecimalNumber(section: String, option: String): DecimalNumber - = decimalNumber(section, option) ?: throw TalerConfigError.missing("decimal number", section, option) -\ No newline at end of file +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -50,8 +50,7 @@ import kotlin.io.path.exists import kotlin.io.path.readText private val logger: Logger = LoggerFactory.getLogger("libeufin-bank") - - + /** * Set up web server handlers for the Taler corebank API. diff --git a/common/src/main/kotlin/Cli.kt b/common/src/main/kotlin/Cli.kt @@ -106,15 +106,15 @@ private class CliConfigGet(private val configSource: ConfigSource) : CliktComman private val section by argument() private val option by argument() - override fun run() = cliCmd(logger, common.log) { val config = configSource.fromFile(common.config) + val section = config.section(section) if (isPath) { - val res = config.lookupPath(section, option) + val res = section.path(option).orNull() ?: throw Exception("option '$option' in section '$section' not found in config") println(res) } else { - val res = config.lookupString(section, option) + val res = section.string(option).orNull() ?: throw Exception("option '$option' in section '$section' not found in config") println(res) } @@ -138,7 +138,7 @@ private class CliConfigDump(private val configSource: ConfigSource) : CliktComma override fun run() = cliCmd(logger, common.log) { val config = configSource.fromFile(common.config) - println("# install path: ${config.getInstallPath()}") + println("# install path: ${configSource.installPath()}") println(config.stringify()) } } diff --git a/common/src/main/kotlin/Config.kt b/common/src/main/kotlin/Config.kt @@ -41,9 +41,9 @@ sealed interface ServerConfig { } fun TalerConfig.loadServerConfig(section: String): ServerConfig { - return when (val method = requireString(section, "serve")) { - "tcp" -> ServerConfig.Tcp(lookupString(section, "address") ?: requireString(section, "bind_to"), requireNumber(section, "port")) - "unix" -> ServerConfig.Unix(requireString(section, "unixpath"), requireNumber(section, "unixpath_mode")) - else -> throw TalerConfigError.invalid("server method", section, "serve", "expected 'tcp' or 'unix' got '$method'") - } + val sect = section(section) + return sect.mapLambda("serve", "server method", mapOf( + "tcp" to { ServerConfig.Tcp(sect.string("address").orNull() ?: sect.string("bind_to").require(), sect.number("port").require()) }, + "unix" to { ServerConfig.Unix(sect.string("unixpath").require(), sect.number("unixpath_mode").require()) } + )).require() } \ No newline at end of file diff --git a/common/src/main/kotlin/TalerConfig.kt b/common/src/main/kotlin/TalerConfig.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023 Taler Systems S.A. + * Copyright (C) 2023-2024 Taler Systems S.A. * * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -31,120 +31,145 @@ import java.time.ZoneId import java.time.format.DateTimeParseException import java.time.temporal.ChronoUnit import kotlin.io.path.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json private val logger: Logger = LoggerFactory.getLogger("libeufin-config") -private data class Section( - val entries: MutableMap<String, String>, -) - -private val reEmptyLine = Regex("^\\s*$") -private val reComment = Regex("^\\s*#.*$") -private val reSection = Regex("^\\s*\\[\\s*([^]]*)\\s*]\\s*$") -private val reParam = Regex("^\\s*([^=]+?)\\s*=\\s*(.*?)\\s*$") -private val reDirective = Regex("^\\s*@([a-zA-Z-_]+)@\\s*(.*?)\\s*$") - +/** Config error when analyzing and using the taler configuration format */ class TalerConfigError private constructor (m: String) : Exception(m) { companion object { - fun missing(type: String, section: String, option: String): TalerConfigError = + /** Error when a specific option value is missing */ + internal fun missing(type: String, section: String, option: String): TalerConfigError = TalerConfigError("Missing $type option '$option' in section '$section'") - fun invalid(type: String, section: String, option: String, err: String): TalerConfigError = + /** Error when a specific option value is invalid */ + internal fun invalid(type: String, section: String, option: String, err: String): TalerConfigError = TalerConfigError("Expected $type option '$option' in section '$section': $err") - fun generic(msg: String): TalerConfigError = + /** Generic error not linked to a specific option value */ + internal fun generic(msg: String): TalerConfigError = TalerConfigError(msg) } } -/** - * Information about how the configuration is loaded. - * - * The entry point for the configuration will be the first file from this list: - * - /etc/$projectName/$componentName.conf - * - /etc/$componentName.conf - */ +/** Configuration error when converting option value */ +private class ValueError(val msg: String): Exception(msg) + +/** Information about how the configuration is loaded */ data class ConfigSource( - /** - * Name of the high-level project. - */ + /** Name of the high-level project */ val projectName: String = "taler", - /** - * Name of the component within the package. - */ + /** Name of the component within the package */ val componentName: String = "taler", /** - * Name of the binary that will be located on $PATH to - * find the installation path of the package. + * Executable name that will be located on $PATH to + * find the installation path of the package */ - val installPathBinary: String = "taler-config", -) - -fun ConfigSource.fromMem(content: String): TalerConfig { - val cfg = TalerConfig(this) - cfg.loadDefaults() - cfg.loadFromMem(content, null) - return cfg -} + val execName: String = "taler-config" +) { + /** Load configuration from string */ + fun fromMem(content: String): TalerConfig { + val loader = ConfigLoader(this) + loader.loadDefaults() + loader.loadFromMem(content, null) + return loader.finalize() + } -fun ConfigSource.fromFile(file: Path?): TalerConfig { - val cfg = TalerConfig(this) - cfg.loadDefaults() - val path = file ?: cfg.findDefaultConfigFilename() - if (path != null) cfg.loadFromFile(path) - return cfg + /** + * Load configuration from [file], if [file] is null load from default configuration file + * + * The entry point for the default configuration will be the first file from this list: + * - $XDG_CONFIG_HOME/$componentName.conf + * - $HOME/.config/$componentName.conf + * - /etc/$componentName.conf + * - /etc/$projectName/$componentName.conf + * */ + fun fromFile(file: Path?): TalerConfig { + /** Search for the default configuration file path */ + fun defaultConfigPath(): Path? { + return sequence { + val xdg = System.getenv("XDG_CONFIG_HOME") + if (xdg != null) yield(Path(xdg, "$componentName.conf")) + + val home = System.getenv("HOME") + if (home != null) yield(Path(home, ".config/$componentName.conf")) + + yield(Path("/etc/$componentName.conf")) + yield(Path("/etc/$projectName/$componentName.conf")) + }.firstOrNull { it.exists() } + } + val path = file ?: defaultConfigPath() + val loader = ConfigLoader(this) + loader.loadDefaults() + if (path != null) loader.loadFromFile(path) + return loader.finalize() + } + + /** Search for the binary installation path in PATH */ + fun installPath(): Path { + val pathEnv = System.getenv("PATH") + for (entry in pathEnv.splitToSequence(':')) { + val path = Path(entry) + if (path.resolve(execName).exists()) { + val parent = path.getParent() + if (parent != null) { + return parent.toRealPath() + } + } + } + return Path("/usr") + } } /** - * Reader and writer for Taler-style configuration files. + * Reader for Taler-style configuration files * * The configuration file format is similar to INI files * and fully described in the taler.conf man page. - * - * @param configSource information about where to load configuration defaults from - */ -class TalerConfig internal constructor( - val configSource: ConfigSource + * + * @param source information about where to load configuration defaults from + **/ +private class ConfigLoader internal constructor( + private val source: ConfigSource ) { - private val sectionMap: MutableMap<String, Section> = mutableMapOf() - - private val componentName = configSource.componentName - private val projectName = configSource.projectName - private val installPathBinary = configSource.installPathBinary - val sections: Set<String> get() = sectionMap.keys + private val sections: MutableMap<String, MutableMap<String, String>> = mutableMapOf() /** * Load configuration defaults from the file system * and populate the PATHS section based on the installation path. */ internal fun loadDefaults() { - val installDir = getInstallPath() - val baseConfigDir = Path(installDir, "share/$projectName/config.d") - setSystemDefault("PATHS", "PREFIX", "$installDir/") - setSystemDefault("PATHS", "BINDIR", "$installDir/bin/") - setSystemDefault("PATHS", "LIBEXECDIR", "$installDir/$projectName/libexec/") - setSystemDefault("PATHS", "DOCDIR", "$installDir/share/doc/$projectName/") - setSystemDefault("PATHS", "ICONDIR", "$installDir/share/icons/") - setSystemDefault("PATHS", "LOCALEDIR", "$installDir/share/locale/") - setSystemDefault("PATHS", "LIBDIR", "$installDir/lib/$projectName/") - setSystemDefault("PATHS", "DATADIR", "$installDir/share/$projectName/") + val installDir = source.installPath() + + val section = sections.getOrPut("PATHS") { mutableMapOf() } + section["PREFIX"] = "$installDir/" + section["BINDIR"] = "$installDir/bin/" + section["LIBEXECDIR"] = "$installDir/${source.projectName}/libexec/" + section["DOCDIR"] = "$installDir/share/doc/${source.projectName}/" + section["ICONDIR"] = "$installDir/share/icons/" + section["LOCALEDIR"] = "$installDir/share/locale/" + section["LIBDIR"] = "$installDir/lib/${source.projectName}/" + section["DATADIR"] = "$installDir/share/${source.projectName}/" + + val baseConfigDir = installDir.resolve("share/${source.projectName}/config.d") for (filePath in baseConfigDir.listDirectoryEntries()) { loadFromFile(filePath) } } private fun loadFromGlob(source: Path, glob: String) { - // FIXME: Check that the Kotlin glob matches the glob from our spec + // TODO: Check that the Kotlin glob matches the glob from our spec for (entry in source.parent.listDirectoryEntries(glob)) { loadFromFile(entry) } } - private fun loadSecret(sectionName: String, secretFilename: Path) { - if (!secretFilename.isReadable()) { - logger.warn("unable to read secrets from $secretFilename") + private fun loadSecret(sectionName: String, path: Path) { + if (!path.isReadable()) { + logger.warn("unable to read secrets from $path") } else { - loadFromFile(secretFilename) + loadFromFile(path) } } @@ -153,46 +178,30 @@ class TalerConfig internal constructor( file.readText() } catch (e: Exception) { when (e) { - is NoSuchFileException -> throw Exception("Could not read config at '$file': no such file") - is AccessDeniedException -> throw Exception("Could not read config at '$file': permission denied") + is NoSuchFileException -> throw TalerConfigError.generic("Could not read config at '$file': no such file") + is AccessDeniedException -> throw TalerConfigError.generic("Could not read config at '$file': permission denied") else -> throw Exception("Could not read config at '$file'", e) } } loadFromMem(content, file) } - internal fun loadFromMem(s: String, source: Path?) { - val lines = s.lines() - var lineNum = 0 - var currentSection: String? = null - for (line in lines) { - lineNum++ - if (reEmptyLine.matches(line)) { - continue - } - if (reComment.matches(line)) { + internal fun loadFromMem(content: String, source: Path?) { + var currentSection: MutableMap<String, String>? = null + for ((lineNum, line) in content.lines().withIndex()) { + if (RE_LINE_OR_COMMENT.matches(line)) { continue } - val directiveMatch = reDirective.matchEntire(line) + val directiveMatch = RE_DIRECTIVE.matchEntire(line) if (directiveMatch != null) { - if (source == null) { - throw TalerConfigError.generic("Directives are only supported when loading from file") - } - val directiveName = directiveMatch.groups[1]!!.value.lowercase() - val directiveArg = directiveMatch.groups[2]!!.value - when (directiveName) { - "inline" -> { - val innerFilename = source.resolveSibling(directiveArg.trim()) - loadFromFile(innerFilename) - } - "inline-matching" -> { - val glob = directiveArg.trim() - loadFromGlob(source, glob) - } + if (source == null) throw TalerConfigError.generic("Directives are only supported when loading from file") + val (directiveName, directiveArg) = directiveMatch.destructured + when (directiveName.lowercase()) { + "inline" -> loadFromFile(source.resolveSibling(directiveArg)) + "inline-matching" -> loadFromGlob(source, directiveArg) "inline-secret" -> { - val arg = directiveArg.trim() - val sp = arg.split(" ") + val sp = directiveArg.split(" ") if (sp.size != 2) { throw TalerConfigError.generic("invalid configuration, @inline-secret@ directive requires exactly two arguments") } @@ -200,92 +209,61 @@ class TalerConfig internal constructor( val secretFilename = source.resolveSibling(sp[1]) loadSecret(sectionName, secretFilename) } - - else -> { - throw TalerConfigError.generic("unsupported directive '$directiveName'") - } + else -> throw TalerConfigError.generic("unsupported directive '$directiveName'") } continue } - val secMatch = reSection.matchEntire(line) + val secMatch = RE_SECTION.matchEntire(line) if (secMatch != null) { - currentSection = secMatch.groups[1]!!.value.uppercase() + val (sectionName) = secMatch.destructured + currentSection = sections.getOrPut(sectionName.uppercase()) { mutableMapOf() } continue } if (currentSection == null) { throw TalerConfigError.generic("section expected") } - val paramMatch = reParam.matchEntire(line) - + val paramMatch = RE_PARAM.matchEntire(line) if (paramMatch != null) { - val optName = paramMatch.groups[1]!!.value.uppercase() - var optVal = paramMatch.groups[2]!!.value - if (optVal.startsWith('"') && optVal.endsWith('"')) { + var (optName, optVal) = paramMatch.destructured + if (optVal.length != 1 && optVal.startsWith('"') && optVal.endsWith('"')) { optVal = optVal.substring(1, optVal.length - 1) } - val section = provideSection(currentSection) - section.entries[optName] = optVal + currentSection[optName.uppercase()] = optVal continue } throw TalerConfigError.generic("expected section header, option assignment or directive in line $lineNum file ${source ?: "<input>"}") } } - private fun provideSection(name: String): Section { - val canonSecName = name.uppercase() - val existingSec = this.sectionMap[canonSecName] - if (existingSec != null) { - return existingSec - } - val newSection = Section(entries = mutableMapOf()) - this.sectionMap[canonSecName] = newSection - return newSection - } - - private fun setSystemDefault(section: String, option: String, value: String) { - // FIXME: The value should be marked as a system default for diagnostics pretty printing - val sec = provideSection(section) - sec.entries[option.uppercase()] = value + internal fun finalize(): TalerConfig { + return TalerConfig(sections) } - fun putValueString(section: String, option: String, value: String) { - val sec = provideSection(section) - sec.entries[option.uppercase()] = value + companion object { + private val RE_LINE_OR_COMMENT = Regex("^\\s*(#.*)?$") + private val RE_SECTION = Regex("^\\s*\\[\\s*(.*)\\s*\\]\\s*$") + private val RE_PARAM = Regex("^\\s*([^=]+?)\\s*=\\s*(.*?)\\s*$") + private val RE_DIRECTIVE = Regex("^\\s*@([a-zA-Z-_]+)@\\s*(.*?)\\s*$") } +} - /** - * Create a string representation of the loaded configuration. - */ - fun stringify(): String { - val outStr = StringBuilder() - this.sectionMap.forEach { (sectionName, section) -> - var headerWritten = false - section.entries.forEach { (optionName, entry) -> - if (!headerWritten) { - outStr.appendLine("[$sectionName]") - headerWritten = true - } - outStr.appendLine("$optionName = $entry") - } - if (headerWritten) { - outStr.appendLine() +/** Taler-style configuration */ +class TalerConfig internal constructor( + private val cfg: Map<String, Map<String, String>> +) { + val sections: Set<String> get() = cfg.keys + + /** Create a string representation of the loaded configuration */ + fun stringify(): String = buildString { + for ((section, options) in cfg) { + appendLine("[$section]") + for ((key, value) in options) { + appendLine("$key = $value") } + appendLine() } - return outStr.toString() - } - - private fun variableLookup(x: String, recursionDepth: Int = 0): Path? { - val pathRes = this.lookupString("PATHS", x) - if (pathRes != null) { - return pathsub(pathRes, recursionDepth + 1) - } - val envVal = System.getenv(x) - if (envVal != null) { - return Path(envVal) - } - return null } /** @@ -295,186 +273,181 @@ class TalerConfig internal constructor( * * This substitution is typically only done for paths. */ - fun pathsub(x: String, recursionDepth: Int = 0): Path { + internal fun pathsub(str: String, recursionDepth: Int = 0): Path { + /** Lookup for variable value from PATHS section in the configuration and environment variables */ + fun lookup(name: String, recursionDepth: Int = 0): Path? { + val pathRes = section("PATHS").string(name).orNull() + if (pathRes != null) { + return pathsub(pathRes, recursionDepth + 1) + } + val envVal = System.getenv(name) + if (envVal != null) { + return Path(envVal) + } + return null + } + if (recursionDepth > 128) { - throw TalerConfigError.generic("recursion limit in path substitution exceeded") + throw ValueError("recursion limit in path substitution exceeded for '$str'") + } else if (!str.contains('$')) { // Fast path without variables + return Path(str) } + + var cursor = 0 val result = StringBuilder() - var l = 0 - while (l < x.length) { - if (x[l] != '$') { - // normal character - result.append(x[l]) - l++ - continue + while (true) { + // Look for next variable + val dollarIndex = str.indexOf("$", cursor) + if (dollarIndex == -1) { + // Reached end of string + result.append(str, cursor, str.length) + break; } - if (l + 1 < x.length && x[l + 1] == '{') { - // ${var} - var depth = 1 - val start = l - var p = start + 2 - var hasDefault = false - var insideNamePath = true - // Find end of the ${...} expression - while (p < x.length) { - if (x[p] == '}') { - insideNamePath = false - depth-- - } else if (x.length > p + 1 && x[p] == '$' && x[p + 1] == '{') { - depth++ - insideNamePath = false - } else if (x.length > p + 1 && insideNamePath && x[p] == ':' && x[p + 1] == '-') { - hasDefault = true - } - p++ - if (depth == 0) { - break - } - } - if (depth == 0) { - val inner = x.substring(start + 2, p - 1) - val varName: String - val varDefault: String? - if (hasDefault) { - val res = inner.split(":-", limit = 2) - varName = res[0] - varDefault = res[1] - } else { - varName = inner - varDefault = null - } - val r = variableLookup(varName, recursionDepth + 1) - if (r != null) { - result.append(r) - l = p - continue - } else if (varDefault != null) { - val resolvedDefault = pathsub(varDefault, recursionDepth + 1) - result.append(resolvedDefault) - l = p - continue - } else { - throw TalerConfigError.generic("malformed variable expression can't resolve variable '$varName'") + + // Append normal characters + result.append(str, cursor, dollarIndex) + cursor = dollarIndex + 1 + + // Check if variable is enclosed + val enclosed = if (str[cursor] == '{') { + // ${var + cursor++ + true + } else false // $var + + // Extract variable name + val startName = cursor + while (cursor < str.length && (str[cursor].isLetterOrDigit() || str[cursor] == '_')) { + cursor++ + } + val name = str.substring(startName, cursor) + + // Extract variable default if enclosed + val default = if (!enclosed) null else { + if (str[cursor] == '}') { + // ${var} + cursor++ + null + } else if (cursor+1<str.length && str[cursor] == ':' && str[cursor+1] == '-') { + // ${var:-default} + cursor += 2 + val startDefault = cursor + var depth = 1 + // Find end of the ${...} expression + while (cursor < str.length && depth != 0) { + if (str[cursor] == '}') { + depth-- + } else if (cursor+1<str.length && str[cursor] == '$' && str[cursor+1] == '{') { + depth++ + } + cursor++ } + if (depth != 0) + throw ValueError("unbalanced variable expression '${str.substring(startDefault)}'") + str.substring(startDefault, cursor - 1) + } else { + throw ValueError("bad substitution '${str.substring(cursor-3)}'") } - throw TalerConfigError.generic("malformed variable expression (unbalanced)") - } else { - // $var - var varEnd = l + 1 - while (varEnd < x.length && (x[varEnd].isLetterOrDigit() || x[varEnd] == '_')) { - varEnd++ - } - val varName = x.substring(l + 1, varEnd) - val res = variableLookup(varName) - if (res != null) { - result.append(res) - } - l = varEnd } + + // Value resolution + val resolved = lookup(name, recursionDepth + 1) + ?: default?.let { pathsub(it, recursionDepth + 1) } + ?: throw ValueError("unbound variable '$name'") + result.append(resolved) } return Path(result.toString()) } - /** - * Determine the filename of the default configuration file. - * - * If no such file can be found, return null. - */ - internal fun findDefaultConfigFilename(): Path? { - val xdg = System.getenv("XDG_CONFIG_HOME") - val home = System.getenv("HOME") - - var filename: Path? = null - if (xdg != null) { - filename = Path(xdg, "$componentName.conf") - } else if (home != null) { - filename = Path(home, ".config/$componentName.conf") - } - if (filename != null && filename.exists()) { - return filename - } - val etc1 = Path("/etc/$componentName.conf") - if (etc1.exists()) { - return etc1 - } - val etc2 = Path("/etc/$projectName/$componentName.conf") - if (etc2.exists()) { - return etc2 - } - return null + /** Access [section] */ + fun section(section: String): TalerConfigSection { + val canonSection = section.uppercase() + return TalerConfigSection(this, cfg[canonSection], section) } +} - /** - * Guess the path that the component was installed to. - */ - fun getInstallPath(): String { - // We use the location of the libeufin-bank - // binary to determine the installation prefix. - // If for some weird reason it's now found, we - // fall back to "/usr" as install prefix. - return getInstallPathFromBinary(installPathBinary) +/** Accessor/Converter for Taler-like configuration options */ +class TalerConfigOption<T> internal constructor( + private val raw: String?, + private val option: String, + private val type: String, + private val section: TalerConfigSection, + private val transform: TalerConfigSection.(String) -> T, +) { + /** Converted value or null if missing */ + fun orNull(): T? { + if (raw == null) return null + try { + return section.transform(raw) + } catch (e: ValueError) { + throw TalerConfigError.invalid(type, section.section, option, e.msg) + } } - - private fun getInstallPathFromBinary(name: String): String { - val pathEnv = System.getenv("PATH") - val paths = pathEnv.split(":") - for (p in paths) { - val possiblePath = Path(p, name) - if (possiblePath.exists()) { - return Path(p, "..").toRealPath().toString() - } + /** Converted value or null if missing, log a warning if missing */ + fun orNull(logger: Logger, warn: String): T? { + val value = orNull() + if (value == null) { + val err = TalerConfigError.missing(type, section.section, option).message + logger.warn("$err$warn") } - return "/usr" + return value } - /* ----- Lookup ----- */ + /** Converted value of default if missing */ + fun default(default: T): T = orNull() ?: default - /** - * Look up a string value from the configuration. - * - * Return null if the value was not found in the configuration. - */ - fun lookupString(section: String, option: String): String? { - val canonSection = section.uppercase() + /** Converted value or default if missing, log a warning if missing */ + fun default(default: T, logger: Logger, warn: String): T = orNull(logger, warn) ?: default + + /** Converted value or throw if missing */ + fun require(): T = orNull() ?: throw TalerConfigError.missing(type, section.section, option) +} + +/** Accessor/Converter for Taler-like configuration sections */ +class TalerConfigSection internal constructor( + private val cfg: TalerConfig, + private val entries: Map<String, String>?, + val section: String +) { + /** Setup an accessor/converted for a [type] at [option] using [transform] */ + private fun <T> option(option: String, type: String, transform: TalerConfigSection.(String) -> T): TalerConfigOption<T> { val canonOption = option.uppercase() - val str = this.sectionMap[canonSection]?.entries?.get(canonOption) - if (str == null || str == "") return null - return str + var raw = entries?.get(canonOption) + if (raw == "") raw = null + return TalerConfigOption(raw, option, type, this, transform) } - fun requireString(section: String, option: String, type: String? = null): String = - lookupString(section, option) ?: throw TalerConfigError.missing(type ?: "string", section, option) + /** Access [option] as String */ + fun string(option: String) = option(option, "string") { it } - fun requireNumber(section: String, option: String): Int { - val raw = lookupString(section, option) ?: throw TalerConfigError.missing("number", section, option) - return raw.toIntOrNull() ?: throw TalerConfigError.invalid("number", section, option, "'$raw' not a valid number") + /** Access [option] as Int */ + fun number(option: String) = option(option, "number") { + it.toIntOrNull() ?: throw ValueError("'$it' not a valid number") } - fun lookupBoolean(section: String, option: String): Boolean? { - val entry = lookupString(section, option) ?: return null - return when (val v = entry.lowercase()) { + /** Access [option] as Boolean */ + fun boolean(option: String) = option(option, "boolean") { + when (it.lowercase()) { "yes" -> true "no" -> false - else -> throw TalerConfigError.invalid("yes/no", section, option, "got '$v'") + else -> throw ValueError("expected 'yes' or 'no' got '$it'") } } - fun requireBoolean(section: String, option: String): Boolean = - lookupBoolean(section, option) ?: throw TalerConfigError.missing("boolean", section, option) - - fun lookupPath(section: String, option: String): Path? { - val entry = lookupString(section, option) ?: return null - return pathsub(entry) + /** Access [option] as Path */ + fun path(option: String) = option(option, "path") { + cfg.pathsub(it) } - fun requirePath(section: String, option: String): Path = - lookupPath(section, option) ?: throw TalerConfigError.missing("path", section, option) - - fun lookupDuration(section: String, option: String): Duration? { - val entry = lookupString(section, option) ?: return null - return TIME_AMOUNT_PATTERN.findAll(entry).map { match -> + /** Access [option] as Duration */ + fun duration(option: String) = option(option, "temporal") { + println("$it $TEMPORAL_PATTERN ${TEMPORAL_PATTERN.matches(it)}") + if (!TEMPORAL_PATTERN.matches(it)) { + throw ValueError("'$it' not a valid temporal") + } + TIME_AMOUNT_PATTERN.findAll(it).map { match -> val (rawAmount, unit) = match.destructured - val amount = rawAmount.toLongOrNull() ?: throw TalerConfigError.invalid("temporal", section, option, "'$rawAmount' not a valid temporal amount") + val amount = rawAmount.toLongOrNull() ?: throw ValueError("'$rawAmount' not a valid temporal amount") val value = when (unit) { "us" -> 1 "ms" -> 1000 @@ -484,30 +457,75 @@ class TalerConfig internal constructor( "d", "day", "days" -> 24 * 60 * 60 * 1000L * 1000L "week", "weeks" -> 7 * 24 * 60 * 60 * 1000L * 1000L "year", "years", "a" -> 31536000000000L - else -> throw TalerConfigError.invalid("temporal", section, option, "'$unit' not a valid temporal unit") + else -> throw ValueError("'$unit' not a valid temporal unit") } Duration.of(amount * value, ChronoUnit.MICROS) }.fold(Duration.ZERO) { a, b -> a.plus(b) } } - fun requireDuration(section: String, option: String): Duration = - lookupDuration(section, option) ?: throw TalerConfigError.missing("temporal", section, option) + /** Access [option] as Instant */ + fun date(option: String) = option(option, "date") { + try { + dateToInstant(it) + } catch (e: DateTimeParseException) { + val indexFmt = if (e.errorIndex != 0) " at index ${e.errorIndex}" else "" + val causeMsg = e.cause?.message + val causeFmt = if (causeMsg != null) ": ${causeMsg}" else "" + throw ValueError("'$it' not a valid date$indexFmt$causeFmt") + } + } - fun lookupDate(section: String, option: String): Instant? { - val raw = lookupString(section, option) ?: return null - val date = try { - LocalDate.parse(raw) - } catch (e: DateTimeParseException ) { - throw TalerConfigError.invalid("date", section, option, "'$raw' not a valid date at index ${e.errorIndex}") + /** Access [option] as Map<String, String> */ + fun jsonMap(option: String) = option(option, "json key/value map") { + try { + Json.decodeFromString<Map<String, String>>(it) + } catch (e: Exception) { + throw ValueError("'$it' is malformed") } - return date.atStartOfDay(ZoneId.of("UTC")).toInstant() } - fun requireDate(section: String, option: String): Instant = - lookupDate(section, option) ?: throw TalerConfigError.missing("date", section, option) + /** Access [option] as TalerAmount */ + fun amount(option: String, currency: String) = option(option, "amount") { + val amount = try { + TalerAmount(it) + } catch (e: CommonError) { + throw ValueError("'$it' is malformed: ${e.message}") + } + if (amount.currency != currency) { + throw ValueError("expected currency $currency got ${amount.currency}") + } + amount + } + /** Access a [type] at [option] using a custom [mapper] */ + fun <T> map(option: String, type: String, mapper: Map<String, T>) = option(option, type) { + mapper[it] ?: throw ValueError("expected ${fmtEntriesChoice(mapper)} got '$it'") + } + + /** Access a [type] at [option] using a custom [mapper] with code execution */ + fun <T> mapLambda(option: String, type: String, mapper: Map<String, () -> T>) = option(option, type) { + mapper[it]?.invoke() ?: throw ValueError("expected ${fmtEntriesChoice(mapper)} got '$it'") + } + companion object { private val TIME_AMOUNT_PATTERN = Regex("([0-9]+) ?([a-z'\"]+)") + private val TEMPORAL_PATTERN = Regex(" *([0-9]+ ?[a-z'\"]+ *)+") + + private fun <T> fmtEntriesChoice(mapper: Map<String, T>): String { + return buildString { + val iter = mapper.keys.iterator() + var next = iter.next() + append("'$next'") + while (iter.hasNext()) { + next = iter.next() + if (iter.hasNext()) { + append(", '$next'") + } else { + append(" or '$next'") + } + } + } + } } } diff --git a/common/src/main/kotlin/helpers.kt b/common/src/main/kotlin/helpers.kt @@ -28,6 +28,10 @@ import java.util.zip.DeflaterInputStream import java.util.zip.InflaterInputStream import java.util.zip.ZipInputStream import kotlin.random.Random +import java.time.LocalDate +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter /* ----- String ----- */ @@ -35,11 +39,18 @@ fun String.decodeBase64(): ByteArray = Base64.getDecoder().decode(this) fun String.decodeUpHex(): ByteArray = HexFormat.of().withUpperCase().parseHex(this) fun String.splitOnce(pat: String): Pair<String, String>? { - val split = split(pat, limit=2) - if (split.size != 2) return null - return Pair(split[0], split[1]) + val split = splitToSequence(pat, limit=2).iterator() + val first = split.next() + if (!split.hasNext()) return null + return Pair(first, split.next()) } +/* ----- Date ----- */ + +/** Converting YYYY-MM-DD to Instant */ +fun dateToInstant(date: String): Instant = + LocalDate.parse(date, DateTimeFormatter.ISO_DATE).atStartOfDay().toInstant(ZoneOffset.UTC) + /* ----- BigInteger -----*/ fun BigInteger.encodeHex(): String = this.toByteArray().encodeHex() diff --git a/common/src/test/kotlin/ConfigTest.kt b/common/src/test/kotlin/ConfigTest.kt @@ -1,17 +1,17 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. - + * Copyright (C) 2023-2024 Taler Systems S.A. + * * 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/> @@ -21,31 +21,214 @@ import org.junit.Test import tech.libeufin.common.* import tech.libeufin.common.db.* import uk.org.webcompere.systemstubs.SystemStubs.* -import java.time.Duration +import kotlin.io.path.Path import kotlin.test.* +import java.time.Duration +import java.time.LocalDate +import java.time.temporal.ChronoUnit class ConfigTest { + fun checkErr(msg: String, block: () -> Unit) { + val exception = assertFailsWith<TalerConfigError>(null, block) + assertEquals(msg, exception.message) + } + @Test - fun timeParsing() { - fun parseTime(raw: String): Duration { - val cfg = TalerConfig(ConfigSource("test", "test", "test")) - cfg.loadFromMem(""" - [test] - time = "$raw" - """, null) - return cfg.requireDuration("test", "time") - } - assertEquals(Duration.ofSeconds(1), parseTime("1s")) - assertEquals(parseTime("1 s"), parseTime("1s")) - assertEquals(Duration.ofMinutes(10), parseTime("10m")) - assertEquals(parseTime("10 m"), parseTime("10m")) - assertEquals(Duration.ofHours(1), parseTime("01h")) - assertEquals( - Duration.ofHours(1).plus(Duration.ofMinutes(10)).plus(Duration.ofSeconds(12)), - parseTime("1h10m12s") + fun error() { + val conf = ConfigSource("libeufin", "libeufin-bank", "libeufin-bank").fromMem( + """ + + [section-a] + + bar = baz + + [section-b] + + first_value = 1 + second_value = "test" + + """.trimIndent() ) - assertEquals(parseTime("1h10m12s"), parseTime("1h10'12\"")) + + // Missing section + checkErr("Missing string option 'value' in section 'unknown'") { + conf.section("unknown").string("value").require() + } + + // Missing value + checkErr("Missing string option 'value' in section 'section-a'") { + conf.section("section-a").string("value").require() + } + } + + fun <T> testConfigValue( + type: String, + lambda: TalerConfigSection.(String) -> TalerConfigOption<T>, + wellformed: List<Pair<List<String>, T>>, + malformed: List<Pair<List<String>, (String) -> String>>, + conf: String = "" + ) { + fun conf(content: String) = ConfigSource("libeufin", "libeufin-bank", "libeufin-bank").fromMem("$conf\n$content") + + // Check missing msg + val conf = conf("") + checkErr("Missing $type option 'value' in section 'section'") { + conf.section("section").lambda("value").require() + } + + // Check wellformed options are properly parsed + for ((raws, expected) in wellformed) { + println(expected) + for (raw in raws) { + println(raw) + val conf = conf("[section]\nvalue=$raw") + assertEquals(expected, conf.section("section").lambda("value").require()) + } + } + + // Check malformed options have proper error message + for ((raws, errorFmt) in malformed) { + for (raw in raws) { + val conf = conf("[section]\nvalue=$raw") + checkErr("Expected $type option 'value' in section 'section': ${errorFmt(raw)}") { + conf.section("section").lambda("value").require() + } + } + } } + fun <T> testConfigValue( + type: String, + lambda: TalerConfigSection.(String) -> TalerConfigOption<T>, + wellformed: List<Pair<List<String>, T>>, + malformed: Pair<List<String>, (String) -> String> + ) = testConfigValue(type, lambda, wellformed, listOf(malformed)) + + @Test + fun string() = testConfigValue( + "string", TalerConfigSection::string, listOf( + listOf("1", "\"1\"") to "1", + listOf("test", "\"test\"") to "test", + listOf("\"") to "\"", + ), listOf() + ) + + @Test + fun number() = testConfigValue( + "number", TalerConfigSection::number, listOf( + listOf("1") to 1, + listOf("42") to 42 + ), listOf("true", "YES") to { "'$it' not a valid number" } + ) + + @Test + fun boolean() = testConfigValue( + "boolean", TalerConfigSection::boolean, listOf( + listOf("yes", "YES", "Yes") to true, + listOf("no", "NO", "No") to false + ), listOf("true", "1") to { "expected 'yes' or 'no' got '$it'" } + ) + + @Test + fun path() = testConfigValue( + "path", TalerConfigSection::path, + listOf( + listOf("path") to Path("path"), + listOf("foo/\$DATADIR/bar", "foo/\${DATADIR}/bar") to Path("foo/mydir/bar"), + listOf("foo/\$DATADIR\$DATADIR/bar") to Path("foo/mydirmydir/bar"), + listOf("foo/pre_\$DATADIR/bar", "foo/pre_\${DATADIR}/bar") to Path("foo/pre_mydir/bar"), + listOf("foo/\${DATADIR}_next/bar", "foo/\${UNKNOWN:-\$DATADIR}_next/bar") to Path("foo/mydir_next/bar"), + listOf("foo/\${UNKNOWN:-default}_next/bar", "foo/\${UNKNOWN:-\${UNKNOWN:-default}}_next/bar") to Path("foo/default_next/bar"), + listOf("foo/\${UNKNOWN:-pre_\${UNKNOWN:-default}_next}_next/bar") to Path("foo/pre_default_next_next/bar"), + ), + listOf( + listOf("foo/\${A/bar") to { "bad substitution '\${A/bar'" }, + listOf("foo/\${A:-pre_\${B}/bar") to { "unbalanced variable expression 'pre_\${B}/bar'" }, + listOf("foo/\${A:-\${B\${C}/bar") to { "unbalanced variable expression '\${B\${C}/bar'" }, + listOf("foo/\$UNKNOWN/bar", "foo/\${UNKNOWN}/bar") to { "unbound variable 'UNKNOWN'" }, + listOf("foo/\$RECURSIVE/bar") to { "recursion limit in path substitution exceeded for '\$RECURSIVE'" } + ), + "[PATHS]\nDATADIR=mydir\nRECURSIVE=\$RECURSIVE" + ) + + @Test + fun duration() = testConfigValue( + "temporal", TalerConfigSection::duration, + listOf( + listOf("1s", "1 s") to Duration.ofSeconds(1), + listOf("10m", "10 m") to Duration.ofMinutes(10), + listOf("1h") to Duration.ofHours(1), + listOf("1h10m12s", "1h 10m 12s", "1 h 10 m 12 s", "1h10'12\"") to + Duration.ofHours(1).plus(Duration.ofMinutes(10)).plus(Duration.ofSeconds(12)), + ), + listOf( + listOf("test", "42") to { "'$it' not a valid temporal" }, + listOf("42t") to { "'t' not a valid temporal unit" }, + listOf("9223372036854775808s") to { "'9223372036854775808' not a valid temporal amount" }, + ) + ) + + @Test + fun date() = testConfigValue( + "date", TalerConfigSection::date, + listOf( + listOf("2024-12-12") to dateToInstant("2024-12-12"), + ), + listOf( + listOf("test", "42") to { "'$it' not a valid date" }, + listOf("2024-12-32") to { "'$it' not a valid date: Invalid value for DayOfMonth (valid values 1 - 28/31): 32" }, + listOf("2024-42-12") to { "'$it' not a valid date: Invalid value for MonthOfYear (valid values 1 - 12): 42" }, + listOf("2024-12-32s") to { "'$it' not a valid date at index 10" }, + ) + ) + + @Test + fun jsonMap() = testConfigValue( + "json key/value map", TalerConfigSection::jsonMap, + listOf( + listOf("{\"a\": \"12\", \"b\": \"test\"}") to mapOf("a" to "12", "b" to "test"), + ), + listOf("test", "12", "{\"a\": 12}", "{\"a\": \"12\",") to { "'$it' is malformed" } + ) + + @Test + fun amount() = testConfigValue( + "amount", { amount(it, "KUDOS") }, + listOf( + listOf("KUDOS:12", "KUDOS:12.0", "KUDOS:012.0") to TalerAmount("KUDOS:12"), + ), + listOf( + listOf("test", "42", "KUDOS:0.3ABC") to { "'$it' is malformed: Invalid amount format" }, + listOf("KUDOS:999999999999999999") to { "'$it' is malformed: Value specified in amount is too large" }, + listOf("EUR:12") to { "expected currency KUDOS got EUR" }, + ) + ) + + @Test + fun map() = testConfigValue( + "map", { map(it, "map", mapOf("one" to 1, "two" to 2, "three" to 3)) }, + listOf( + listOf("one") to 1, + listOf("two") to 2, + listOf("three") to 3, + ), + listOf( + listOf("test", "42") to { "expected 'one', 'two' or 'three' got '$it'" }, + ) + ) + + @Test + fun mapLambda() = testConfigValue( + "lambda", + { + map(it, "lambda", mapOf("ok" to 1, "fail" to { throw Exception("Never executed") })) + }, + listOf( + listOf("ok") to 1, + ), + listOf( + listOf("test", "42") to { "expected 'ok' or 'fail' got '$it'" } + ) + ) @Test fun jdbcParsing() { @@ -61,4 +244,4 @@ class ConfigTest { assertEquals("jdbc:postgresql://localhost/?user=$user&socketFactory=org.newsclub.net.unix.AFUNIXSocketFactory\$FactoryArg&socketFactoryArg=/tmp/.s.PGSQL.1234", jdbcFromPg("postgresql:///")) } } -} -\ No newline at end of file +} diff --git a/common/src/test/kotlin/TalerConfigTest.kt b/common/src/test/kotlin/TalerConfigTest.kt @@ -1,64 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2023 Taler Systems S.A. - * - * 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 tech.libeufin.common.ConfigSource -import tech.libeufin.common.fromFile -import tech.libeufin.common.fromMem -import kotlin.io.path.Path -import kotlin.test.assertEquals - -class TalerConfigTest { - - @Test - fun parsing() { - // We assume that libeufin-bank is installed. We could also try to locate the source tree here. - val conf = ConfigSource("libeufin", "libeufin-bank", "libeufin-bank").fromMem( - """ - - [foo] - - bar = baz - - """.trimIndent() - ) - - println(conf.stringify()) - - assertEquals("baz", conf.lookupString("foo", "bar")) - - println(conf.getInstallPath()) - } - - @Test - fun substitution() { - // We assume that libeufin-bank is installed. We could also try to locate the source tree here. - val conf = ConfigSource("libeufin", "libeufin-bank", "libeufin-bank").fromFile(null) - conf.putValueString("PATHS", "DATADIR", "mydir") - conf.putValueString("foo", "bar", "baz") - conf.putValueString("foo", "bar2", "baz") - - assertEquals("baz", conf.lookupString("foo", "bar")) - assertEquals(Path("baz"), conf.lookupPath("foo", "bar")) - - conf.putValueString("foo", "dir1", "foo/\$DATADIR/bar") - - assertEquals(Path("foo/mydir/bar"), conf.lookupPath("foo", "dir1")) - } -} diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt @@ -25,57 +25,54 @@ import tech.libeufin.nexus.ebics.Dialect val NEXUS_CONFIG_SOURCE = ConfigSource("libeufin", "libeufin-nexus", "libeufin-nexus") - class NexusFetchConfig(config: TalerConfig) { - val frequency = config.requireDuration("nexus-fetch", "frequency") - val ignoreBefore = config.lookupDate("nexus-fetch", "ignore_transactions_before") + val section = config.section("nexus-fetch") + val frequency = section.duration("frequency").require() + val ignoreBefore = section.date("ignore_transactions_before").orNull() } -class ApiConfig(config: TalerConfig, section: String) { - val authMethod = config.requireAuthMethod(section) +class ApiConfig(section: TalerConfigSection) { + val authMethod = section.requireAuthMethod() } /** Configuration for libeufin-nexus */ class NexusConfig(val config: TalerConfig) { - private fun requireString(option: String, type: String? = null): String = config.requireString("nexus-ebics", option, type) - private fun requirePath(option: String): Path = config.requirePath("nexus-ebics", option) + private val sect = config.section("nexus-ebics") /** The bank's currency */ - val currency = requireString("currency") + val currency = sect.string("currency").require() /** The bank base URL */ - val hostBaseUrl = requireString("host_base_url") + val hostBaseUrl = sect.string("host_base_url").require() /** The bank EBICS host ID */ - val ebicsHostId = requireString("host_id") + val ebicsHostId = sect.string("host_id").require() /** EBICS user ID */ - val ebicsUserId = requireString("user_id") + val ebicsUserId = sect.string("user_id").require() /** EBICS partner ID */ - val ebicsPartnerId = requireString("partner_id") + val ebicsPartnerId = sect.string("partner_id").require() /** Bank account metadata */ val account = IbanAccountMetadata( - iban = requireString("iban"), - bic = requireString("bic"), - name = requireString("name") + iban = sect.string("iban").require(), + bic = sect.string("bic").require(), + name = sect.string("name").require() ) /** Bank account payto */ val payto = IbanPayto.build(account.iban, account.bic, account.name) /** Path where we store the bank public keys */ - val bankPublicKeysPath = requirePath("bank_public_keys_file") + val bankPublicKeysPath = sect.path("bank_public_keys_file").require() /** Path where we store our private keys */ - val clientPrivateKeysPath = requirePath("client_private_keys_file") + val clientPrivateKeysPath = sect.path("client_private_keys_file").require() val fetch = NexusFetchConfig(config) - val dialect = when (val type = requireString("bank_dialect", "dialect")) { - "postfinance" -> Dialect.postfinance - "gls" -> Dialect.gls - else -> throw TalerConfigError.invalid("bank dialect", "libeufin-nexus", "bank_dialect", "expected 'postfinance' or 'gls' got '$type'") - } - val accountType = when (val type = requireString("account_type", "account type")) { - "normal" -> AccountType.normal - "exchange" -> AccountType.exchange - else -> throw TalerConfigError.invalid("account type", "libeufin-nexus", "account_type", "expected 'normal' or 'exchange' got '$type'") - } - val wireGatewayApiCfg = config.apiConf("nexus-httpd-wire-gateway-api") - val revenueApiCfg = config.apiConf("nexus-httpd-revenue-api") + val dialect = sect.map("bank_dialect", "dialect", mapOf( + "postfinance" to Dialect.postfinance, + "gls" to Dialect.gls + )).require() + val accountType = sect.map("account_type", "account type", mapOf( + "normal" to AccountType.normal, + "exchange" to AccountType.exchange + )).require() + val wireGatewayApiCfg = config.section("nexus-httpd-wire-gateway-api").apiConf() + val revenueApiCfg = config.section("nexus-httpd-revenue-api").apiConf() } fun NexusConfig.checkCurrency(amount: TalerAmount) { @@ -85,21 +82,20 @@ fun NexusConfig.checkCurrency(amount: TalerAmount) { ) } -fun TalerConfig.requireAuthMethod(section: String): AuthMethod { - return when (val method = requireString(section, "auth_method", "auth method")) { - "none" -> AuthMethod.None - "bearer-token" -> { - val token = requireString(section, "auth_bearer_token") +fun TalerConfigSection.requireAuthMethod(): AuthMethod { + return mapLambda("auth_method", "auth method", mapOf( + "none" to { AuthMethod.None }, + "bearer-token" to { + val token = string("auth_bearer_token").require() AuthMethod.Bearer(token) } - else -> throw TalerConfigError.invalid("auth method target type", section, "auth_method", "expected 'bearer-token' or 'none' got '$method'") - } + )).require() } -fun TalerConfig.apiConf(section: String): ApiConfig? { - val enabled = requireBoolean(section, "enabled") +fun TalerConfigSection.apiConf(): ApiConfig? { + val enabled = boolean("enabled").require() return if (enabled) { - return ApiConfig(this, section) + return ApiConfig(this) } else { null } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt @@ -406,15 +406,14 @@ class EbicsFetch: CliktCommand("Fetches EBICS files") { val pinnedStartVal = pinnedStart val pinnedStartArg = if (pinnedStartVal != null) { logger.debug("Pinning start date to: $pinnedStartVal") - // Converting YYYY-MM-DD to Instant. - LocalDate.parse(pinnedStartVal).atStartOfDay(ZoneId.of("UTC")).toInstant() + dateToInstant(pinnedStartVal) } else null ctx.pinnedStart = pinnedStartArg if (!fetchDocuments(db, ctx, docs)) { throw Exception("Failed to fetch documents") } } else { - val raw = cfg.config.requireString("nexus-fetch", "frequency") + val raw = cfg.config.section("nexus-fetch").string("frequency").require() logger.debug("Running with a frequency of $raw") if (cfg.fetch.frequency == Duration.ZERO) { logger.warn("Long-polling not implemented, running therefore in transient mode") diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt @@ -163,8 +163,9 @@ class EbicsSubmit : CliktCommand("Submits any initiated payment found in the dat logger.info("Transient mode: submitting what found and returning.") Duration.ZERO } else { - val frequency = cfg.config.requireDuration("nexus-submit", "frequency") - val raw = cfg.config.requireString("nexus-submit", "frequency") + val sect = cfg.config.section("nexus-submit") + val frequency = sect.duration("frequency").require() + val raw = sect.string("frequency").require() logger.debug("Running with a frequency of $raw") if (frequency == Duration.ZERO) { logger.warn("Long-polling not implemented, running therefore in transient mode") diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -90,11 +90,14 @@ fun loadConfig(configFile: Path?): TalerConfig = NEXUS_CONFIG_SOURCE.fromFile(co /** * Abstracts fetching the DB config values to set up Nexus. */ -fun TalerConfig.dbConfig(): DatabaseConfig = - DatabaseConfig( - dbConnStr = lookupString("libeufin-nexusdb-postgres", "config") ?: requireString("nexus-postgres", "config"), - sqlDir = requirePath("libeufin-nexusdb-postgres", "sql_dir") +fun TalerConfig.dbConfig(): DatabaseConfig { + val sect = section("libeufin-nexusdb-postgres") + val configOption = sect.string("config") + return DatabaseConfig( + dbConnStr = configOption.orNull() ?: section("nexus-postgres").string("config").orNull() ?: configOption.require(), + sqlDir = sect.path("sql_dir").require() ) +} class InitiatePayment: CliktCommand("Initiate an outgoing payment") { private val common by CommonOption() @@ -117,7 +120,7 @@ class InitiatePayment: CliktCommand("Initiate an outgoing payment") { override fun run() = cliCmd(logger, common.log) { val cfg = loadConfig(common.config) val dbCfg = cfg.dbConfig() - val currency = cfg.requireString("nexus-ebics", "currency") + val currency = cfg.section("nexus-ebics").string("currency").require() val subject = payto.message ?: subject ?: throw Exception("Missing subject") val amount = payto.amount ?: amount ?: throw Exception("Missing amount") @@ -272,8 +275,7 @@ class EbicsDownload: CliktCommand("Perform EBICS requests", name = "ebics-btd") val pinnedStartVal = pinnedStart val pinnedStartArg = if (pinnedStartVal != null) { logger.debug("Pinning start date to: $pinnedStartVal") - // Converting YYYY-MM-DD to Instant. - LocalDate.parse(pinnedStartVal).atStartOfDay(ZoneId.of("UTC")).toInstant() + dateToInstant(pinnedStartVal) } else null val client = HttpClient { install(HttpTimeout) { @@ -320,7 +322,7 @@ class ListCmd: CliktCommand("List nexus transactions", name = "list") { override fun run() = cliCmd(logger, common.log) { val cfg = loadConfig(common.config) val dbCfg = cfg.dbConfig() - val currency = cfg.requireString("nexus-ebics", "currency") + val currency = cfg.section("nexus-ebics").string("currency").require() Database(dbCfg, currency).use { db -> fun fmtPayto(payto: String?): String { diff --git a/nexus/src/test/kotlin/CliTest.kt b/nexus/src/test/kotlin/CliTest.kt @@ -51,8 +51,9 @@ class CliTest { val allCmds = listOf("ebics-submit", "ebics-fetch", "ebics-setup") val conf = "conf/test.conf" val cfg = loadConfig(Path(conf)) - val clientKeysPath = cfg.requirePath("nexus-ebics", "client_private_keys_file") - val bankKeysPath = cfg.requirePath("nexus-ebics", "bank_public_keys_file") + val section = cfg.section("nexus-ebics") + val clientKeysPath = section.path("client_private_keys_file").require() + val bankKeysPath = section.path("bank_public_keys_file").require() clientKeysPath.parent!!.createDirectories() clientKeysPath.parent!!.toFile().setWritable(true) bankKeysPath.parent!!.createDirectories() diff --git a/nexus/src/test/kotlin/Iso20022Test.kt b/nexus/src/test/kotlin/Iso20022Test.kt @@ -19,6 +19,7 @@ import org.junit.Test import tech.libeufin.common.TalerAmount +import tech.libeufin.common.dateToInstant import tech.libeufin.nexus.IncomingPayment import tech.libeufin.nexus.OutgoingPayment import tech.libeufin.nexus.TxNotification.Reversal @@ -32,9 +33,6 @@ import java.time.format.DateTimeFormatter import kotlin.io.path.Path import kotlin.test.assertEquals -private fun instant(date: String): Instant = - LocalDate.parse(date, DateTimeFormatter.ISO_DATE).atStartOfDay().toInstant(ZoneOffset.UTC) - class Iso20022Test { @Test fun postfinance_camt054() { @@ -46,21 +44,21 @@ class Iso20022Test { messageId = "ZS1PGNTSV0ZNDFAJBBWWB8015G", amount = TalerAmount("CHF:3.00"), wireTransferSubject = null, - executionTime = instant("2024-01-15"), + executionTime = dateToInstant("2024-01-15"), creditPaytoUri = null ), IncomingPayment( bankId = "62e2b511-7313-4ccd-8d40-c9d8e612cd71", amount = TalerAmount("CHF:10"), wireTransferSubject = "G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YBG", - executionTime = instant("2023-12-19"), + executionTime = dateToInstant("2023-12-19"), debitPaytoUri = "payto://iban/CH7389144832588726658?receiver-name=Mr%20Test" ), IncomingPayment( bankId = "62e2b511-7313-4ccd-8d40-c9d8e612cd71", amount = TalerAmount("CHF:2.53"), wireTransferSubject = "G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YB", - executionTime = instant("2023-12-19"), + executionTime = dateToInstant("2023-12-19"), debitPaytoUri = "payto://iban/CH7389144832588726658?receiver-name=Mr%20Test" ) ), @@ -77,12 +75,12 @@ class Iso20022Test { Reversal( msgId = "889d1a80-1267-49bd-8fcc-85701a", reason = "InconsistenWithEndCustomer 'Identification of end customer is not consistent with associated account number, organisation ID or private ID.' - 'more info here ...'", - executionTime = instant("2023-11-22") + executionTime = dateToInstant("2023-11-22") ), Reversal( msgId = "4cc61cc7-6230-49c2-b5e2-b40bbb", reason = "MissingCreditorNameOrAddress 'Specification of the creditor’s name and/or address needed for regulatory requirements is insufficient or missing.' - 'more info here ...'", - executionTime = instant("2023-11-22") + executionTime = dateToInstant("2023-11-22") ) ), txs @@ -99,27 +97,27 @@ class Iso20022Test { messageId = "G059N0SR5V0WZ0XSFY1H92QBZ0", amount = TalerAmount("EUR:2"), wireTransferSubject = "TestABC123", - executionTime = instant("2024-04-18"), + executionTime = dateToInstant("2024-04-18"), creditPaytoUri = "payto://iban/DE20500105172419259181?receiver-name=John%20Smith" ), OutgoingPayment( messageId = "YF5QBARGQ0MNY0VK59S477VDG4", amount = TalerAmount("EUR:1.1"), wireTransferSubject = "This should fail because dummy", - executionTime = instant("2024-04-18"), + executionTime = dateToInstant("2024-04-18"), creditPaytoUri = "payto://iban/DE20500105172419259181?receiver-name=John%20Smith" ), IncomingPayment( bankId = "BYLADEM1WOR-G2910276709458A2", amount = TalerAmount("EUR:3"), wireTransferSubject = "Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-", - executionTime = instant("2024-04-12"), + executionTime = dateToInstant("2024-04-12"), debitPaytoUri = "payto://iban/DE84500105177118117964?receiver-name=John%20Smith" ), Reversal( msgId = "G27KNKZAR5DV7HRB085YMA9GB4", reason = "IncorrectAccountNumber 'Format of the account number specified is not correct' - 'IBAN ...'", - executionTime = instant("2024-04-12") + executionTime = dateToInstant("2024-04-12") ) ), txs @@ -136,27 +134,27 @@ class Iso20022Test { messageId = "G059N0SR5V0WZ0XSFY1H92QBZ0", amount = TalerAmount("EUR:2"), wireTransferSubject = "TestABC123", - executionTime = instant("2024-04-18"), + executionTime = dateToInstant("2024-04-18"), creditPaytoUri = "payto://iban/DE20500105172419259181?receiver-name=John%20Smith" ), OutgoingPayment( messageId = "YF5QBARGQ0MNY0VK59S477VDG4", amount = TalerAmount("EUR:1.1"), wireTransferSubject = "This should fail because dummy", - executionTime = instant("2024-04-18"), + executionTime = dateToInstant("2024-04-18"), creditPaytoUri = "payto://iban/DE20500105172419259181?receiver-name=John%20Smith" ), IncomingPayment( bankId = "BYLADEM1WOR-G2910276709458A2", amount = TalerAmount("EUR:3"), wireTransferSubject = "Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-", - executionTime = instant("2024-04-12"), + executionTime = dateToInstant("2024-04-12"), debitPaytoUri = "payto://iban/DE84500105177118117964?receiver-name=John%20Smith" ), Reversal( msgId = "G27KNKZAR5DV7HRB085YMA9GB4", reason = "IncorrectAccountNumber 'Format of the account number specified is not correct' - 'IBAN ...'", - executionTime = instant("2024-04-12") + executionTime = dateToInstant("2024-04-12") ) ), txs @@ -173,7 +171,7 @@ class Iso20022Test { bankId = null, //"IS11PGENODEFF2DA8899900378806", amount = TalerAmount("EUR:2.5"), wireTransferSubject = "Test ICT", - executionTime = instant("2024-05-05"), + executionTime = dateToInstant("2024-05-05"), debitPaytoUri = "payto://iban/DE84500105177118117964?receiver-name=Mr%20Test" ) ), diff --git a/testbench/src/main/kotlin/Main.kt b/testbench/src/main/kotlin/Main.kt @@ -102,9 +102,10 @@ class Cli : CliktCommand("Run integration tests on banks provider") { CONFIG = postgres:///libeufintestbench """) val cfg = loadConfig(conf) + val section = cfg.section("nexus-ebics") // Check if platform is known - val kind = when (cfg.requireString("nexus-ebics", "host_base_url")) { + val kind = when (section.string("host_base_url").require()) { "https://isotest.postfinance.ch/ebicsweb/ebicsweb" -> Kind("PostFinance IsoTest", "https://isotest.postfinance.ch/corporates/user/settings/ebics") "https://iso20022test.credit-suisse.com/ebicsweb/ebicsweb" -> @@ -122,9 +123,9 @@ class Cli : CliktCommand("Run integration tests on banks provider") { val log = "DEBUG" val flags = " -c $conf -L $log" val ebicsFlags = "$flags --transient --debug-ebics test/$platform" - val clientKeysPath = cfg.requirePath("nexus-ebics", "client_private_keys_file") - val bankKeysPath = cfg.requirePath("nexus-ebics", "bank_public_keys_file") - val currency = cfg.requireString("nexus-ebics", "currency") + val clientKeysPath = section.path("client_private_keys_file").require() + val bankKeysPath = section.path("bank_public_keys_file").require() + val currency = section.string("currency").require() val dummyPaytos = mapOf( "CHF" to "payto://iban/CH4189144589712575493?receiver-name=John%20Smith", diff --git a/testbench/src/test/kotlin/IntegrationTest.kt b/testbench/src/test/kotlin/IntegrationTest.kt @@ -71,7 +71,7 @@ fun setup(conf: String, lambda: suspend (NexusDb) -> Unit) { runBlocking { val cfg = loadConfig(Path(conf)) val dbCfg = cfg.dbConfig() - val currency = cfg.requireString("nexus-ebics", "currency") + val currency = cfg.section("nexus-ebics").string("currency").require() NexusDb(dbCfg, currency).use { lambda(it) } diff --git a/testbench/src/test/kotlin/Iso20022Test.kt b/testbench/src/test/kotlin/Iso20022Test.kt @@ -57,8 +57,9 @@ class Iso20022Test { val fetch = file.resolve("fetch") if (!fetch.exists()) continue val cfg = loadConfig(platform.resolve("ebics.conf")) - val currency = cfg.requireString("nexus-ebics", "currency") - val dialect = Dialect.valueOf(cfg.requireString("nexus-ebics", "bank_dialect")) + val section = cfg.section("nexus-ebics") + val currency = section.string("currency").require() + val dialect = Dialect.valueOf(section.string("bank_dialect").require()) for (log in fetch.listDirectoryEntries()) { val content = Files.newInputStream(log) val name = log.toString()