libeufin

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

commit 17012c45d710b4ec2b061262ba60735ac3e8fe99
parent ad56730b3bb511d51f2994e0152937138012560c
Author: Antoine A <>
Date:   Tue, 17 Mar 2026 10:19:16 +0100

common: add OpenAPI with contributions by Maki Bytes

Diffstat:
Mlibeufin-bank/build.gradle | 4++++
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/Config.kt | 12++++++++++++
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/Main.kt | 11++++++++++-
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 284+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt | 239++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 569++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/api/ObservabilityApi.kt | 21+++++++++++++++++++--
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/api/PreparedTransferApi.kt | 44++++++++++++++++++++++++++++++++++++++++----
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt | 40++++++++++++++++++++++++++++++++++++++--
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Alibeufin-bank/src/test/kotlin/OpenApiTest.kt | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlibeufin-common/build.gradle | 6+++++-
Mlibeufin-common/src/main/kotlin/TalerCommon.kt | 6++++++
Mlibeufin-common/src/main/kotlin/TalerMessage.kt | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlibeufin-common/src/main/kotlin/api/route.kt | 15+++++++++------
Mlibeufin-common/src/main/kotlin/api/server.kt | 133++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mlibeufin-nexus/build.gradle | 4++++
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 11++++++++++-
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/ObservabilityApi.kt | 21+++++++++++++++++++--
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/PreparedTransferApi.kt | 35++++++++++++++++++++++++++++++++---
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt | 30++++++++++++++++++++++++++++--
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Alibeufin-nexus/src/test/kotlin/OpenApiTest.kt | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
24 files changed, 2023 insertions(+), 96 deletions(-)

diff --git a/libeufin-bank/build.gradle b/libeufin-bank/build.gradle @@ -35,6 +35,10 @@ dependencies { implementation("io.ktor:ktor-server-core:$ktor_version") implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") + // OpenAPI spec generation + implementation("io.github.smiley4:ktor-openapi:5.6.0") + implementation("io.github.smiley4:schema-kenerator-core:2.6.0") + // UNIX domain sockets support (used to connect to PostgreSQL) implementation("com.kohlschutter.junixsocket:junixsocket-core:$junixsocket_version") diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/Config.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/Config.kt @@ -19,6 +19,7 @@ package tech.libeufin.bank import kotlinx.serialization.Serializable +import io.github.smiley4.schemakenerator.core.annotations.Description import tech.libeufin.bank.db.Database import tech.libeufin.common.* import tech.libeufin.common.crypto.PwCrypto @@ -69,17 +70,28 @@ data class BankConfig( } } +@Description("Currency conversion rate configuration") @Serializable data class ConversionRate ( + @Description("Ratio for converting fiat to regional currency") val cashin_ratio: DecimalNumber, + @Description("Fee charged on cash-in conversions") val cashin_fee: TalerAmount, + @Description("Smallest possible cash-in amount") val cashin_tiny_amount: TalerAmount, + @Description("Rounding mode for cash-in conversions") val cashin_rounding_mode: RoundingMode, + @Description("Minimum amount for cash-in conversions") val cashin_min_amount: TalerAmount, + @Description("Ratio for converting regional currency to fiat") val cashout_ratio: DecimalNumber, + @Description("Fee charged on cash-out conversions") val cashout_fee: TalerAmount, + @Description("Smallest possible cash-out amount") val cashout_tiny_amount: TalerAmount, + @Description("Rounding mode for cash-out conversions") val cashout_rounding_mode: RoundingMode, + @Description("Minimum amount for cash-out conversions") val cashout_min_amount: TalerAmount, ) { fun check(cfg: BankConfig) { diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -28,13 +28,22 @@ import org.slf4j.LoggerFactory import tech.libeufin.bank.api.* import tech.libeufin.bank.cli.LibeufinBank import tech.libeufin.bank.db.Database +import tech.libeufin.common.api.OpenApiInfo import tech.libeufin.common.api.talerApi import com.github.ajalt.clikt.core.main val logger: Logger = LoggerFactory.getLogger("libeufin-bank") /** Set up web server handlers for the Taler corebank API */ -fun Application.corebankWebApp(db: Database, cfg: BankConfig) = talerApi(LoggerFactory.getLogger("libeufin-bank-api")) { +fun Application.corebankWebApp(db: Database, cfg: BankConfig, serveSpec: Boolean = false) = talerApi( + LoggerFactory.getLogger("libeufin-bank-api"), + OpenApiInfo( + title = "LibEuFin Bank API", + version = "1.4.0", + description = "Taler corebank, wire gateway, wire transfer gateway, integration, conversion, revenue, and observability APIs for LibEuFin Bank" + ), + serveSpec = serveSpec +) { coreBankApi(db, cfg) conversionApi(db, cfg) bankIntegrationApi(db, cfg) diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -26,6 +26,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import io.github.smiley4.schemakenerator.core.annotations.Description import tech.libeufin.common.* import java.time.Instant @@ -37,17 +38,20 @@ enum class FracDigits { } // Allowed values for bank transactions directions. +@Description("Direction of a bank transaction") enum class TransactionDirection { credit, debit } +@Description("Status of a cashout operation") enum class CashoutStatus { pending, aborted, confirmed } +@Description("Status of a withdrawal operation") enum class WithdrawalStatus { pending, aborted, @@ -55,12 +59,14 @@ enum class WithdrawalStatus { confirmed } +@Description("Status of a bank account") enum class AccountStatus { active, locked, deleted } +@Description("Rounding mode for currency conversion") enum class RoundingMode { zero, up, @@ -84,6 +90,7 @@ enum class Operation { create_token } +@Description("Wire transfer method type") enum class WireMethod { IBAN, X_TALER_BANK @@ -139,21 +146,31 @@ sealed class Option<out T> { } @Serializable +@Description("Response containing TAN challenges") data class ChallengeResponse( + @Description("List of pending challenges") val challenges: List<Challenge>, + @Description("Whether all challenges must be solved") val combi_and: Boolean ) @Serializable +@Description("Single TAN challenge details") data class Challenge( + @Description("Unique challenge identifier") val challenge_id: String, + @Description("Channel used for TAN delivery") val tan_channel: TanChannel, + @Description("Masked contact information for TAN") val tan_info: String ) @Serializable +@Description("Response after requesting a TAN challenge") data class ChallengeRequestResponse( + @Description("Expiration time for solving the challenge") val solve_expiration: TalerTimestamp, + @Description("Earliest time to request retransmission") val earliest_retransmission: TalerTimestamp ) @@ -164,8 +181,11 @@ data class ChallengeRequestResponse( * when this token expires. */ @Serializable +@Description("Successful token creation or refresh response") data class TokenSuccessResponse( + @Description("Crockford-encoded access token") val access_token: String, + @Description("Token expiration timestamp") val expiration: TalerTimestamp ) @@ -173,8 +193,11 @@ data class TokenSuccessResponse( /* Contains contact data to send TAN challges to the * users, to let them complete cashout operations. */ @Serializable +@Description("Contact data for TAN challenge delivery") data class ChallengeContactData( + @Description("Email address for TAN delivery") val email: Option<String?> = Option.None, + @Description("Phone number for TAN delivery") val phone: Option<String?> = Option.None ) { init { @@ -192,18 +215,31 @@ data class ChallengeContactData( // Type expected at POST /accounts @Serializable +@Description("Request to register a new bank account") data class RegisterAccountRequest( + @Description("Unique account username") val username: String, + @Description("Account password") val password: String, + @Description("Legal name of account holder") val name: String, + @Description("Whether account is publicly visible") val is_public: Boolean = false, + @Description("Whether account is a Taler exchange") val is_taler_exchange: Boolean = false, + @Description("Contact data for TAN challenges") val contact_data: ChallengeContactData? = null, + @Description("Cashout payto URI for fiat withdrawals") val cashout_payto_uri: IbanPayto? = null, + @Description("Payto URI for the account") val payto_uri: Payto? = null, + @Description("Maximum allowed debit balance") val debit_threshold: TalerAmount? = null, + @Description("Preferred TAN channel (deprecated)") val tan_channel: TanChannel? = null, + @Description("Set of enabled TAN channels") val tan_channels: Set<TanChannel>? = null, + @Description("Conversion rate class identifier") val conversion_rate_class_id: Long? = null ) { init { @@ -213,6 +249,7 @@ data class RegisterAccountRequest( throw badRequest("you must only use either tan_channel or tan_channels") } + @Description("Computed set of enabled TAN channels") val channels: Set<TanChannel> get() { if (tan_channels != null) { return tan_channels @@ -229,7 +266,9 @@ data class RegisterAccountRequest( } @Serializable +@Description("Response after successful account registration") data class RegisterAccountResponse( + @Description("Internal payto URI of the new account") val internal_payto_uri: String ) @@ -237,15 +276,25 @@ data class RegisterAccountResponse( * Request of PATCH /accounts/{USERNAME} */ @Serializable +@Description("Request to reconfigure an existing account") data class AccountReconfiguration( + @Description("Updated contact data for TAN challenges") val contact_data: ChallengeContactData? = null, + @Description("Updated cashout payto URI") val cashout_payto_uri: Option<IbanPayto?> = Option.None, + @Description("Updated legal name of account holder") val name: String? = null, + @Description("Whether account is publicly visible") val is_public: Boolean? = null, + @Description("Updated maximum allowed debit balance") val debit_threshold: TalerAmount? = null, + @Description("Updated preferred TAN channel (deprecated)") val tan_channel: Option<TanChannel?> = Option.None, + @Description("Updated set of enabled TAN channels") val tan_channels: Option<Set<TanChannel>> = Option.None, + @Description("Whether account is a Taler exchange") val is_taler_exchange: Boolean? = null, + @Description("Updated conversion rate class identifier") val conversion_rate_class_id: Option<Long?> = Option.None ) { init { @@ -253,6 +302,7 @@ data class AccountReconfiguration( throw badRequest("you must only use either tan_channel or tan_channels") } + @Description("Computed set of enabled TAN channels") val channels: Option<Set<TanChannel>> get() { if (tan_channels.isSome()) { return tan_channels @@ -273,14 +323,20 @@ data class AccountReconfiguration( * It complies with Taler's design document #49 */ @Serializable +@Description("Request to create an authentication token") data class TokenRequest( + @Description("Permission scope for the token") val scope: TokenScope, + @Description("Token validity duration") val duration: RelativeTime? = null, + @Description("Human-readable token description") val description: String? = null, + @Description("Whether the token can be refreshed") val refreshable: Boolean = false ) @Serializable +@Description("Monitor statistics response") sealed interface MonitorResponse { val talerInCount: Long val talerInVolume: TalerAmount @@ -290,25 +346,41 @@ sealed interface MonitorResponse { @Serializable @SerialName("no-conversions") +@Description("Monitor stats without currency conversion") data class MonitorNoConversion( + @Description("Number of incoming Taler transactions") override val talerInCount: Long, + @Description("Total volume of incoming Taler transactions") override val talerInVolume: TalerAmount, + @Description("Number of outgoing Taler transactions") override val talerOutCount: Long, + @Description("Total volume of outgoing Taler transactions") override val talerOutVolume: TalerAmount ) : MonitorResponse @Serializable @SerialName("with-conversions") +@Description("Monitor stats with currency conversion") data class MonitorWithConversion( + @Description("Number of cash-in operations") val cashinCount: Long, + @Description("Total regional currency cash-in volume") val cashinRegionalVolume: TalerAmount, + @Description("Total fiat currency cash-in volume") val cashinFiatVolume: TalerAmount, + @Description("Number of cashout operations") val cashoutCount: Long, + @Description("Total regional currency cashout volume") val cashoutRegionalVolume: TalerAmount, + @Description("Total fiat currency cashout volume") val cashoutFiatVolume: TalerAmount, + @Description("Number of incoming Taler transactions") override val talerInCount: Long, + @Description("Total volume of incoming Taler transactions") override val talerInVolume: TalerAmount, + @Description("Number of outgoing Taler transactions") override val talerOutCount: Long, + @Description("Total volume of outgoing Taler transactions") override val talerOutVolume: TalerAmount ) : MonitorResponse @@ -343,12 +415,14 @@ interface TanInfo { } // Allowed values for cashout TAN channels. +@Description("Channel for TAN delivery") enum class TanChannel { sms, email } // Scopes for authentication tokens. +@Description("Scope for authentication tokens") enum class TokenScope { readonly, readwrite, @@ -384,73 +458,119 @@ data class BearerToken( ) @Serializable +@Description("Information about an authentication token") data class TokenInfo( + @Description("Token creation timestamp") val creation_time: TalerTimestamp, + @Description("Token expiration timestamp") val expiration: TalerTimestamp, + @Description("Permission scope of the token") val scope: TokenScope, + @Description("Whether the token can be refreshed") val isRefreshable: Boolean, + @Description("Human-readable token description") val description: String? = null, + @Description("Timestamp of last token usage") val last_access: TalerTimestamp, + @Description("Row identifier of the token") val row_id: Long, + @Description("Unique token identifier") val token_id: Long ) @Serializable +@Description("List of authentication tokens") data class TokenInfos ( + @Description("Array of token information entries") val tokens: List<TokenInfo> ) @Serializable +@Description("Bank configuration response") data class Config( + @Description("Regional currency code") val currency: String, + @Description("Currency specification for display") val currency_specification: CurrencySpecification, + @Description("Base URL of the bank") val base_url: BaseURL?, + @Description("Human-readable bank name") val bank_name: String, + @Description("Whether currency conversion is enabled") val allow_conversion: Boolean, + @Description("Whether account registration is open") val allow_registrations: Boolean, + @Description("Whether account deletion is allowed") val allow_deletions: Boolean, + @Description("Whether users can edit their legal name") val allow_edit_name: Boolean, + @Description("Whether users can edit their cashout payto URI") val allow_edit_cashout_payto_uri: Boolean, + @Description("Default debit threshold for new accounts") val default_debit_threshold: TalerAmount, + @Description("Supported TAN channels for two-factor authentication") val supported_tan_channels: Set<TanChannel>, + @Description("Wire transfer method (IBAN or X_TALER_BANK)") val wire_type: WireMethod, + @Description("Fee charged per wire transfer") val wire_transfer_fees: TalerAmount, + @Description("Minimum wire transfer amount") val min_wire_transfer_amount: TalerAmount, + @Description("Maximum wire transfer amount") val max_wire_transfer_amount: TalerAmount ) { + @Description("API name identifier") val name: String = "taler-corebank" + @Description("API version string") val version: String = COREBANK_API_VERSION } @Serializable +@Description("Currency conversion configuration") data class ConversionConfig( + @Description("Regional currency code") val regional_currency: String, + @Description("Regional currency display specification") val regional_currency_specification: CurrencySpecification, + @Description("Fiat currency code") val fiat_currency: String, + @Description("Fiat currency display specification") val fiat_currency_specification: CurrencySpecification, + @Description("Applicable conversion rate") val conversion_rate: ConversionRate ) { + @Description("API name identifier") val name: String = "taler-conversion-info" + @Description("API version string") val version: String = CONVERSION_API_VERSION } @Serializable +@Description("Taler integration API configuration response") data class TalerIntegrationConfigResponse( + @Description("Currency used by this bank") val currency: String, + @Description("Currency display specification") val currency_specification: CurrencySpecification ) { + @Description("API name identifier") val name: String = "taler-bank-integration" + @Description("API version string") val version: String = INTEGRATION_API_VERSION } +@Description("Indicates whether a transaction is a credit or debit") enum class CreditDebitInfo { credit, debit } @Serializable +@Description("Account balance information") data class Balance( + @Description("Balance amount") val amount: TalerAmount, + @Description("Whether balance is credit or debit") val credit_debit_indicator: CreditDebitInfo, ) @@ -458,18 +578,31 @@ data class Balance( * GET /accounts response. */ @Serializable +@Description("Minimal account data for account listings") data class AccountMinimalData( + @Description("Account username") val username: String, + @Description("Legal name of account holder") val name: String, + @Description("Payto URI of the account") val payto_uri: String, + @Description("Current account balance") val balance: Balance, + @Description("Maximum allowed debit balance") val debit_threshold: TalerAmount, + @Description("Whether account is publicly visible") val is_public: Boolean, + @Description("Whether account is a Taler exchange") val is_taler_exchange: Boolean, + @Description("Whether account is locked") val is_locked: Boolean, + @Description("Row identifier of the account") val row_id: Long, + @Description("Current account status") val status: AccountStatus, + @Description("Conversion rate class identifier") val conversion_rate_class_id: Long? = null, + @Description("Applicable conversion rate") val conversion_rate: ConversionRate? = null ) @@ -477,7 +610,9 @@ data class AccountMinimalData( * Response type of GET /accounts. */ @Serializable +@Description("Paginated list of bank accounts") data class ListBankAccountsResponse( + @Description("Array of account summary entries") val accounts: List<AccountMinimalData> ) @@ -485,110 +620,181 @@ data class ListBankAccountsResponse( * GET /accounts/$USERNAME response. */ @Serializable +@Description("Detailed account data for a single account") data class AccountData( + @Description("Legal name of account holder") val name: String, + @Description("Payto URI of the account") val payto_uri: String, + @Description("Current account balance") val balance: Balance, + @Description("Maximum allowed debit balance") val debit_threshold: TalerAmount, + @Description("Contact data for TAN challenges") val contact_data: ChallengeContactData? = null, + @Description("Cashout payto URI for fiat withdrawals") val cashout_payto_uri: String? = null, + @Description("Preferred TAN channel (deprecated)") val tan_channel: TanChannel? = null, + @Description("Set of enabled TAN channels") val tan_channels: Set<TanChannel> = emptySet(), + @Description("Whether account is publicly visible") val is_public: Boolean, + @Description("Whether account is a Taler exchange") val is_taler_exchange: Boolean, + @Description("Whether account is locked") val is_locked: Boolean, + @Description("Current account status") val status: AccountStatus, + @Description("Conversion rate class identifier") val conversion_rate_class_id: Long? = null, + @Description("Applicable conversion rate") val conversion_rate: ConversionRate? = null ) @Serializable +@Description("Request to create a bank transaction") data class TransactionCreateRequest( + @Description("Recipient payto URI") val payto_uri: Payto, + @Description("Transaction amount") val amount: TalerAmount?, + @Description("Idempotency key for the request") val request_uid: ShortHashCode? ) @Serializable +@Description("Response after creating a bank transaction") data class TransactionCreateResponse( + @Description("Row identifier of the new transaction") val row_id: Long ) /* History element, either from GET /transactions/T_ID or from GET /transactions */ @Serializable +@Description("Details of a single bank transaction") data class BankAccountTransactionInfo( + @Description("Payto URI of the creditor") val creditor_payto_uri: String, + @Description("Payto URI of the debtor") val debtor_payto_uri: String, + @Description("Transaction amount") val amount: TalerAmount, + @Description("Transaction direction (credit or debit)") val direction: TransactionDirection, + @Description("Wire transfer subject line") val subject: String, + @Description("Row identifier of the transaction") val row_id: Long, // is T_ID + @Description("Transaction timestamp") val date: TalerTimestamp ) // Response type for histories, namely GET /transactions @Serializable +@Description("Paginated list of bank transactions") data class BankAccountTransactionsResponse( + @Description("Array of transaction entries") val transactions: List<BankAccountTransactionInfo> ) // Taler withdrawal request. @Serializable +@Description("Request to create a Taler withdrawal") data class BankAccountCreateWithdrawalRequest( + @Description("Exact withdrawal amount") val amount: TalerAmount? = null, + @Description("Suggested withdrawal amount for wallet") val suggested_amount: TalerAmount? = null, + @Description("Whether wallet should choose the amount") val no_amount_to_wallet: Boolean = false ) // Taler withdrawal response. @Serializable +@Description("Response after creating a Taler withdrawal") data class BankAccountCreateWithdrawalResponse( + @Description("Unique withdrawal operation identifier") val withdrawal_id: String, + @Description("Taler URI for the wallet to process") val taler_withdraw_uri: String ) @Serializable +@Description("Public information about a withdrawal operation") data class WithdrawalPublicInfo ( + @Description("Current withdrawal status") val status: WithdrawalStatus, + @Description("Withdrawal amount if fixed") val amount: TalerAmount? = null, + @Description("Suggested withdrawal amount") val suggested_amount: TalerAmount? = null, + @Description("Whether wallet should choose the amount") val no_amount_to_wallet: Boolean, + @Description("Username of the withdrawing account") val username: String, + @Description("Selected reserve public key") val selected_reserve_pub: EddsaPublicKey? = null, + @Description("Selected exchange payto account") val selected_exchange_account: String? = null, ) @Serializable +@Description("Currency display specification") data class CurrencySpecification( + @Description("Human-readable currency name") val name: String, + @Description("Fractional digits for input") val num_fractional_input_digits: Int, + @Description("Fractional digits for normal display") val num_fractional_normal_digits: Int, + @Description("Trailing zero digits to display") val num_fractional_trailing_zero_digits: Int, + @Description("Alternative unit names by power of ten") val alt_unit_names: Map<String, String> ) @Serializable +@Description("Detailed withdrawal operation status") data class BankWithdrawalOperationStatus( + @Description("Current withdrawal status") val status: WithdrawalStatus, + @Description("Withdrawal amount if fixed") val amount: TalerAmount? = null, + @Description("Suggested withdrawal amount") val suggested_amount: TalerAmount? = null, + @Description("Minimum allowed withdrawal amount") val min_amount: TalerAmount? = null, + @Description("Maximum allowed withdrawal amount") val max_amount: TalerAmount? = null, + @Description("Card processing fees") val card_fees: TalerAmount? = null, + @Description("Sender wire account details") val sender_wire: String? = null, + @Description("Suggested exchange base URL") val suggested_exchange: String? = null, + @Description("Required exchange base URL") val required_exchange: String? = null, + @Description("URL to confirm the wire transfer") val confirm_transfer_url: String? = null, + @Description("Supported wire transfer types") val wire_types: List<String>, + @Description("Selected reserve public key") val selected_reserve_pub: EddsaPublicKey? = null, + @Description("Selected exchange payto account") val selected_exchange_account: String? = null, + @Description("Whether wallet should choose the amount") val no_amount_to_wallet: Boolean = false, + @Description("Currency of the withdrawal") val currency: String? = null, // TODO deprecated remove in the next breaking release + @Description("Whether withdrawal was aborted (deprecated)") val aborted: Boolean, + @Description("Whether exchange was selected (deprecated)") val selection_done: Boolean, + @Description("Whether wire transfer completed (deprecated)") val transfer_done: Boolean, ) @@ -596,9 +802,13 @@ data class BankWithdrawalOperationStatus( * Selection request on a Taler withdrawal. */ @Serializable +@Description("Wallet request to select exchange for withdrawal") data class BankWithdrawalOperationPostRequest( + @Description("Reserve public key from the wallet") val reserve_pub: EddsaPublicKey, + @Description("Selected exchange payto URI") val selected_exchange: Payto, + @Description("Withdrawal amount chosen by wallet") val amount: TalerAmount? = null ) @@ -607,67 +817,100 @@ data class BankWithdrawalOperationPostRequest( * and the reserve pub. */ @Serializable +@Description("Response after wallet selects exchange") data class BankWithdrawalOperationPostResponse( + @Description("Current withdrawal status") val status: WithdrawalStatus, + @Description("URL to confirm the wire transfer") val confirm_transfer_url: String? = null, // TODO deprecated remove in the next breaking release + @Description("Whether wire transfer completed (deprecated)") val transfer_done: Boolean, ) @Serializable +@Description("Request to initiate a cashout operation") data class CashoutRequest( + @Description("Idempotency key for the request") val request_uid: ShortHashCode, + @Description("Wire transfer subject line") val subject: String?, + @Description("Amount debited in regional currency") val amount_debit: TalerAmount, + @Description("Amount credited in fiat currency") val amount_credit: TalerAmount ) @Serializable +@Description("Response after creating a cashout operation") data class CashoutResponse( + @Description("Unique cashout operation identifier") val cashout_id: Long, ) @Serializable +@Description("List of cashout operations for an account") data class Cashouts( + @Description("Array of cashout summary entries") val cashouts: List<CashoutInfo>, ) @Serializable +@Description("Summary of a single cashout operation") data class CashoutInfo( + @Description("Unique cashout operation identifier") val cashout_id: Long, + @Description("Current cashout status") val status: CashoutStatus, ) @Serializable +@Description("Global list of cashout operations") data class GlobalCashouts( + @Description("Array of global cashout entries") val cashouts: List<GlobalCashoutInfo>, ) @Serializable +@Description("Global cashout entry with username") data class GlobalCashoutInfo( + @Description("Unique cashout operation identifier") val cashout_id: Long, + @Description("Account username of the cashout owner") val username: String, + @Description("Current cashout status") val status: CashoutStatus, ) @Serializable +@Description("Detailed status of a cashout operation") data class CashoutStatusResponse( + @Description("Amount debited in regional currency") val amount_debit: TalerAmount, + @Description("Amount credited in fiat currency") val amount_credit: TalerAmount, + @Description("Wire transfer subject line") val subject: String, + @Description("Cashout creation timestamp") val creation_time: TalerTimestamp, + @Description("Cashout confirmation timestamp") val confirmation_time: TalerTimestamp? = null // TODO update doc ) @Serializable +@Description("Request to solve a TAN challenge") data class ChallengeSolve( + @Description("TAN code entered by the user") val tan: String ) @Serializable +@Description("Currency conversion result") data class ConversionResponse( + @Description("Amount debited from source currency") val amount_debit: TalerAmount, + @Description("Amount credited in target currency") val amount_credit: TalerAmount, ) @@ -675,7 +918,9 @@ data class ConversionResponse( * Response to GET /public-accounts */ @Serializable +@Description("List of publicly visible accounts") data class PublicAccountsResponse( + @Description("Array of public account entries") val public_accounts: List<PublicAccount> ) @@ -683,11 +928,17 @@ data class PublicAccountsResponse( * Single element of GET /public-accounts list. */ @Serializable +@Description("Public account summary information") data class PublicAccount( + @Description("Account username") val username: String, + @Description("Payto URI of the account") val payto_uri: String, + @Description("Current account balance") val balance: Balance, + @Description("Whether account is a Taler exchange") val is_taler_exchange: Boolean, + @Description("Row identifier of the account") val row_id: Long ) @@ -695,14 +946,19 @@ data class PublicAccount( * Request of PATCH /accounts/{USERNAME}/auth */ @Serializable +@Description("Request to change account password") data class AccountPasswordChange( + @Description("New password for the account") val new_password: String, + @Description("Current password for verification") val old_password: String? = null ) // Request POST /accounts/{USERNAME}/withdrawals/{WITHDRAWAL_ID}/confirm @Serializable +@Description("Request to confirm a withdrawal operation") data class BankAccountConfirmWithdrawalRequest( + @Description("Final withdrawal amount") val amount: TalerAmount? = null ) @@ -711,22 +967,35 @@ data class BankAccountConfirmWithdrawalRequest( * Request PATCH /conversion-rate-classes/{CLASS_ID} */ @Serializable +@Description("Input for creating or updating a conversion rate class") data class ConversionRateClassInput( + @Description("Human-readable class name") val name: String, + @Description("Description of the rate class") val description: String? = null, + @Description("Cash-in conversion ratio") val cashin_ratio: DecimalNumber? = null, + @Description("Fee applied on cash-in operations") val cashin_fee: TalerAmount? = null, + @Description("Rounding mode for cash-in amounts") val cashin_rounding_mode: RoundingMode? = null, + @Description("Minimum amount for cash-in operations") val cashin_min_amount: TalerAmount? = null, + @Description("Cashout conversion ratio") val cashout_ratio: DecimalNumber? = null, + @Description("Fee applied on cashout operations") val cashout_fee: TalerAmount? = null, + @Description("Rounding mode for cashout amounts") val cashout_rounding_mode: RoundingMode? = null, + @Description("Minimum amount for cashout operations") val cashout_min_amount: TalerAmount? = null ) /** Response POST /conversion-rate-classes */ @Serializable +@Description("Response after creating a conversion rate class") data class ConversionRateClassResponse( + @Description("Identifier of the created rate class") val conversion_rate_class_id: Long, ) @@ -734,18 +1003,31 @@ data class ConversionRateClassResponse( * Response GET /conversion-rate-classes/{CLASS_ID} */ @Serializable +@Description("Detailed conversion rate class information") data class ConversionRateClass( + @Description("Unique rate class identifier") val conversion_rate_class_id: Long, + @Description("Human-readable class name") val name: String, + @Description("Description of the rate class") val description: String? = null, + @Description("Number of users in this class") val num_users: Int, + @Description("Cash-in conversion ratio") val cashin_ratio: DecimalNumber? = null, + @Description("Fee applied on cash-in operations") val cashin_fee: TalerAmount? = null, + @Description("Rounding mode for cash-in amounts") val cashin_rounding_mode: RoundingMode? = null, + @Description("Minimum amount for cash-in operations") val cashin_min_amount: TalerAmount? = null, + @Description("Cashout conversion ratio") val cashout_ratio: DecimalNumber? = null, + @Description("Fee applied on cashout operations") val cashout_fee: TalerAmount? = null, + @Description("Rounding mode for cashout amounts") val cashout_rounding_mode: RoundingMode? = null, + @Description("Minimum amount for cashout operations") val cashout_min_amount: TalerAmount? = null ) @@ -753,6 +1035,8 @@ data class ConversionRateClass( * Response GET /conversion-rate-classes */ @Serializable +@Description("List of all conversion rate classes") data class ConversionRateClasses( + @Description("Array of conversion rate class entries") val classes: List<ConversionRateClass> ) diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt @@ -26,6 +26,7 @@ import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* +import io.github.smiley4.ktoropenapi.* import tech.libeufin.bank.* import tech.libeufin.bank.db.AbortResult import tech.libeufin.bank.db.Database @@ -33,7 +34,14 @@ import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalSelectionResult import tech.libeufin.common.* fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { - get("/taler-integration/config") { + get("/taler-integration/config", { + operationId = "getIntegrationConfig" + description = "Get bank integration configuration" + tags = listOf("Bank Integration") + response { + code(HttpStatusCode.OK) { description = "Integration configuration"; body<TalerIntegrationConfigResponse>() } + } + }) { call.respond(TalerIntegrationConfigResponse( currency = ctx.regionalCurrency, currency_specification = ctx.regionalCurrencySpec @@ -41,7 +49,26 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { } // Note: wopid acts as an authentication token. - get("/taler-integration/withdrawal-operation/{wopid}") { + get("/taler-integration/withdrawal-operation/{wopid}", { + operationId = "getIntegrationWithdrawalStatus" + description = "Get withdrawal operation status" + tags = listOf("Bank Integration") + request { + pathParameter<String>("wopid") { description = "Withdrawal operation ID" } + queryParameter<Long>("timeout_ms") { + description = "Timeout for long polling in milliseconds (default: 0, no long polling)" + required = false + } + queryParameter<String>("old_state") { + description = "Previous state for long polling (pending, aborted, selected, confirmed). Default: pending" + required = false + } + } + response { + code(HttpStatusCode.OK) { description = "Withdrawal operation status"; body<BankWithdrawalOperationStatus>() } + code(HttpStatusCode.NotFound) { description = "Withdrawal operation not found" } + } + }) { val uuid = call.uuidPath("wopid") val params = StatusParams.extract(call.request.queryParameters) val op = db.withdrawal.pollStatus( @@ -61,7 +88,20 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { confirm_transfer_url = if (op.status == WithdrawalStatus.pending || op.status == WithdrawalStatus.selected) ctx.withdrawConfirmUrl(uuid) else null )) } - post("/taler-integration/withdrawal-operation/{wopid}") { + post("/taler-integration/withdrawal-operation/{wopid}", { + operationId = "selectWithdrawalExchange" + description = "Select exchange and reserve for withdrawal" + tags = listOf("Bank Integration") + request { + pathParameter<String>("wopid") { description = "Withdrawal operation ID" } + body<BankWithdrawalOperationPostRequest>() + } + response { + code(HttpStatusCode.OK) { description = "Exchange selected"; body<BankWithdrawalOperationPostResponse>() } + code(HttpStatusCode.NotFound) { description = "Withdrawal operation not found" } + code(HttpStatusCode.Conflict) { description = "Operation conflict" } + } + }) { val uuid = call.uuidPath("wopid") val req = call.receive<BankWithdrawalOperationPostRequest>() req.amount?.run(ctx::checkRegionalCurrency) @@ -118,7 +158,19 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { } } } - post("/taler-integration/withdrawal-operation/{wopid}/abort") { + post("/taler-integration/withdrawal-operation/{wopid}/abort", { + operationId = "abortIntegrationWithdrawal" + description = "Abort a withdrawal operation" + tags = listOf("Bank Integration") + request { + pathParameter<String>("wopid") { description = "Withdrawal operation ID" } + } + response { + code(HttpStatusCode.NoContent) { description = "Withdrawal aborted successfully" } + code(HttpStatusCode.NotFound) { description = "Withdrawal operation not found" } + code(HttpStatusCode.Conflict) { description = "Cannot abort confirmed withdrawal" } + } + }) { val uuid = call.uuidPath("wopid") when (db.withdrawal.abort(uuid)) { AbortResult.UnknownOperation -> throw notFound( diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt @@ -23,6 +23,7 @@ import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* +import io.github.smiley4.ktoropenapi.* import tech.libeufin.bank.* import tech.libeufin.bank.auth.* import tech.libeufin.bank.db.ConversionDAO @@ -48,13 +49,78 @@ private suspend fun ApplicationCall.setGlobal(db: Database, cfg: BankConfig) { respond(HttpStatusCode.NoContent) } fun Routing.conversionApi(db: Database, cfg: BankConfig) = conditional(cfg.allowConversion) { - get("/conversion-info/config") { call.config(db, cfg) } - get("/conversion-rate-classes/{CLASS_ID}/conversion-info/config") { call.config(db, cfg) } - get("/accounts/{USERNAME}/conversion-info/config") { call.config(db, cfg) } + get("/conversion-info/config", { + operationId = "getConversionConfig" + description = "Get conversion configuration" + tags = listOf("Conversion") + response { + code(HttpStatusCode.OK) { description = "Conversion configuration"; body<ConversionConfig>() } + } + }) { call.config(db, cfg) } + get("/conversion-rate-classes/{CLASS_ID}/conversion-info/config", { + operationId = "getConversionConfigForClass" + description = "Get conversion configuration for a rate class" + tags = listOf("Conversion") + request { + pathParameter<Long>("CLASS_ID") { description = "Conversion rate class ID" } + } + response { + code(HttpStatusCode.OK) { description = "Conversion configuration for rate class"; body<ConversionConfig>() } + } + }) { call.config(db, cfg) } + get("/accounts/{USERNAME}/conversion-info/config", { + operationId = "getConversionConfigForAccount" + description = "Get conversion configuration for an account" + tags = listOf("Conversion") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + } + response { + code(HttpStatusCode.OK) { description = "Conversion configuration for account"; body<ConversionConfig>() } + } + }) { call.config(db, cfg) } authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat) { - post("/conversion-info/conversion-rate") { call.setGlobal(db, cfg) } - post("/accounts/{USERNAME}/conversion-info/conversion-rate") { call.setGlobal(db, cfg) } - post("/conversion-rate-classes/{CLASS_ID}/conversion-info/conversion-rate") { call.setGlobal(db, cfg) } + post("/conversion-info/conversion-rate", { + operationId = "setGlobalConversionRate" + description = "Set global conversion rate" + tags = listOf("Conversion") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + body<ConversionRate>() + } + response { + code(HttpStatusCode.NoContent) { description = "Conversion rate updated" } + } + }) { call.setGlobal(db, cfg) } + post("/accounts/{USERNAME}/conversion-info/conversion-rate", { + operationId = "setAccountConversionRate" + description = "Set conversion rate for an account" + tags = listOf("Conversion") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + body<ConversionRate>() + } + response { + code(HttpStatusCode.NoContent) { description = "Conversion rate updated" } + } + }) { call.setGlobal(db, cfg) } + post("/conversion-rate-classes/{CLASS_ID}/conversion-info/conversion-rate", { + operationId = "setClassConversionRate" + description = "Set conversion rate for a rate class" + tags = listOf("Conversion") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<Long>("CLASS_ID") { description = "Conversion rate class ID" } + body<ConversionRate>() + } + response { + code(HttpStatusCode.NoContent) { description = "Conversion rate updated" } + } + }) { call.setGlobal(db, cfg) } } suspend fun ApplicationCall.convert( input: TalerAmount, @@ -77,10 +143,34 @@ fun Routing.conversionApi(db: Database, cfg: BankConfig) = conditional(cfg.allow ) } } - get("/conversion-info/rate") { + get("/conversion-info/rate", { + operationId = "getDefaultConversionRate" + description = "Get default conversion rate" + tags = listOf("Conversion") + response { + code(HttpStatusCode.OK) { description = "Default conversion rate"; body<ConversionRate>() } + } + }) { call.respond(db.conversion.getDefaultRate()) } - get("/conversion-info/cashout-rate") { + get("/conversion-info/cashout-rate", { + operationId = "getDefaultCashoutRate" + description = "Calculate cashout conversion rate" + tags = listOf("Conversion") + request { + queryParameter<String>("amount_debit") { + description = "Amount to convert from regional currency (mutually exclusive with amount_credit)" + required = false + } + queryParameter<String>("amount_credit") { + description = "Amount to convert to fiat currency (mutually exclusive with amount_debit)" + required = false + } + } + response { + code(HttpStatusCode.OK) { description = "Cashout conversion result"; body<ConversionResponse>() } + } + }) { val params = RateParams.extract(call.request.queryParameters) params.debit?.let { cfg.checkRegionalCurrency(it) } @@ -96,7 +186,24 @@ fun Routing.conversionApi(db: Database, cfg: BankConfig) = conditional(cfg.allow } } } - get("/conversion-info/cashin-rate") { + get("/conversion-info/cashin-rate", { + operationId = "getDefaultCashinRate" + description = "Calculate cashin conversion rate" + tags = listOf("Conversion") + request { + queryParameter<String>("amount_debit") { + description = "Amount to convert from fiat currency (mutually exclusive with amount_credit)" + required = false + } + queryParameter<String>("amount_credit") { + description = "Amount to convert to regional currency (mutually exclusive with amount_debit)" + required = false + } + } + response { + code(HttpStatusCode.OK) { description = "Cashin conversion result"; body<ConversionResponse>() } + } + }) { val params = RateParams.extract(call.request.queryParameters) params.debit?.let { cfg.checkFiatCurrency(it) } @@ -113,12 +220,44 @@ fun Routing.conversionApi(db: Database, cfg: BankConfig) = conditional(cfg.allow } } authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat) { - get("/conversion-rate-classes/{CLASS_ID}/conversion-info/rate") { + get("/conversion-rate-classes/{CLASS_ID}/conversion-info/rate", { + operationId = "getClassConversionRate" + description = "Get conversion rate for a rate class" + tags = listOf("Conversion") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<Long>("CLASS_ID") { description = "Conversion rate class ID" } + } + response { + code(HttpStatusCode.OK) { description = "Class conversion rate"; body<ConversionRate>() } + } + }) { val id = call.longPath("CLASS_ID") val rate = db.conversion.getClassRate(id) call.respond(rate) } - get("/conversion-rate-classes/{CLASS_ID}/conversion-info/cashout-rate") { + get("/conversion-rate-classes/{CLASS_ID}/conversion-info/cashout-rate", { + operationId = "getClassCashoutRate" + description = "Calculate cashout conversion rate for a rate class" + tags = listOf("Conversion") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<Long>("CLASS_ID") { description = "Conversion rate class ID" } + queryParameter<String>("amount_debit") { + description = "Amount to convert from regional currency (mutually exclusive with amount_credit)" + required = false + } + queryParameter<String>("amount_credit") { + description = "Amount to convert to fiat currency (mutually exclusive with amount_debit)" + required = false + } + } + response { + code(HttpStatusCode.OK) { description = "Class cashout conversion result"; body<ConversionResponse>() } + } + }) { val id = call.longPath("CLASS_ID") val params = RateParams.extract(call.request.queryParameters) @@ -135,7 +274,27 @@ fun Routing.conversionApi(db: Database, cfg: BankConfig) = conditional(cfg.allow } } } - get("/conversion-rate-classes/{CLASS_ID}/conversion-info/cashin-rate") { + get("/conversion-rate-classes/{CLASS_ID}/conversion-info/cashin-rate", { + operationId = "getClassCashinRate" + description = "Calculate cashin conversion rate for a rate class" + tags = listOf("Conversion") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<Long>("CLASS_ID") { description = "Conversion rate class ID" } + queryParameter<String>("amount_debit") { + description = "Amount to convert from fiat currency (mutually exclusive with amount_credit)" + required = false + } + queryParameter<String>("amount_credit") { + description = "Amount to convert to regional currency (mutually exclusive with amount_debit)" + required = false + } + } + response { + code(HttpStatusCode.OK) { description = "Class cashin conversion result"; body<ConversionResponse>() } + } + }) { val id = call.longPath("CLASS_ID") val params = RateParams.extract(call.request.queryParameters) @@ -153,7 +312,25 @@ fun Routing.conversionApi(db: Database, cfg: BankConfig) = conditional(cfg.allow } } } - get("/accounts/{USERNAME}/conversion-info/cashin-rate") { + get("/accounts/{USERNAME}/conversion-info/cashin-rate", { + operationId = "getAccountCashinRate" + description = "Calculate cashin conversion rate for an account" + tags = listOf("Conversion") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + queryParameter<String>("amount_debit") { + description = "Amount to convert from fiat currency (mutually exclusive with amount_credit)" + required = false + } + queryParameter<String>("amount_credit") { + description = "Amount to convert to regional currency (mutually exclusive with amount_debit)" + required = false + } + } + response { + code(HttpStatusCode.OK) { description = "Account cashin conversion result"; body<ConversionResponse>() } + } + }) { val params = RateParams.extract(call.request.queryParameters) params.debit?.let { cfg.checkFiatCurrency(it) } @@ -170,7 +347,19 @@ fun Routing.conversionApi(db: Database, cfg: BankConfig) = conditional(cfg.allow } } optAuth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) { - get("/accounts/{USERNAME}/conversion-info/rate") { + get("/accounts/{USERNAME}/conversion-info/rate", { + operationId = "getAccountConversionRate" + description = "Get conversion rate for an account" + tags = listOf("Conversion") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + } + response { + code(HttpStatusCode.OK) { description = "Account conversion rate"; body<ConversionRate>() } + } + }) { val (isExchange, rate) = db.conversion.getUserRate(call.pathUsername) if (!isExchange && !call.isAuthenticated) { throw forbidden("Non exchange account rates are private") @@ -179,7 +368,27 @@ fun Routing.conversionApi(db: Database, cfg: BankConfig) = conditional(cfg.allow } } auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) { - get("/accounts/{USERNAME}/conversion-info/cashout-rate") { + get("/accounts/{USERNAME}/conversion-info/cashout-rate", { + operationId = "getAccountCashoutRate" + description = "Calculate cashout conversion rate for an account" + tags = listOf("Conversion") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + queryParameter<String>("amount_debit") { + description = "Amount to convert from regional currency (mutually exclusive with amount_credit)" + required = false + } + queryParameter<String>("amount_credit") { + description = "Amount to convert to fiat currency (mutually exclusive with amount_debit)" + required = false + } + } + response { + code(HttpStatusCode.OK) { description = "Account cashout conversion result"; body<ConversionResponse>() } + } + }) { val params = RateParams.extract(call.request.queryParameters) params.debit?.let { cfg.checkRegionalCurrency(it) } diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt @@ -23,6 +23,7 @@ import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* +import io.github.smiley4.ktoropenapi.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.future.await import kotlinx.coroutines.withContext @@ -48,7 +49,14 @@ import java.util.* private val logger: Logger = LoggerFactory.getLogger("libeufin-bank-api") fun Routing.coreBankApi(db: Database, cfg: BankConfig) { - get("/config") { + get("/config", { + operationId = "getConfig" + description = "Get bank configuration" + tags = listOf("Core Bank") + response { + code(HttpStatusCode.OK) { description = "Bank configuration"; body<Config>() } + } + }) { call.respond( Config( bank_name = cfg.name, @@ -70,7 +78,31 @@ fun Routing.coreBankApi(db: Database, cfg: BankConfig) { ) } authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat) { - get("/monitor") { + get("/monitor", { + operationId = "getMonitor" + description = "Get bank monitoring data (admin only)" + tags = listOf("Core Bank") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + queryParameter<String>("timeframe") { + description = "Timeframe for monitoring data (hour, day, month, year). Default: hour" + required = false + } + queryParameter<Int>("which") { + description = "Deprecated. Which period to query within the timeframe" + required = false + deprecated = true + } + queryParameter<Long>("date_s") { + description = "Timestamp in seconds to specify the period" + required = false + } + } + response { + code(HttpStatusCode.OK) { description = "Monitoring data"; body<MonitorResponse>() } + } + }) { val params = MonitorParams.extract(call.request.queryParameters) call.respond(db.monitor(params)) } @@ -87,7 +119,20 @@ fun Routing.coreBankApi(db: Database, cfg: BankConfig) { private fun Routing.coreBankTokenApi(db: Database, cfg: BankConfig) { val TOKEN_DEFAULT_DURATION: Duration = Duration.ofDays(1L) auth(db, cfg.pwCrypto, TokenLogicalScope.refreshable, cfg.basicAuthCompat, allowPw = true) { - post("/accounts/{USERNAME}/token") { + post("/accounts/{USERNAME}/token", { + operationId = "createToken" + description = "Create or refresh an authentication token" + tags = listOf("Core Bank - Tokens") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + body<TokenRequest>() + } + response { + code(HttpStatusCode.OK) { description = "Token created"; body<TokenSuccessResponse>() } + } + }) { val (req, challenge) = call.receiveChallenge<TokenRequest>(db, Operation.create_token) val existingToken = call.authToken @@ -137,7 +182,21 @@ private fun Routing.coreBankTokenApi(db: Database, cfg: BankConfig) { } } auth(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat, allowAdmin = true) { - delete("/accounts/{USERNAME}/tokens/{TOKEN_ID}") { + delete("/accounts/{USERNAME}/tokens/{TOKEN_ID}", { + operationId = "deleteTokenById" + description = "Delete a specific authentication token by ID" + tags = listOf("Core Bank - Tokens") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + pathParameter<Long>("TOKEN_ID") { description = "Token identifier" } + } + response { + code(HttpStatusCode.NoContent) { description = "Token deleted" } + code(HttpStatusCode.NotFound) { description = "Token not found" } + } + }) { val id = call.longPath("TOKEN_ID") if (db.token.deleteById(id)) { call.respond(HttpStatusCode.NoContent) @@ -150,14 +209,47 @@ private fun Routing.coreBankTokenApi(db: Database, cfg: BankConfig) { } } auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat) { - delete("/accounts/{USERNAME}/token") { + delete("/accounts/{USERNAME}/token", { + operationId = "deleteToken" + description = "Delete the current authentication token" + tags = listOf("Core Bank - Tokens") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + } + response { + code(HttpStatusCode.NoContent) { description = "Token deleted" } + } + }) { val token = call.authToken ?: throw badRequest("Basic auth not supported here.") db.token.delete(token) call.respond(HttpStatusCode.NoContent) } } auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) { - get("/accounts/{USERNAME}/tokens") { + get("/accounts/{USERNAME}/tokens", { + operationId = "listTokens" + description = "List authentication tokens for an account" + tags = listOf("Core Bank - Tokens") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + queryParameter<Int>("limit") { + description = "Maximum number of results to return (default: -20, negative means descending)" + required = false + } + queryParameter<Long>("offset") { + description = "Row ID offset for pagination" + required = false + } + } + response { + code(HttpStatusCode.OK) { description = "List of tokens"; body<TokenInfos>() } + code(HttpStatusCode.NoContent) { description = "No tokens found" } + } + }) { val params = PageParams.extract(call.request.queryParameters) val tokens = db.token.page(params, call.pathUsername, Instant.now()) if (tokens.isEmpty()) { @@ -321,7 +413,20 @@ suspend fun patchAccount( private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) { authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat, !cfg.allowRegistration) { - post("/accounts") { + post("/accounts", { + operationId = "createAccount" + description = "Create a new bank account" + tags = listOf("Core Bank - Accounts") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + body<RegisterAccountRequest>() + } + response { + code(HttpStatusCode.OK) { description = "Account created"; body<RegisterAccountResponse>() } + code(HttpStatusCode.Conflict) { description = "Account creation conflict" } + } + }) { val req = call.receive<RegisterAccountRequest>() when (val result = createAccount(db, cfg, req, call.isAdmin)) { AccountCreationResult.BonusBalanceInsufficient -> throw conflict( @@ -350,7 +455,21 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) { allowAdmin = true, requireAdmin = !cfg.allowAccountDeletion ) { - delete("/accounts/{USERNAME}") { + delete("/accounts/{USERNAME}", { + operationId = "deleteAccount" + description = "Delete a bank account" + tags = listOf("Core Bank - Accounts") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + } + response { + code(HttpStatusCode.NoContent) { description = "Account deleted" } + code(HttpStatusCode.NotFound) { description = "Account not found" } + code(HttpStatusCode.Conflict) { description = "Account balance not zero or reserved" } + } + }) { val (_, challenge) = call.receiveChallenge(db, Operation.account_delete, Unit) // Not deleting reserved names. @@ -377,7 +496,22 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) { } } auth(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat, allowAdmin = true) { - patch("/accounts/{USERNAME}") { + patch("/accounts/{USERNAME}", { + operationId = "reconfigureAccount" + description = "Reconfigure a bank account" + tags = listOf("Core Bank - Accounts") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + body<AccountReconfiguration>() + } + response { + code(HttpStatusCode.NoContent) { description = "Account reconfigured" } + code(HttpStatusCode.NotFound) { description = "Account not found" } + code(HttpStatusCode.Conflict) { description = "Reconfiguration conflict" } + } + }) { val (req, pendingValidation) = call.receiveChallenge<AccountReconfiguration>(db, Operation.account_reconfig) if (pendingValidation != null && pendingValidation.isNotEmpty()) { @@ -419,7 +553,22 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) { ) } } - patch("/accounts/{USERNAME}/auth") { + patch("/accounts/{USERNAME}/auth", { + operationId = "changePassword" + description = "Change account password" + tags = listOf("Core Bank - Accounts") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + body<AccountPasswordChange>() + } + response { + code(HttpStatusCode.NoContent) { description = "Password changed" } + code(HttpStatusCode.NotFound) { description = "Account not found" } + code(HttpStatusCode.Conflict) { description = "Old password mismatch" } + } + }) { val (req, challenge) = call.receiveChallenge<AccountPasswordChange>(db, Operation.account_auth_reconfig) if (!call.isAdmin && req.old_password == null) { @@ -441,7 +590,33 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) { } } } - get("/public-accounts") { + get("/public-accounts", { + operationId = "listPublicAccounts" + description = "List public bank accounts" + tags = listOf("Core Bank - Accounts") + request { + queryParameter<Int>("limit") { + description = "Maximum number of results to return (default: -20, negative means descending)" + required = false + } + queryParameter<Long>("offset") { + description = "Row ID offset for pagination" + required = false + } + queryParameter<String>("filter_name") { + description = "Filter accounts by username substring" + required = false + } + queryParameter<Long>("conversion_rate_class_id") { + description = "Filter accounts by conversion rate class ID" + required = false + } + } + response { + code(HttpStatusCode.OK) { description = "List of public accounts"; body<PublicAccountsResponse>() } + code(HttpStatusCode.NoContent) { description = "No public accounts found" } + } + }) { val params = AccountParams.extract(call.request.queryParameters) val publicAccounts = db.account.pagePublic(params) if (publicAccounts.isEmpty()) { @@ -451,7 +626,35 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) { } } authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat) { - get("/accounts") { + get("/accounts", { + operationId = "listAccounts" + description = "List all bank accounts (admin only)" + tags = listOf("Core Bank - Accounts") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + queryParameter<Int>("limit") { + description = "Maximum number of results to return (default: -20, negative means descending)" + required = false + } + queryParameter<Long>("offset") { + description = "Row ID offset for pagination" + required = false + } + queryParameter<String>("filter_name") { + description = "Filter accounts by username substring" + required = false + } + queryParameter<Long>("conversion_rate_class_id") { + description = "Filter accounts by conversion rate class ID" + required = false + } + } + response { + code(HttpStatusCode.OK) { description = "List of accounts"; body<ListBankAccountsResponse>() } + code(HttpStatusCode.NoContent) { description = "No accounts found" } + } + }) { val params = AccountParams.extract(call.request.queryParameters) val accounts = db.account.pageAdmin(params) if (accounts.isEmpty()) { @@ -462,7 +665,20 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) { } } auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) { - get("/accounts/{USERNAME}") { + get("/accounts/{USERNAME}", { + operationId = "getAccount" + description = "Get account details" + tags = listOf("Core Bank - Accounts") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + } + response { + code(HttpStatusCode.OK) { description = "Account details"; body<AccountData>() } + code(HttpStatusCode.NotFound) { description = "Account not found" } + } + }) { val account = db.account.get(call.pathUsername) ?: throw unknownAccount(call.pathUsername) call.respond(account) } @@ -471,7 +687,32 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) { private fun Routing.coreBankTransactionsApi(db: Database, cfg: BankConfig) { auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) { - get("/accounts/{USERNAME}/transactions") { + get("/accounts/{USERNAME}/transactions", { + operationId = "getTransactions" + description = "Get transaction history for an account" + tags = listOf("Core Bank - Transactions") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + queryParameter<Int>("limit") { + description = "Maximum number of results to return (default: -20, negative means descending)" + required = false + } + queryParameter<Long>("offset") { + description = "Row ID offset for pagination" + required = false + } + queryParameter<Long>("timeout_ms") { + description = "Timeout for long polling in milliseconds (default: 0, no long polling)" + required = false + } + } + response { + code(HttpStatusCode.OK) { description = "Transaction history"; body<BankAccountTransactionsResponse>() } + code(HttpStatusCode.NoContent) { description = "No transactions found" } + } + }) { val params = HistoryParams.extract(call.request.queryParameters) val bankAccount = call.bankInfo(db) @@ -483,7 +724,21 @@ private fun Routing.coreBankTransactionsApi(db: Database, cfg: BankConfig) { call.respond(BankAccountTransactionsResponse(history)) } } - get("/accounts/{USERNAME}/transactions/{T_ID}") { + get("/accounts/{USERNAME}/transactions/{T_ID}", { + operationId = "getTransaction" + description = "Get a specific transaction by ID" + tags = listOf("Core Bank - Transactions") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + pathParameter<Long>("T_ID") { description = "Transaction identifier" } + } + response { + code(HttpStatusCode.OK) { description = "Transaction details"; body<BankAccountTransactionInfo>() } + code(HttpStatusCode.NotFound) { description = "Transaction not found" } + } + }) { val tId = call.longPath("T_ID") val tx = db.transaction.get(tId, call.pathUsername) ?: throw notFound( "Bank transaction '$tId' not found", @@ -493,7 +748,21 @@ private fun Routing.coreBankTransactionsApi(db: Database, cfg: BankConfig) { } } auth(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat) { - post("/accounts/{USERNAME}/transactions") { + post("/accounts/{USERNAME}/transactions", { + operationId = "createTransaction" + description = "Create a new bank transaction" + tags = listOf("Core Bank - Transactions") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + body<TransactionCreateRequest>() + } + response { + code(HttpStatusCode.OK) { description = "Transaction created"; body<TransactionCreateResponse>() } + code(HttpStatusCode.Conflict) { description = "Transaction conflict" } + } + }) { val (req, challenge) = call.receiveChallenge<TransactionCreateRequest>(db, Operation.bank_transaction) val subject = req.payto_uri.message ?: throw badRequest("Wire transfer lacks subject") @@ -547,7 +816,21 @@ private fun Routing.coreBankTransactionsApi(db: Database, cfg: BankConfig) { private fun Routing.coreBankWithdrawalApi(db: Database, cfg: BankConfig) { auth(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat) { - post("/accounts/{USERNAME}/withdrawals") { + post("/accounts/{USERNAME}/withdrawals", { + operationId = "createWithdrawal" + description = "Create a new withdrawal operation" + tags = listOf("Core Bank - Withdrawals") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + body<BankAccountCreateWithdrawalRequest>() + } + response { + code(HttpStatusCode.OK) { description = "Withdrawal created"; body<BankAccountCreateWithdrawalResponse>() } + code(HttpStatusCode.Conflict) { description = "Withdrawal conflict" } + } + }) { val req = call.receive<BankAccountCreateWithdrawalRequest>() req.amount?.run(cfg::checkRegionalCurrency) req.suggested_amount?.run(cfg::checkRegionalCurrency) @@ -586,7 +869,23 @@ private fun Routing.coreBankWithdrawalApi(db: Database, cfg: BankConfig) { } } } - post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/confirm") { + post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/confirm", { + operationId = "confirmWithdrawal" + description = "Confirm a withdrawal operation" + tags = listOf("Core Bank - Withdrawals") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + pathParameter<String>("withdrawal_id") { description = "Withdrawal operation ID" } + body<BankAccountConfirmWithdrawalRequest>() + } + response { + code(HttpStatusCode.NoContent) { description = "Withdrawal confirmed" } + code(HttpStatusCode.NotFound) { description = "Withdrawal operation not found" } + code(HttpStatusCode.Conflict) { description = "Confirmation conflict" } + } + }) { val id = call.uuidPath("withdrawal_id") val (req, challenge) = call.receiveChallenge<BankAccountConfirmWithdrawalRequest>(db, Operation.withdrawal, BankAccountConfirmWithdrawalRequest()) req.amount?.run(cfg::checkRegionalCurrency) @@ -638,7 +937,22 @@ private fun Routing.coreBankWithdrawalApi(db: Database, cfg: BankConfig) { WithdrawalConfirmationResult.Success -> call.respond(HttpStatusCode.NoContent) } } - post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/abort") { + post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/abort", { + operationId = "abortWithdrawal" + description = "Abort a withdrawal operation" + tags = listOf("Core Bank - Withdrawals") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + pathParameter<String>("withdrawal_id") { description = "Withdrawal operation ID" } + } + response { + code(HttpStatusCode.NoContent) { description = "Withdrawal aborted" } + code(HttpStatusCode.NotFound) { description = "Withdrawal operation not found" } + code(HttpStatusCode.Conflict) { description = "Cannot abort confirmed withdrawal" } + } + }) { val opId = call.uuidPath("withdrawal_id") when (db.withdrawal.abort(opId)) { AbortResult.UnknownOperation -> throw notFound( @@ -653,7 +967,26 @@ private fun Routing.coreBankWithdrawalApi(db: Database, cfg: BankConfig) { } } } - get("/withdrawals/{withdrawal_id}") { + get("/withdrawals/{withdrawal_id}", { + operationId = "getWithdrawalStatus" + description = "Get withdrawal operation status" + tags = listOf("Core Bank - Withdrawals") + request { + pathParameter<String>("withdrawal_id") { description = "Withdrawal operation ID" } + queryParameter<Long>("timeout_ms") { + description = "Timeout for long polling in milliseconds (default: 0, no long polling)" + required = false + } + queryParameter<String>("old_state") { + description = "Previous state for long polling (pending, aborted, selected, confirmed). Default: pending" + required = false + } + } + response { + code(HttpStatusCode.OK) { description = "Withdrawal status"; body<WithdrawalPublicInfo>() } + code(HttpStatusCode.NotFound) { description = "Withdrawal operation not found" } + } + }) { val uuid = call.uuidPath("withdrawal_id") val params = StatusParams.extract(call.request.queryParameters) val op = db.withdrawal.pollInfo(uuid, params) ?: throw notFound( @@ -666,7 +999,21 @@ private fun Routing.coreBankWithdrawalApi(db: Database, cfg: BankConfig) { private fun Routing.coreBankCashoutApi(db: Database, cfg: BankConfig) = conditional(cfg.allowConversion) { auth(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat) { - post("/accounts/{USERNAME}/cashouts") { + post("/accounts/{USERNAME}/cashouts", { + operationId = "createCashout" + description = "Create a new cashout operation" + tags = listOf("Core Bank - Cashouts") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + body<CashoutRequest>() + } + response { + code(HttpStatusCode.OK) { description = "Cashout created"; body<CashoutResponse>() } + code(HttpStatusCode.Conflict) { description = "Cashout conflict" } + } + }) { val (req, challenge) = call.receiveChallenge<CashoutRequest>(db, Operation.cashout) cfg.checkRegionalCurrency(req.amount_debit) @@ -715,7 +1062,21 @@ private fun Routing.coreBankCashoutApi(db: Database, cfg: BankConfig) = conditio } } auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) { - get("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}") { + get("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}", { + operationId = "getCashout" + description = "Get a specific cashout operation" + tags = listOf("Core Bank - Cashouts") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + pathParameter<Long>("CASHOUT_ID") { description = "Cashout operation identifier" } + } + response { + code(HttpStatusCode.OK) { description = "Cashout details"; body<CashoutStatusResponse>() } + code(HttpStatusCode.NotFound) { description = "Cashout operation not found" } + } + }) { val id = call.longPath("CASHOUT_ID") val cashout = db.cashout.get(id, call.pathUsername) ?: throw notFound( "Cashout operation $id not found", @@ -723,7 +1084,28 @@ private fun Routing.coreBankCashoutApi(db: Database, cfg: BankConfig) = conditio ) call.respond(cashout) } - get("/accounts/{USERNAME}/cashouts") { + get("/accounts/{USERNAME}/cashouts", { + operationId = "listAccountCashouts" + description = "List cashout operations for an account" + tags = listOf("Core Bank - Cashouts") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + queryParameter<Int>("limit") { + description = "Maximum number of results to return (default: -20, negative means descending)" + required = false + } + queryParameter<Long>("offset") { + description = "Row ID offset for pagination" + required = false + } + } + response { + code(HttpStatusCode.OK) { description = "List of cashouts for account"; body<Cashouts>() } + code(HttpStatusCode.NoContent) { description = "No cashout operations found" } + } + }) { val params = PageParams.extract(call.request.queryParameters) val cashouts = db.cashout.pageForUser(params, call.pathUsername) if (cashouts.isEmpty()) { @@ -734,7 +1116,27 @@ private fun Routing.coreBankCashoutApi(db: Database, cfg: BankConfig) = conditio } } authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat) { - get("/cashouts") { + get("/cashouts", { + operationId = "listAllCashouts" + description = "List all cashout operations (admin only)" + tags = listOf("Core Bank - Cashouts") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + queryParameter<Int>("limit") { + description = "Maximum number of results to return (default: -20, negative means descending)" + required = false + } + queryParameter<Long>("offset") { + description = "Row ID offset for pagination" + required = false + } + } + response { + code(HttpStatusCode.OK) { description = "List of all cashouts"; body<GlobalCashouts>() } + code(HttpStatusCode.NoContent) { description = "No cashout operations found" } + } + }) { val params = PageParams.extract(call.request.queryParameters) val cashouts = db.cashout.pageAll(params) if (cashouts.isEmpty()) { @@ -747,7 +1149,20 @@ private fun Routing.coreBankCashoutApi(db: Database, cfg: BankConfig) = conditio } private fun Routing.coreBankTanApi(db: Database, cfg: BankConfig) { - post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}") { + post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}", { + operationId = "sendTanChallenge" + description = "Send a TAN challenge" + tags = listOf("Core Bank - TAN") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + pathParameter<String>("CHALLENGE_ID") { description = "Challenge identifier" } + } + response { + code(HttpStatusCode.OK) { description = "Challenge response"; body<ChallengeRequestResponse>() } + code(HttpStatusCode.NotFound) { description = "Challenge not found or expired" } + code(HttpStatusCode.Gone) { description = "Challenge already solved" } + } + }) { val uuid = call.uuidPath("CHALLENGE_ID") val res = db.tan.send( uuid = uuid, @@ -820,7 +1235,21 @@ private fun Routing.coreBankTanApi(db: Database, cfg: BankConfig) { } } } - post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}/confirm") { + post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}/confirm", { + operationId = "confirmTanChallenge" + description = "Confirm a TAN challenge" + tags = listOf("Core Bank - TAN") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + pathParameter<String>("CHALLENGE_ID") { description = "Challenge identifier" } + body<ChallengeSolve>() + } + response { + code(HttpStatusCode.NoContent) { description = "Challenge confirmed successfully" } + code(HttpStatusCode.NotFound) { description = "Challenge not found or expired" } + code(HttpStatusCode.Conflict) { description = "Incorrect TAN code" } + } + }) { val uuid = call.uuidPath("CHALLENGE_ID") val req = call.receive<ChallengeSolve>() val code = req.tan.removePrefix("T-") @@ -853,7 +1282,20 @@ private fun Routing.coreBankTanApi(db: Database, cfg: BankConfig) { private fun Routing.coreBankConversionApi(db: Database, cfg: BankConfig) = conditional(cfg.allowConversion) { authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat) { - post("/conversion-rate-classes") { + post("/conversion-rate-classes", { + operationId = "createConversionRateClass" + description = "Create a conversion rate class" + tags = listOf("Core Bank - Conversion Rate Classes") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + body<ConversionRateClassInput>() + } + response { + code(HttpStatusCode.OK) { description = "Rate class created"; body<ConversionRateClassResponse>() } + code(HttpStatusCode.Conflict) { description = "Name already in use" } + } + }) { val req = call.receive<ConversionRateClassInput>() cfg.checkCurrency(req) when (val res = db.conversion.createClass(req)) { @@ -865,7 +1307,22 @@ private fun Routing.coreBankConversionApi(db: Database, cfg: BankConfig) = condi } } - patch("/conversion-rate-classes/{CLASS_ID}") { + patch("/conversion-rate-classes/{CLASS_ID}", { + operationId = "updateConversionRateClass" + description = "Update a conversion rate class" + tags = listOf("Core Bank - Conversion Rate Classes") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<Long>("CLASS_ID") { description = "Conversion rate class ID" } + body<ConversionRateClassInput>() + } + response { + code(HttpStatusCode.NoContent) { description = "Rate class updated" } + code(HttpStatusCode.NotFound) { description = "Rate class not found" } + code(HttpStatusCode.Conflict) { description = "Name already in use" } + } + }) { val id = call.longPath("CLASS_ID") val req = call.receive<ConversionRateClassInput>() cfg.checkCurrency(req) @@ -881,7 +1338,20 @@ private fun Routing.coreBankConversionApi(db: Database, cfg: BankConfig) = condi ) } } - delete("/conversion-rate-classes/{CLASS_ID}") { + delete("/conversion-rate-classes/{CLASS_ID}", { + operationId = "deleteConversionRateClass" + description = "Delete a conversion rate class" + tags = listOf("Core Bank - Conversion Rate Classes") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<Long>("CLASS_ID") { description = "Conversion rate class ID" } + } + response { + code(HttpStatusCode.NoContent) { description = "Rate class deleted" } + code(HttpStatusCode.NotFound) { description = "Rate class not found" } + } + }) { val id = call.longPath("CLASS_ID") if (db.conversion.deleteClass(id)) { call.respond(HttpStatusCode.NoContent) @@ -894,7 +1364,20 @@ private fun Routing.coreBankConversionApi(db: Database, cfg: BankConfig) = condi } } authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat) { - get("/conversion-rate-classes/{CLASS_ID}") { + get("/conversion-rate-classes/{CLASS_ID}", { + operationId = "getConversionRateClass" + description = "Get a conversion rate class" + tags = listOf("Core Bank - Conversion Rate Classes") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<Long>("CLASS_ID") { description = "Conversion rate class ID" } + } + response { + code(HttpStatusCode.OK) { description = "Rate class details"; body<ConversionRateClass>() } + code(HttpStatusCode.NotFound) { description = "Rate class not found" } + } + }) { val id = call.longPath("CLASS_ID") val cashout = db.conversion.getClass(id) ?: throw notFound( "Conversion rate class $id not found", @@ -902,7 +1385,31 @@ private fun Routing.coreBankConversionApi(db: Database, cfg: BankConfig) = condi ) call.respond(cashout) } - get("/conversion-rate-classes") { + get("/conversion-rate-classes", { + operationId = "listConversionRateClasses" + description = "List all conversion rate classes" + tags = listOf("Core Bank - Conversion Rate Classes") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + queryParameter<Int>("limit") { + description = "Maximum number of results to return (default: -20, negative means descending)" + required = false + } + queryParameter<Long>("offset") { + description = "Row ID offset for pagination" + required = false + } + queryParameter<String>("filter_name") { + description = "Filter rate classes by name substring" + required = false + } + } + response { + code(HttpStatusCode.OK) { description = "List of rate classes"; body<ConversionRateClasses>() } + code(HttpStatusCode.NoContent) { description = "No rate classes found" } + } + }) { val params = ClassParams.extract(call.request.queryParameters) val page = db.conversion.pageClass(params) diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/ObservabilityApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/ObservabilityApi.kt @@ -24,6 +24,7 @@ import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* +import io.github.smiley4.ktoropenapi.* import io.ktor.util.pipeline.* import io.prometheus.metrics.core.metrics.* import io.prometheus.metrics.model.registry.PrometheusRegistry @@ -54,11 +55,27 @@ object Metrics { } fun Routing.observabilityApi(db: Database, cfg: BankConfig) { - get("/taler-observability/config") { + get("/taler-observability/config", { + operationId = "getObservabilityConfig" + description = "Get observability configuration" + tags = listOf("Observability") + response { + code(HttpStatusCode.OK) { description = "Observability configuration"; body<TalerObservabilityConfig>() } + } + }) { call.respond(TalerObservabilityConfig()) } authAdmin(db, cfg.pwCrypto, TokenLogicalScope.observability, cfg.basicAuthCompat) { - get("/taler-observability/metrics") { + get("/taler-observability/metrics", { + operationId = "getMetrics" + description = "Get Prometheus metrics" + tags = listOf("Observability") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + response { + code(HttpStatusCode.OK) { description = "Prometheus metrics in text format" } + } + }) { val snapshot = PrometheusRegistry.defaultRegistry.scrape() val outputStream = ByteArrayOutputStream() ExpositionFormats.init().getPrometheusTextFormatWriter().write(outputStream, snapshot) diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/PreparedTransferApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/PreparedTransferApi.kt @@ -23,6 +23,8 @@ import io.ktor.http.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* +import io.github.smiley4.ktoropenapi.* +import io.ktor.util.pipeline.* import tech.libeufin.bank.* import tech.libeufin.bank.auth.pathUsername import tech.libeufin.bank.db.Database @@ -33,7 +35,17 @@ import java.time.Instant import java.time.Duration fun Routing.preparedTransferApi(db: Database, cfg: BankConfig) { - get("/accounts/{USERNAME}/taler-prepared-transfer/config") { + get("/accounts/{USERNAME}/taler-prepared-transfer/config", { + operationId = "getPreparedTransferConfig" + description = "Get the configuration of the prepared transfer API" + tags = listOf("Prepared Transfer") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + } + response { + code(HttpStatusCode.OK) { description = "Configuration of the prepared transfer API"; body<PreparedTransferConfig>() } + } + }) { call.respond( PreparedTransferConfig( currency = cfg.regionalCurrency, @@ -41,7 +53,19 @@ fun Routing.preparedTransferApi(db: Database, cfg: BankConfig) { ) ) } - post("/accounts/{USERNAME}/taler-prepared-transfer/registration") { + post("/accounts/{USERNAME}/taler-prepared-transfer/registration", { + operationId = "registerTransfer" + description = "Register a prepared transfer" + tags = listOf("Prepared Transfer") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + body<SubjectRequest>() + } + response { + code(HttpStatusCode.OK) { description = "Registration successful"; body<SubjectResult>() } + code(HttpStatusCode.Conflict) { description = "Reserve pub or subject derivation already used" } + } + }) { val username = call.pathUsername val req = call.receive<SubjectRequest>(); cfg.checkRegionalCurrency(req.credit_amount) @@ -82,7 +106,19 @@ fun Routing.preparedTransferApi(db: Database, cfg: BankConfig) { } } } - post("/accounts/{USERNAME}/taler-prepared-transfer/unregistration") { + post("/accounts/{USERNAME}/taler-prepared-transfer/unregistration", { + operationId = "unregisterTransfer" + description = "Unregister a prepared subject" + tags = listOf("Prepared Transfer") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + } + response { + code(HttpStatusCode.NoContent) { description = "Successfully unregistered" } + code(HttpStatusCode.NotFound) { description = "Prepared transfer not found" } + code(HttpStatusCode.Conflict) { description = "Invalid signature or timestamp too old" } + } + }) { val req = call.receive<Unregistration>(); val timestamp = Instant.parse(req.timestamp) @@ -98,7 +134,7 @@ fun Routing.preparedTransferApi(db: Database, cfg: BankConfig) { "invalid signature", TalerErrorCode.BANK_BAD_SIGNATURE ) - + if (db.transfer.unregister(req.authorization_pub, Instant.now())) { call.respond(HttpStatusCode.NoContent) } else { diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt @@ -22,6 +22,7 @@ import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.response.* import io.ktor.server.routing.* +import io.github.smiley4.ktoropenapi.* import tech.libeufin.bank.BankConfig import tech.libeufin.bank.TokenLogicalScope import tech.libeufin.bank.auth.auth @@ -32,13 +33,48 @@ import tech.libeufin.common.RevenueConfig import tech.libeufin.common.RevenueIncomingHistory fun Routing.revenueApi(db: Database, cfg: BankConfig) { - get("/accounts/{USERNAME}/taler-revenue/config") { + get("/accounts/{USERNAME}/taler-revenue/config", { + operationId = "getRevenueConfig" + description = "Get revenue API configuration" + tags = listOf("Revenue") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + } + response { + code(HttpStatusCode.OK) { description = "Revenue API configuration"; body<RevenueConfig>() } + } + }) { call.respond(RevenueConfig( currency = cfg.regionalCurrency )) } auth(db, cfg.pwCrypto, TokenLogicalScope.revenue, cfg.basicAuthCompat) { - get("/accounts/{USERNAME}/taler-revenue/history") { + get("/accounts/{USERNAME}/taler-revenue/history", { + operationId = "getRevenueHistory" + description = "Get revenue history for an account" + tags = listOf("Revenue") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + queryParameter<Int>("limit") { + description = "Maximum number of results to return (default: -20, negative means descending)" + required = false + } + queryParameter<Long>("offset") { + description = "Row ID offset for pagination" + required = false + } + queryParameter<Long>("timeout_ms") { + description = "Timeout for long polling in milliseconds (default: 0, no long polling)" + required = false + } + } + response { + code(HttpStatusCode.OK) { description = "Revenue history"; body<RevenueIncomingHistory>() } + code(HttpStatusCode.NoContent) { description = "No revenue history found" } + } + }) { val params = HistoryParams.extract(call.request.queryParameters) val bankAccount = call.bankInfo(db) val items = db.transaction.revenueHistory(params, bankAccount.bankAccountId) diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt @@ -26,6 +26,7 @@ import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* +import io.github.smiley4.ktoropenapi.* import tech.libeufin.bank.* import tech.libeufin.bank.auth.auth import tech.libeufin.bank.auth.authAdmin @@ -39,7 +40,17 @@ import java.time.Instant fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { - get("/accounts/{USERNAME}/taler-wire-gateway/config") { + get("/accounts/{USERNAME}/taler-wire-gateway/config", { + operationId = "getWireGatewayConfig" + description = "Get wire gateway configuration" + tags = listOf("Wire Gateway") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + } + response { + code(HttpStatusCode.OK) { description = "Wire gateway configuration"; body<WireGatewayConfig>() } + } + }) { call.respond( WireGatewayConfig( currency = cfg.regionalCurrency, @@ -48,7 +59,21 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { ) } auth(db, cfg.pwCrypto, TokenLogicalScope.readwrite_wiregateway, cfg.basicAuthCompat) { - post("/accounts/{USERNAME}/taler-wire-gateway/transfer") { + post("/accounts/{USERNAME}/taler-wire-gateway/transfer", { + operationId = "createWireGatewayTransfer" + description = "Initiate a wire transfer from an exchange account" + tags = listOf("Wire Gateway") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + body<TransferRequest>() + } + response { + code(HttpStatusCode.OK) { description = "Transfer initiated"; body<TransferResponse>() } + code(HttpStatusCode.Conflict) { description = "Transfer conflict" } + } + }) { val req = call.receive<TransferRequest>() cfg.checkRegionalCurrency(req.amount) val res = db.exchange.transfer( @@ -113,13 +138,88 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { this.respond(reduce(items, bankAccount.payto)) } } - get("/accounts/{USERNAME}/taler-wire-gateway/history/incoming") { + get("/accounts/{USERNAME}/taler-wire-gateway/history/incoming", { + operationId = "getIncomingHistory" + description = "Get incoming wire transfer history" + tags = listOf("Wire Gateway") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + queryParameter<Int>("limit") { + description = "Maximum number of results to return (default: -20, negative means descending)" + required = false + } + queryParameter<Long>("offset") { + description = "Row ID offset for pagination" + required = false + } + queryParameter<Long>("timeout_ms") { + description = "Timeout for long polling in milliseconds (default: 0, no long polling)" + required = false + } + } + response { + code(HttpStatusCode.OK) { description = "Incoming transfer history"; body<IncomingHistory>() } + code(HttpStatusCode.NoContent) { description = "No incoming transfers found" } + } + }) { call.historyEndpoint(::IncomingHistory, ExchangeDAO::incomingHistory) } - get("/accounts/{USERNAME}/taler-wire-gateway/history/outgoing") { + get("/accounts/{USERNAME}/taler-wire-gateway/history/outgoing", { + operationId = "getOutgoingHistory" + description = "Get outgoing wire transfer history" + tags = listOf("Wire Gateway") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + queryParameter<Int>("limit") { + description = "Maximum number of results to return (default: -20, negative means descending)" + required = false + } + queryParameter<Long>("offset") { + description = "Row ID offset for pagination" + required = false + } + queryParameter<Long>("timeout_ms") { + description = "Timeout for long polling in milliseconds (default: 0, no long polling)" + required = false + } + } + response { + code(HttpStatusCode.OK) { description = "Outgoing transfer history"; body<OutgoingHistory>() } + code(HttpStatusCode.NoContent) { description = "No outgoing transfers found" } + } + }) { call.historyEndpoint(::OutgoingHistory, ExchangeDAO::outgoingHistory) } - get("/accounts/{USERNAME}/taler-wire-gateway/transfers") { + get("/accounts/{USERNAME}/taler-wire-gateway/transfers", { + operationId = "listWireGatewayTransfers" + description = "List wire gateway transfers" + tags = listOf("Wire Gateway") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + queryParameter<Int>("limit") { + description = "Maximum number of results to return (default: -20, negative means descending)" + required = false + } + queryParameter<Long>("offset") { + description = "Row ID offset for pagination" + required = false + } + queryParameter<String>("status") { + description = "Filter by transfer status (pending, transient_failure, permanent_failure, success)" + required = false + } + } + response { + code(HttpStatusCode.OK) { description = "List of transfers"; body<TransferList>() } + code(HttpStatusCode.NoContent) { description = "No transfers found" } + } + }) { val params = TransferParams.extract(call.request.queryParameters) val bankAccount = call.bankInfo(db) if (!bankAccount.isTalerExchange) @@ -137,7 +237,21 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { } } } - get("/accounts/{USERNAME}/taler-wire-gateway/transfers/{ROW_ID}") { + get("/accounts/{USERNAME}/taler-wire-gateway/transfers/{ROW_ID}", { + operationId = "getWireGatewayTransfer" + description = "Get a specific wire gateway transfer" + tags = listOf("Wire Gateway") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + pathParameter<Long>("ROW_ID") { description = "Transfer row ID" } + } + response { + code(HttpStatusCode.OK) { description = "Transfer details"; body<TransferStatus>() } + code(HttpStatusCode.NotFound) { description = "Transfer not found" } + } + }) { val bankAccount = call.bankInfo(db) if (!bankAccount.isTalerExchange) throw notExchange(call.pathUsername) @@ -149,7 +263,24 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { ) call.respond(transfer) } - get("/accounts/{USERNAME}/taler-wire-gateway/account/check") { + get("/accounts/{USERNAME}/taler-wire-gateway/account/check", { + operationId = "checkAccount" + description = "Check if an account exists" + tags = listOf("Wire Gateway") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + queryParameter<String>("account") { + description = "Payto URI of the account to check (required)" + required = true + } + } + response { + code(HttpStatusCode.OK) { description = "Account information"; body<AccountInfo>() } + code(HttpStatusCode.NotFound) { description = "Account not found" } + } + }) { val bankAccount = call.bankInfo(db) if (!bankAccount.isTalerExchange) throw notExchange(call.pathUsername) @@ -216,7 +347,21 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { ) } } - post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming") { + post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming", { + operationId = "addIncoming" + description = "Add an incoming wire transfer (admin)" + tags = listOf("Wire Gateway") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + body<AddIncomingRequest>() + } + response { + code(HttpStatusCode.OK) { description = "Incoming transfer added"; body<AddIncomingResponse>() } + code(HttpStatusCode.Conflict) { description = "Transfer conflict" } + } + }) { val req = call.receive<AddIncomingRequest>() call.addIncoming( amount = req.amount, @@ -225,7 +370,21 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { metadata = IncomingSubject.Reserve(req.reserve_pub) ) } - post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-kycauth") { + post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-kycauth", { + operationId = "addKycauth" + description = "Add an incoming KYC auth wire transfer (admin)" + tags = listOf("Wire Gateway") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<String>("USERNAME") { description = "Account username" } + body<AddKycauthRequest>() + } + response { + code(HttpStatusCode.OK) { description = "KYC auth transfer added"; body<AddIncomingResponse>() } + code(HttpStatusCode.Conflict) { description = "Transfer conflict" } + } + }) { val req = call.receive<AddKycauthRequest>() call.addIncoming( amount = req.amount, diff --git a/libeufin-bank/src/test/kotlin/OpenApiTest.kt b/libeufin-bank/src/test/kotlin/OpenApiTest.kt @@ -0,0 +1,59 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2026 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 io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.testing.* +import org.junit.Test +import sun.misc.Unsafe +import tech.libeufin.bank.bankConfig +import tech.libeufin.bank.corebankWebApp +import tech.libeufin.bank.db.Database +import tech.libeufin.common.* +import java.io.File +import kotlin.io.path.Path +import kotlin.test.* + +class OpenApiTest { + private fun fakeDatabase(): Database { + val field = Unsafe::class.java.getDeclaredField("theUnsafe") + field.isAccessible = true + val unsafe = field.get(null) as Unsafe + return unsafe.allocateInstance(Database::class.java) as Database + } + + @Test + fun generateSpec() { + val cfg = bankConfig(Path("conf/test.conf")) + testApplication { + application { + corebankWebApp(fakeDatabase(), cfg, serveSpec = true) + } + val resp = client.get("/openapi.yaml") + resp.assertOk() + val spec = resp.bodyAsText() + assertTrue(spec.contains("openapi: 3.1.0"), "Response should be a valid OpenAPI spec") + assertTrue(spec.contains("LibEuFin Bank API"), "Spec should contain the API title") + val outFile = File("build/openapi.yaml") + outFile.writeText(spec) + println("OpenAPI spec written to ${outFile.absolutePath}") + } + } +} diff --git a/libeufin-common/build.gradle b/libeufin-common/build.gradle @@ -67,7 +67,11 @@ dependencies { implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") implementation("io.ktor:ktor-server-test-host:$ktor_version") implementation("io.ktor:ktor-server-call-id:$ktor_version") - + + // OpenAPI spec generation + implementation("io.github.smiley4:ktor-openapi:5.6.0") + implementation("io.github.smiley4:schema-kenerator-core:2.6.0") + implementation("com.github.ajalt.clikt:clikt:$clikt_version") implementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") diff --git a/libeufin-common/src/main/kotlin/TalerCommon.kt b/libeufin-common/src/main/kotlin/TalerCommon.kt @@ -30,6 +30,7 @@ import java.time.Duration import java.time.temporal.ChronoUnit import java.util.concurrent.TimeUnit import org.bouncycastle.math.ec.rfc8032.Ed25519 +import io.github.smiley4.schemakenerator.core.annotations.Description sealed class CommonError(msg: String) : Exception(msg) { class AmountFormat(msg: String) : CommonError(msg) @@ -41,6 +42,7 @@ sealed class CommonError(msg: String) : Exception(msg) { * Internal representation of relative times. The * "forever" case is represented with Long.MAX_VALUE. */ +@Description("Relative time duration, serialized as microseconds or 'forever'") @JvmInline @Serializable(with = RelativeTime.Serializer::class) value class RelativeTime(val duration: Duration) { @@ -92,6 +94,7 @@ value class RelativeTime(val duration: Duration) { } /** Timestamp containing the number of seconds since epoch */ +@Description("Timestamp as seconds since Unix epoch, or 'never'") @JvmInline @Serializable(with = TalerTimestamp.Serializer::class) value class TalerTimestamp constructor(val instant: Instant) { @@ -142,6 +145,7 @@ value class TalerTimestamp constructor(val instant: Instant) { } } +@Description("Base URL string ending with a trailing slash") @JvmInline @Serializable(with = BaseURL.Serializer::class) value class BaseURL private constructor(val url: Url) { @@ -623,6 +627,7 @@ class Base32Crockford16B { } /** 32-byte Crockford's Base32 encoded data */ +@Description("32-byte Crockford Base32 encoded data") @Serializable(with = Base32Crockford32B.Serializer::class) class Base32Crockford32B { private var encoded: String? = null @@ -688,6 +693,7 @@ class Base32Crockford32B { } /** 64-byte Crockford's Base32 encoded data */ +@Description("64-byte Crockford Base32 encoded data") @Serializable(with = Base32Crockford64B.Serializer::class) class Base32Crockford64B { private var encoded: String? = null diff --git a/libeufin-common/src/main/kotlin/TalerMessage.kt b/libeufin-common/src/main/kotlin/TalerMessage.kt @@ -19,6 +19,7 @@ package tech.libeufin.common +import io.github.smiley4.schemakenerator.core.annotations.Description import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -28,6 +29,7 @@ enum class IncomingType { map } +@Description("State of a wire transfer") enum class TransferStatusState { pending, transient_failure, @@ -37,22 +39,34 @@ enum class TransferStatusState { /** Response GET /taler-wire-gateway/config */ @Serializable +@Description("Wire gateway configuration response") data class WireGatewayConfig( + @Description("Currency supported by the gateway") val currency: String, + @Description("Whether account check is supported") val support_account_check: Boolean ) { + @Description("API name identifier") val name: String = "taler-wire-gateway" + @Description("API version string") val version: String = WIRE_GATEWAY_API_VERSION } /** Request POST /taler-wire-gateway/transfer */ @Serializable +@Description("Wire transfer request") data class TransferRequest( + @Description("Unique identifier for this request") val request_uid: HashCode, + @Description("Amount to transfer") val amount: TalerAmount, + @Description("Base URL of the exchange") val exchange_base_url: BaseURL, + @Description("Wire transfer identifier") val wtid: ShortHashCode, + @Description("Payto URI of the credit account") val credit_account: Payto, + @Description("Optional transfer metadata") val metadata: String? = null, ) { init { @@ -67,60 +81,92 @@ data class TransferRequest( /** Response POST /taler-wire-gateway/transfer */ @Serializable +@Description("Wire transfer response") data class TransferResponse( + @Description("Timestamp of the transfer") val timestamp: TalerTimestamp, + @Description("Database row identifier") val row_id: Long ) /** Request GET /taler-wire-gateway/transfers */ @Serializable +@Description("List of wire transfers") data class TransferList( + @Description("List of transfer statuses") val transfers: List<TransferListStatus>, + @Description("Payto URI of the debit account") val debit_account: String ) @Serializable +@Description("Transfer status in a list response") data class TransferListStatus( + @Description("Database row identifier") val row_id: Long, + @Description("Current transfer status") val status: TransferStatusState, + @Description("Transfer amount") val amount: TalerAmount, + @Description("Payto URI of the credit account") val credit_account: String, + @Description("Timestamp of the transfer") val timestamp: TalerTimestamp ) /** Request GET /taler-wire-gateway/transfers/{ROW_iD} */ @Serializable +@Description("Detailed status of a single transfer") data class TransferStatus( + @Description("Current transfer status") val status: TransferStatusState, + @Description("Optional status message") val status_msg: String? = null, + @Description("Transfer amount") val amount: TalerAmount, + @Description("URL of the originating exchange") val origin_exchange_url: String, + @Description("Optional transfer metadata") val metadata: String? = null, + @Description("Wire transfer identifier") val wtid: ShortHashCode, + @Description("Payto URI of the credit account") val credit_account: String, + @Description("Timestamp of the transfer") val timestamp: TalerTimestamp ) /** Request POST /taler-wire-gateway/admin/add-incoming */ @Serializable +@Description("Request to add an incoming transaction") data class AddIncomingRequest( + @Description("Amount of the incoming transaction") val amount: TalerAmount, + @Description("Reserve public key") val reserve_pub: EddsaPublicKey, + @Description("Payto URI of the debit account") val debit_account: Payto ) /** Response POST /taler-wire-gateway/admin/add-incoming */ @Serializable +@Description("Response to adding an incoming transaction") data class AddIncomingResponse( + @Description("Timestamp of the transaction") val timestamp: TalerTimestamp, + @Description("Database row identifier") val row_id: Long ) /** Request POST /taler-wire-gateway/admin/add-kycauth */ @Serializable +@Description("Request to add a KYC auth transaction") data class AddKycauthRequest( + @Description("Amount of the KYC auth transaction") val amount: TalerAmount, + @Description("Account public key for KYC") val account_pub: EddsaPublicKey, + @Description("Payto URI of the debit account") val debit_account: Payto ) @@ -134,13 +180,17 @@ data class AddMappedRequest( /** Response GET /taler-wire-gateway/history/incoming */ @Serializable +@Description("History of incoming transactions") data class IncomingHistory( + @Description("List of incoming transactions") val incoming_transactions: List<IncomingBankTransaction>, + @Description("Payto URI of the credit account") val credit_account: String ) /** Inner response GET /taler-wire-gateway/history/incoming */ @Serializable +@Description("Incoming bank transaction details") sealed interface IncomingBankTransaction { val row_id: Long val date: TalerTimestamp @@ -151,103 +201,158 @@ sealed interface IncomingBankTransaction { @Serializable @SerialName("KYCAUTH") +@Description("Incoming KYC authentication transaction") data class IncomingKycAuthTransaction( + @Description("Database row identifier") override val row_id: Long, + @Description("Timestamp of the transaction") override val date: TalerTimestamp, + @Description("Transaction amount") override val amount: TalerAmount, + @Description("Optional credit fee") override val credit_fee: TalerAmount? = null, + @Description("Payto URI of the debit account") override val debit_account: String, + @Description("Account public key for KYC") val account_pub: EddsaPublicKey, + @Description("Optional authorization public key") val authorization_pub: EddsaPublicKey? = null, + @Description("Optional authorization signature") val authorization_sig: EddsaSignature? = null, ) : IncomingBankTransaction @Serializable @SerialName("RESERVE") +@Description("Incoming reserve transaction") data class IncomingReserveTransaction( + @Description("Database row identifier") override val row_id: Long, + @Description("Timestamp of the transaction") override val date: TalerTimestamp, + @Description("Transaction amount") override val amount: TalerAmount, + @Description("Optional credit fee") override val credit_fee: TalerAmount? = null, + @Description("Payto URI of the debit account") override val debit_account: String, + @Description("Reserve public key") val reserve_pub: EddsaPublicKey, + @Description("Optional authorization public key") val authorization_pub: EddsaPublicKey? = null, + @Description("Optional authorization signature") val authorization_sig: EddsaSignature? = null, ) : IncomingBankTransaction @Serializable @SerialName("WAD") +@Description("Incoming WAD transaction") data class IncomingWadTransaction( + @Description("Database row identifier") override val row_id: Long, + @Description("Timestamp of the transaction") override val date: TalerTimestamp, + @Description("Transaction amount") override val amount: TalerAmount, + @Description("Optional credit fee") override val credit_fee: TalerAmount? = null, + @Description("Payto URI of the debit account") override val debit_account: String, + @Description("URL of the originating exchange") val origin_exchange_url: String, + @Description("WAD identifier") val wad_id: String // TODO 24 bytes Base32 ) : IncomingBankTransaction /** Response GET /taler-wire-gateway/history/outgoing */ @Serializable +@Description("History of outgoing transactions") data class OutgoingHistory( + @Description("List of outgoing transactions") val outgoing_transactions: List<OutgoingTransaction>, + @Description("Payto URI of the debit account") val debit_account: String ) /** Inner response GET /taler-wire-gateway/history/outgoing */ @Serializable +@Description("Single outgoing transaction details") data class OutgoingTransaction( + @Description("Database row identifier") val row_id: Long, // DB row ID of the payment. + @Description("Timestamp of the transaction") val date: TalerTimestamp, + @Description("Transaction amount") val amount: TalerAmount, + @Description("Payto URI of the credit account") val credit_account: String, + @Description("Wire transfer identifier") val wtid: ShortHashCode, + @Description("Base URL of the exchange") val exchange_base_url: String, + @Description("Optional transfer metadata") val metadata: String? = null, + @Description("Optional debit fee") val debit_fee: TalerAmount? = null ) /** Response GET /taler-wire-gateway/account/check */ @Serializable +@Description("Account information response") class AccountInfo() /** Response GET /taler-prepared-transfer/config */ @Serializable +@Description("Prepared transfer configuration") data class PreparedTransferConfig( + @Description("Currency supporte") val currency: String, + @Description("List of supported subject formats") val supported_formats: List<SubjectFormat> ) { + @Description("API name identifier") val name: String = "taler-prepared-transfer" + @Description("API version string") val version: String = WIRE_TRANSFER_API_VERSION } /** Inner response GET /taler-prepared-transfer/registration */ @Serializable +@Description("Transfer subject information") sealed interface TransferSubject { @Serializable @SerialName("SIMPLE") + @Description("Simple text transfer subject") data class Simple( + @Description("Plain text transfer subject") val subject: String, + @Description("Credit amount for the transfer") val credit_amount: TalerAmount ) : TransferSubject @Serializable @SerialName("URI") + @Description("URI-based transfer subject") data class Uri( + @Description("Taler URI for the transfer") val uri: String, + @Description("Credit amount for the transfer") val credit_amount: TalerAmount ) : TransferSubject @Serializable @SerialName("CH_QR_BILL") + @Description("Swiss QR bill transfer subject") data class QrBill( + @Description("QR reference number for the bill") val qr_reference_number: String, + @Description("Credit amount for the transfer") val credit_amount: TalerAmount, ) : TransferSubject } @Serializable +@Description("Supported transfer subject format") enum class SubjectFormat { SIMPLE, URI, @@ -255,70 +360,104 @@ enum class SubjectFormat { } @Serializable +@Description("Public key algorithm") enum class PublicKeyAlg { EdDSA } @Serializable +@Description("Type of wire transfer") enum class TransferType { reserve, kyc } @Serializable +@Description("Request to generate a transfer subject") data class SubjectRequest( + @Description("Credit amount for the transfer") val credit_amount: TalerAmount, + @Description("Type of transfer") val type: TransferType, + @Description("Public key algorithm") val alg: PublicKeyAlg, + @Description("Account public key") val account_pub: EddsaPublicKey, + @Description("Authorization public key") val authorization_pub: EddsaPublicKey, + @Description("Authorization signature") val authorization_sig: EddsaSignature, + @Description("Whether subject is recurrent") val recurrent: Boolean ) @Serializable +@Description("Result of subject generation") data class SubjectResult( + @Description("List of generated transfer subjects") val subjects: List<TransferSubject>, + @Description("Expiration timestamp of the subjects") val expiration: TalerTimestamp ) @Serializable +@Description("Request to unregister a subject") data class Unregistration( + @Description("Timestamp of the unregistration") val timestamp: String, + @Description("Authorization public key") val authorization_pub: EddsaPublicKey, + @Description("Authorization signature") val authorization_sig: EddsaSignature ) /** Response GET /taler-revenue/config */ @Serializable +@Description("Revenue API configuration response") data class RevenueConfig( + @Description("Currency supported by the API") val currency: String ) { + @Description("API name identifier") val name: String = "taler-revenue" + @Description("API version string") val version: String = REVENUE_API_VERSION } /** Request GET /taler-revenue/history */ @Serializable +@Description("History of revenue incoming transactions") data class RevenueIncomingHistory( + @Description("List of incoming revenue transactions") val incoming_transactions: List<RevenueIncomingBankTransaction>, + @Description("Payto URI of the credit account") val credit_account: String ) /** Inner request GET /taler-revenue/history */ @Serializable +@Description("Single revenue incoming bank transaction") data class RevenueIncomingBankTransaction( + @Description("Database row identifier") val row_id: Long, + @Description("Timestamp of the transaction") val date: TalerTimestamp, + @Description("Transaction amount") val amount: TalerAmount, + @Description("Optional credit fee") val credit_fee: TalerAmount? = null, + @Description("Payto URI of the debit account") val debit_account: String, + @Description("Transaction subject line") val subject: String ) /** Response GET /taler-observability/config */ @Serializable +@Description("Observability API configuration response") class TalerObservabilityConfig() { + @Description("API name identifier") val name: String = "taler-observability" + @Description("API version string") val version: String = OBSERVABILITY_API_VERSION } diff --git a/libeufin-common/src/main/kotlin/api/route.kt b/libeufin-common/src/main/kotlin/api/route.kt @@ -27,11 +27,15 @@ fun Route.intercept(name: String, build: Route.() -> Unit, lambda: suspend Appli val plugin = createRouteScopedPlugin(name) { onCall { call -> call.lambda() } } - val subRoute = createChild(object : RouteSelector() { - override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = - RouteSelectorEvaluation.Transparent - }) + val subRoute = createChild(TransparentRouteSelector()) subRoute.install(plugin) subRoute.build() return subRoute -} -\ No newline at end of file +} + +private class TransparentRouteSelector : RouteSelector() { + override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + RouteSelectorEvaluation.Transparent + + override fun toString(): String = "" +} diff --git a/libeufin-common/src/main/kotlin/api/server.kt b/libeufin-common/src/main/kotlin/api/server.kt @@ -19,6 +19,10 @@ package tech.libeufin.common.api +import io.github.smiley4.ktoropenapi.OpenApi +import io.github.smiley4.ktoropenapi.OpenApiPlugin +import io.github.smiley4.ktoropenapi.config.OpenApiPluginConfig +import io.github.smiley4.ktoropenapi.config.OutputFormat import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* @@ -147,8 +151,41 @@ fun talerPlugin(logger: Logger): ApplicationPlugin<Unit> { } } +data class OpenApiInfo( + val title: String, + val version: String, + val description: String? = null +) + /** Set up web server handlers for a Taler API */ -fun Application.talerApi(logger: Logger, routes: Routing.() -> Unit) { +fun Application.talerApi(logger: Logger, openApiInfo: OpenApiInfo? = null, serveSpec: Boolean = false, routes: Routing.() -> Unit) { + if (openApiInfo != null) { + install(OpenApi) { + info { + title = openApiInfo.title + version = openApiInfo.version + description = openApiInfo.description + } + server { + url = "/" + description = "Same host" + } + security { + securityScheme("bearerAuth") { + type = io.github.smiley4.ktoropenapi.config.AuthType.HTTP + scheme = io.github.smiley4.ktoropenapi.config.AuthScheme.BEARER + bearerFormat = "token" + description = "Bearer tokens minted by libeufin-bank /accounts/{USERNAME}/token" + } + securityScheme("basicAuth") { + type = io.github.smiley4.ktoropenapi.config.AuthType.HTTP + scheme = io.github.smiley4.ktoropenapi.config.AuthScheme.BASIC + description = "HTTP Basic authentication, supported only on compatibility-enabled endpoints" + } + } + outputFormat = OutputFormat.YAML + } + } install(CallId) { generate(10, "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") verify { true } @@ -282,7 +319,96 @@ fun Application.talerApi(logger: Logger, routes: Routing.() -> Unit) { } } - routing { routes() } + routing { + routes() + if (serveSpec) { + get("openapi.yaml") { + call.respondText( + getOpenApiSpec(), + ContentType.parse("application/yaml") + ) + } + } + } +} + +/** Get the generated OpenAPI spec as a string (available after application start) */ +fun getOpenApiSpec(): String = + normalizeOpenApiSpec(OpenApiPlugin.getOpenApiSpec(OpenApiPluginConfig.DEFAULT_SPEC_ID)) + +private fun normalizeOpenApiSpec(spec: String): String { + val lines = spec.lines() + val schemaNames = mutableListOf<String>() + var inSchemas = false + + for (line in lines) { + when { + !inSchemas && line == " schemas:" -> inSchemas = true + inSchemas && line.startsWith(" ") && !line.startsWith(" ") -> break + inSchemas && line.startsWith(" ") && !line.startsWith(" ") && line.endsWith(":") -> { + schemaNames += line.removePrefix(" ").removeSuffix(":") + } + } + } + if (schemaNames.isEmpty()) return spec + + val renamed = LinkedHashMap<String, String>() + val used = mutableSetOf<String>() + + fun sanitizeSchemaName(name: String): String { + val cleaned = name + .replace("tech.libeufin.bank.", "") + .replace("tech.libeufin.common.", "") + .replace("tech.libeufin.nexus.", "") + .replace("tech.libeufin.ebics.", "") + .replace("kotlin.collections.", "") + .replace("kotlin.", "") + .replace("java.net.", "") + .replace(Regex("[^A-Za-z0-9]+"), "_") + .trim('_') + return cleaned.ifEmpty { "Schema" } + } + + for (original in schemaNames) { + val base = sanitizeSchemaName(original) + var candidate = base + var i = 2 + while (!used.add(candidate)) { + candidate = "${base}_$i" + i += 1 + } + renamed[original] = candidate + } + + val normalizedLines = buildList { + var insideSchemas = false + for (line in lines) { + when { + !insideSchemas && line == " schemas:" -> { + insideSchemas = true + add(line) + } + insideSchemas && line.startsWith(" ") && !line.startsWith(" ") -> { + insideSchemas = false + add(line) + } + insideSchemas && line.startsWith(" ") && !line.startsWith(" ") && line.endsWith(":") -> { + val original = line.removePrefix(" ").removeSuffix(":") + add(" ${renamed.getValue(original)}:") + } + else -> add(line) + } + } + } + + var normalized = normalizedLines.joinToString("\n") + for ((original, shortName) in renamed.entries.sortedByDescending { it.key.length }) { + normalized = normalized.replace( + "#/components/schemas/$original", + "#/components/schemas/$shortName" + ) + } + return normalized } // Dirty local variable to stop the server in test TODO remove this ugly hack @@ -311,4 +437,4 @@ fun serve(cfg: tech.libeufin.common.ServerConfig, logger: Logger, api: Applicati ) engine = server.engine server.start(wait = true) -} -\ No newline at end of file +} diff --git a/libeufin-nexus/build.gradle b/libeufin-nexus/build.gradle @@ -44,6 +44,10 @@ dependencies { // Serialization implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") + // OpenAPI spec generation + implementation("io.github.smiley4:ktor-openapi:5.6.0") + implementation("io.github.smiley4:schema-kenerator-core:2.6.0") + // Unit testing testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") testImplementation("io.ktor:ktor-server-test-host:$ktor_version") diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -29,6 +29,7 @@ import org.slf4j.Logger import org.slf4j.LoggerFactory import kotlinx.serialization.Serializable import kotlinx.serialization.Contextual +import tech.libeufin.common.api.OpenApiInfo import tech.libeufin.common.api.talerApi import tech.libeufin.common.setupSecurityProperties import tech.libeufin.nexus.api.revenueApi @@ -47,7 +48,15 @@ data class IbanAccountMetadata( val name: String ) -fun Application.nexusApi(db: Database, cfg: NexusConfig) = talerApi(LoggerFactory.getLogger("libeufin-nexus-api")) { +fun Application.nexusApi(db: Database, cfg: NexusConfig, serveSpec: Boolean = false) = talerApi( + LoggerFactory.getLogger("libeufin-nexus-api"), + OpenApiInfo( + title = "LibEuFin Nexus API", + version = "1.4.0", + description = "Taler wire gateway, wire transfer gateway, revenue, and observability APIs for LibEuFin Nexus" + ), + serveSpec = serveSpec +) { wireGatewayApi(db, cfg) preparedTransferAPI(db, cfg) revenueApi(db, cfg) diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/ObservabilityApi.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/ObservabilityApi.kt @@ -24,6 +24,7 @@ import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* +import io.github.smiley4.ktoropenapi.* import io.ktor.util.pipeline.* import io.prometheus.metrics.core.metrics.* import io.prometheus.metrics.model.registry.PrometheusRegistry @@ -169,11 +170,27 @@ object Metrics { } fun Routing.observabilityApi(db: Database, cfg: NexusConfig) = conditional(cfg.observabilityApiCfg) { - get("/taler-observability/config") { + get("/taler-observability/config", { + operationId = "getObservabilityConfig" + description = "Get the configuration of the observability API" + tags = listOf("Observability") + response { + code(HttpStatusCode.OK) { description = "Configuration of the observability API"; body<TalerObservabilityConfig>() } + } + }) { call.respond(TalerObservabilityConfig()) } auth(cfg.observabilityApiCfg) { - get("/taler-observability/metrics") { + get("/taler-observability/metrics", { + operationId = "getMetrics" + description = "Get Prometheus metrics" + tags = listOf("Observability") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + response { + code(HttpStatusCode.OK) { description = "Prometheus text format metrics" } + } + }) { Metrics.sync(db) val snapshot = PrometheusRegistry.defaultRegistry.scrape() val outputStream = ByteArrayOutputStream() diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/PreparedTransferApi.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/PreparedTransferApi.kt @@ -23,6 +23,8 @@ import io.ktor.http.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* +import io.github.smiley4.ktoropenapi.* +import io.ktor.util.pipeline.* import tech.libeufin.common.* import tech.libeufin.common.crypto.CryptoUtil import tech.libeufin.nexus.NexusConfig @@ -32,7 +34,14 @@ import java.time.Instant import java.time.Duration fun Routing.preparedTransferAPI(db: Database, cfg: NexusConfig) = conditional(cfg.wireGatewayApiCfg) { - get("/taler-prepared-transfer/config") { + get("/taler-prepared-transfer/config", { + operationId = "getPreparedTransferConfig" + description = "Get the configuration of the prepared transfer API" + tags = listOf("Prepared Transfer") + response { + code(HttpStatusCode.OK) { description = "Configuration of the prepared transfer API"; body<PreparedTransferConfig>() } + } + }) { call.respond( PreparedTransferConfig( currency = cfg.currency, @@ -40,7 +49,18 @@ fun Routing.preparedTransferAPI(db: Database, cfg: NexusConfig) = conditional(cf ) ) } - post("/taler-prepared-transfer/registration") { + post("/taler-prepared-transfer/registration", { + operationId = "registerTransfer" + description = "Register a prepared transfer" + tags = listOf("Prepared Transfer") + request { + body<SubjectRequest>() + } + response { + code(HttpStatusCode.OK) { description = "Registration successful"; body<SubjectResult>() } + code(HttpStatusCode.Conflict) { description = "Reserve pub or subject derivation already used" } + } + }) { val req = call.receive<SubjectRequest>(); if (!CryptoUtil.checkEdssaSignature(req.account_pub.raw, req.authorization_sig, req.authorization_pub)) @@ -81,7 +101,16 @@ fun Routing.preparedTransferAPI(db: Database, cfg: NexusConfig) = conditional(cf } } } - post("/taler-prepared-transfer/unregistration") { + post("/taler-prepared-transfer/unregistration", { + operationId = "unregisterTransfer" + description = "Unregister a prepared subject" + tags = listOf("Prepared Transfer") + response { + code(HttpStatusCode.NoContent) { description = "Successfully unregistered" } + code(HttpStatusCode.NotFound) { description = "Prepared transfer not found" } + code(HttpStatusCode.Conflict) { description = "Invalid signature or timestamp too old" } + } + }) { val req = call.receive<Unregistration>(); val timestamp = Instant.parse(req.timestamp) diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt @@ -22,6 +22,7 @@ import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.response.* import io.ktor.server.routing.* +import io.github.smiley4.ktoropenapi.* import tech.libeufin.common.HistoryParams import tech.libeufin.common.RevenueConfig import tech.libeufin.common.RevenueIncomingHistory @@ -29,13 +30,38 @@ import tech.libeufin.nexus.NexusConfig import tech.libeufin.nexus.db.Database fun Routing.revenueApi(db: Database, cfg: NexusConfig) = conditional(cfg.revenueApiCfg) { - get("/taler-revenue/config") { + get("/taler-revenue/config", { + operationId = "getRevenueConfig" + description = "Get the configuration of the revenue API" + tags = listOf("Revenue") + response { + code(HttpStatusCode.OK) { description = "Configuration of the revenue API"; body<RevenueConfig>() } + } + }) { call.respond(RevenueConfig( currency = cfg.currency )) } auth(cfg.revenueApiCfg) { - get("/taler-revenue/history") { + get("/taler-revenue/history", { + operationId = "getRevenueHistory" + description = "Get incoming revenue history" + tags = listOf("Revenue") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + queryParameter<Long>("start") { description = "Row ID to start from (legacy alias for offset)"; required = false } + queryParameter<Long>("offset") { description = "Row ID to start from"; required = false } + queryParameter<Int>("delta") { description = "Number of results to return (legacy alias for limit)"; required = false } + queryParameter<Int>("limit") { description = "Number of results to return. Negative for descending order. Max 1024"; required = false } + queryParameter<Long>("long_poll_ms") { description = "Long-polling timeout in milliseconds (legacy alias for timeout_ms)"; required = false } + queryParameter<Long>("timeout_ms") { description = "Long-polling timeout in milliseconds. Max 3600000 (1 hour)"; required = false } + } + response { + code(HttpStatusCode.OK) { description = "Incoming revenue history"; body<RevenueIncomingHistory>() } + code(HttpStatusCode.NoContent) { description = "No revenue transactions found" } + } + }) { val params = HistoryParams.extract(call.request.queryParameters) val items = db.payment.revenueHistory(params) diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt @@ -24,6 +24,7 @@ import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* +import io.github.smiley4.ktoropenapi.* import io.ktor.util.pipeline.* import tech.libeufin.common.* import tech.libeufin.nexus.NexusConfig @@ -38,7 +39,14 @@ import java.time.Instant fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wireGatewayApiCfg) { - get("/taler-wire-gateway/config") { + get("/taler-wire-gateway/config", { + operationId = "getWireGatewayConfig" + description = "Get the configuration of the wire gateway" + tags = listOf("Wire Gateway") + response { + code(HttpStatusCode.OK) { description = "Configuration of the wire gateway"; body<WireGatewayConfig>() } + } + }) { call.respond( WireGatewayConfig( currency = cfg.currency, @@ -47,7 +55,20 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wir ) } auth(cfg.wireGatewayApiCfg) { - post("/taler-wire-gateway/transfer") { + post("/taler-wire-gateway/transfer", { + operationId = "createTransfer" + description = "Initiate a wire transfer" + tags = listOf("Wire Gateway") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + body<TransferRequest>() + } + response { + code(HttpStatusCode.OK) { description = "Transfer initiated successfully"; body<TransferResponse>() } + code(HttpStatusCode.Conflict) { description = "Request UID or WTID already used" } + } + }) { val req = call.receive<TransferRequest>() cfg.checkCurrency(req.amount) req.credit_account.expectIbanFull() @@ -76,7 +97,24 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wir ) } } - get("/taler-wire-gateway/transfers") { + get("/taler-wire-gateway/transfers", { + operationId = "listTransfers" + description = "List wire transfers" + tags = listOf("Wire Gateway") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + queryParameter<Long>("start") { description = "Row ID to start from (legacy alias for offset)"; required = false } + queryParameter<Long>("offset") { description = "Row ID to start from"; required = false } + queryParameter<Int>("delta") { description = "Number of results to return (legacy alias for limit)"; required = false } + queryParameter<Int>("limit") { description = "Number of results to return. Negative for descending order. Max 1024"; required = false } + queryParameter<String>("status") { description = "Filter by transfer status"; required = false } + } + response { + code(HttpStatusCode.OK) { description = "List of transfers"; body<TransferList>() } + code(HttpStatusCode.NoContent) { description = "No transfers found" } + } + }) { val params = TransferParams.extract(call.request.queryParameters) val items = db.exchange.pageTransfer(params) @@ -86,7 +124,20 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wir call.respond(TransferList(items, cfg.ebics.payto)) } } - get("/taler-wire-gateway/transfers/{ROW_ID}") { + get("/taler-wire-gateway/transfers/{ROW_ID}", { + operationId = "getTransfer" + description = "Get details of a specific wire transfer" + tags = listOf("Wire Gateway") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + pathParameter<Long>("ROW_ID") { description = "Row ID of the transfer" } + } + response { + code(HttpStatusCode.OK) { description = "Transfer details"; body<TransferStatus>() } + code(HttpStatusCode.NotFound) { description = "Transfer not found" } + } + }) { val id = call.longPath("ROW_ID") val transfer = db.exchange.getTransfer(id) ?: throw notFound( "Transfer '$id' not found", @@ -106,13 +157,58 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wir this.respond(reduce(items, cfg.ebics.payto)) } } - get("/taler-wire-gateway/history/incoming") { + get("/taler-wire-gateway/history/incoming", { + operationId = "getIncomingHistory" + description = "Get incoming transaction history" + tags = listOf("Wire Gateway") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + queryParameter<Long>("start") { description = "Row ID to start from (legacy alias for offset)"; required = false } + queryParameter<Long>("offset") { description = "Row ID to start from"; required = false } + queryParameter<Int>("delta") { description = "Number of results to return (legacy alias for limit)"; required = false } + queryParameter<Int>("limit") { description = "Number of results to return. Negative for descending order. Max 1024"; required = false } + queryParameter<Long>("long_poll_ms") { description = "Long-polling timeout in milliseconds (legacy alias for timeout_ms)"; required = false } + queryParameter<Long>("timeout_ms") { description = "Long-polling timeout in milliseconds. Max 3600000 (1 hour)"; required = false } + } + response { + code(HttpStatusCode.OK) { description = "Incoming transaction history"; body<IncomingHistory>() } + code(HttpStatusCode.NoContent) { description = "No incoming transactions found" } + } + }) { call.historyEndpoint(::IncomingHistory, ExchangeDAO::incomingHistory) } - get("/taler-wire-gateway/history/outgoing") { + get("/taler-wire-gateway/history/outgoing", { + operationId = "getOutgoingHistory" + description = "Get outgoing transaction history" + tags = listOf("Wire Gateway") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + queryParameter<Long>("start") { description = "Row ID to start from (legacy alias for offset)"; required = false } + queryParameter<Long>("offset") { description = "Row ID to start from"; required = false } + queryParameter<Int>("delta") { description = "Number of results to return (legacy alias for limit)"; required = false } + queryParameter<Int>("limit") { description = "Number of results to return. Negative for descending order. Max 1024"; required = false } + queryParameter<Long>("long_poll_ms") { description = "Long-polling timeout in milliseconds (legacy alias for timeout_ms)"; required = false } + queryParameter<Long>("timeout_ms") { description = "Long-polling timeout in milliseconds. Max 3600000 (1 hour)"; required = false } + } + response { + code(HttpStatusCode.OK) { description = "Outgoing transaction history"; body<OutgoingHistory>() } + code(HttpStatusCode.NoContent) { description = "No outgoing transactions found" } + } + }) { call.historyEndpoint(::OutgoingHistory, ExchangeDAO::outgoingHistory) } - get("/taler-wire-gateway/account/check") { + get("/taler-wire-gateway/account/check", { + operationId = "checkAccount" + description = "Check account status" + tags = listOf("Wire Gateway") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + response { + code(HttpStatusCode.NotImplemented) { description = "Not implemented" } + } + }) { throw notImplemented() } suspend fun ApplicationCall.addIncoming( @@ -154,7 +250,20 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wir ) } } - post("/taler-wire-gateway/admin/add-incoming") { + post("/taler-wire-gateway/admin/add-incoming", { + operationId = "addIncoming" + description = "Manually add an incoming transaction" + tags = listOf("Wire Gateway") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + body<AddIncomingRequest>() + } + response { + code(HttpStatusCode.OK) { description = "Incoming transaction added"; body<AddIncomingResponse>() } + code(HttpStatusCode.Conflict) { description = "Reserve pub already used" } + } + }) { val req = call.receive<AddIncomingRequest>() call.addIncoming( amount = req.amount, @@ -163,7 +272,20 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wir metadata = IncomingSubject.Reserve(req.reserve_pub) ) } - post("/taler-wire-gateway/admin/add-kycauth") { + post("/taler-wire-gateway/admin/add-kycauth", { + operationId = "addKycauth" + description = "Manually add a KYC auth incoming transaction" + tags = listOf("Wire Gateway") + protected = true + securitySchemeNames("bearerAuth", "basicAuth") + request { + body<AddKycauthRequest>() + } + response { + code(HttpStatusCode.OK) { description = "KYC auth incoming transaction added"; body<AddIncomingResponse>() } + code(HttpStatusCode.Conflict) { description = "Reserve pub already used" } + } + }) { val req = call.receive<AddKycauthRequest>() call.addIncoming( amount = req.amount, diff --git a/libeufin-nexus/src/test/kotlin/OpenApiTest.kt b/libeufin-nexus/src/test/kotlin/OpenApiTest.kt @@ -0,0 +1,59 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2026 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 io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.testing.* +import org.junit.Test +import sun.misc.Unsafe +import tech.libeufin.common.* +import tech.libeufin.nexus.db.Database +import tech.libeufin.nexus.nexusApi +import tech.libeufin.nexus.nexusConfig +import java.io.File +import kotlin.io.path.Path +import kotlin.test.* + +class OpenApiTest { + private fun fakeDatabase(): Database { + val field = Unsafe::class.java.getDeclaredField("theUnsafe") + field.isAccessible = true + val unsafe = field.get(null) as Unsafe + return unsafe.allocateInstance(Database::class.java) as Database + } + + @Test + fun generateSpec() { + val cfg = nexusConfig(Path("conf/test.conf")) + testApplication { + application { + nexusApi(fakeDatabase(), cfg, serveSpec = true) + } + val resp = client.get("/openapi.yaml") + resp.assertOk() + val spec = resp.bodyAsText() + assertTrue(spec.contains("openapi: 3.1.0"), "Response should be a valid OpenAPI spec") + assertTrue(spec.contains("LibEuFin Nexus API"), "Spec should contain the API title") + val outFile = File("build/openapi.yaml") + outFile.writeText(spec) + println("OpenAPI spec written to ${outFile.absolutePath}") + } + } +}