libeufin

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

commit a6350237b5b9905437e4dd9d763b4794f834c926
parent 973c88cc69eb6cf38bb00d59bb6b3234432cd38d
Author: MS <ms@taler.net>
Date:   Thu, 28 Sep 2023 13:58:26 +0200

Time types handling.

Completing serializers for duration and timestamp types
based on java.time. The serialization includes the values
"never" and "forever".

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt | 19+++++++++++--------
Mbank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt | 43++++++++++++++++++++++++++++---------------
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 1-
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mbank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt | 1-
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 5+++++
Mbank/src/test/kotlin/DatabaseTest.kt | 3+--
Mbank/src/test/kotlin/JsonTest.kt | 44++++++++++++++++++++++----------------------
Mbank/src/test/kotlin/LibeuFinApiTest.kt | 26+++++++++++++++++++++++++-
Mutil/src/main/kotlin/Config.kt | 2++
Mutil/src/main/kotlin/DB.kt | 2--
Mutil/src/main/kotlin/Ebics.kt | 2--
Mutil/src/main/kotlin/time.kt | 38+++++++++++++++++++++++++++++++++++---
13 files changed, 218 insertions(+), 73 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt @@ -21,10 +21,11 @@ package tech.libeufin.bank import io.ktor.http.* import io.ktor.server.application.* -import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable +import java.time.Duration +import java.time.Instant +import java.time.temporal.ChronoUnit import java.util.* -import kotlin.reflect.jvm.internal.impl.types.AbstractStubType /** * Allowed lengths for fractional digits in amounts. @@ -38,13 +39,15 @@ enum class FracDigits(howMany: Int) { /** * Timestamp containing the number of seconds since epoch. */ -@Serializable +@Serializable(with = TalerProtocolTimestampSerializer::class) data class TalerProtocolTimestamp( - val t_s: Long, // FIXME (?): not supporting "never" at the moment. + val t_s: Instant, ) { companion object { fun fromMicroseconds(uSec: Long): TalerProtocolTimestamp { - return TalerProtocolTimestamp(uSec / 1000000) + return TalerProtocolTimestamp( + Instant.EPOCH.plus(uSec, ChronoUnit.MICROS) + ) } } } @@ -101,8 +104,9 @@ data class RegisterAccountRequest( * Internal representation of relative times. The * "forever" case is represented with Long.MAX_VALUE. */ +@Serializable(with = RelativeTimeSerializer::class) data class RelativeTime( - val d_us: Long + val d_us: Duration ) /** @@ -112,7 +116,6 @@ data class RelativeTime( @Serializable data class TokenRequest( val scope: TokenScope, - @Contextual val duration: RelativeTime? = null, val refreshable: Boolean = false ) @@ -169,6 +172,7 @@ data class Customer( * maybeCurrency is typically null when the TalerAmount object gets * defined by the Database class. */ +@Serializable(with = TalerAmountSerializer::class) class TalerAmount( val value: Long, val frac: Int, @@ -592,7 +596,6 @@ data class IncomingReserveTransaction( @Serializable data class TransferRequest( val request_uid: String, - @Contextual val amount: TalerAmount, val exchange_base_url: String, val wtid: String, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt @@ -10,6 +10,9 @@ import net.taler.wallet.crypto.Base32Crockford import org.slf4j.Logger import org.slf4j.LoggerFactory import tech.libeufin.util.* +import java.time.Duration +import java.time.Instant +import java.time.temporal.ChronoUnit import java.util.* private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.accountsMgmtHandlers") @@ -50,29 +53,38 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { val tokenBytes = ByteArray(32).apply { Random().nextBytes(this) } - val tokenDurationUs = req.duration?.d_us ?: TOKEN_DEFAULT_DURATION_US + val tokenDuration: Duration = req.duration?.d_us ?: TOKEN_DEFAULT_DURATION + + val creationTime = Instant.now() + val expirationTimestamp = if (tokenDuration == ChronoUnit.FOREVER.duration) { + Instant.MAX + } else { + try { + creationTime.plus(tokenDuration) + } catch (e: Exception) { + logger.error("Could not add token duration to current time: ${e.message}") + throw badRequest("Bad token duration: ${e.message}") + } + } val customerDbRow = customer.dbRowId ?: throw internalServerError( "Could not get customer '${customer.login}' database row ID" ) - val creationTime = getNowUs() - val expirationTimestampUs: Long = creationTime + tokenDurationUs - if (expirationTimestampUs < tokenDurationUs) throw badRequest( - "Token duration caused arithmetic overflow", // FIXME: need dedicate EC (?) - talerErrorCode = TalerErrorCode.TALER_EC_END - ) val token = BearerToken( bankCustomer = customerDbRow, content = tokenBytes, - creationTime = creationTime, - expirationTime = expirationTimestampUs, + creationTime = creationTime.toDbMicros() + ?: throw internalServerError("Could not get micros out of token creationTime Instant."), + expirationTime = expirationTimestamp.toDbMicros() + ?: throw internalServerError("Could not get micros out of token expirationTime Instant."), scope = req.scope, isRefreshable = req.refreshable ) - if (!db.bearerTokenCreate(token)) throw internalServerError("Failed at inserting new token in the database") + if (!db.bearerTokenCreate(token)) + throw internalServerError("Failed at inserting new token in the database") call.respond( TokenSuccessResponse( access_token = Base32Crockford.encode(tokenBytes), expiration = TalerProtocolTimestamp( - t_s = expirationTimestampUs / 1000000L + t_s = expirationTimestamp ) ) ) @@ -295,10 +307,11 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { // Note: 'when' helps not to omit more result codes, should more // be added. when (db.talerWithdrawalConfirm(op.withdrawalUuid, getNowUs())) { - WithdrawalConfirmationResult.BALANCE_INSUFFICIENT -> throw conflict( - "Insufficient funds", TalerErrorCode.TALER_EC_END // FIXME: define EC for this. - ) - + WithdrawalConfirmationResult.BALANCE_INSUFFICIENT -> + throw conflict( + "Insufficient funds", + TalerErrorCode.TALER_EC_END // FIXME: define EC for this. + ) WithdrawalConfirmationResult.OP_NOT_FOUND -> /** * Despite previous checks, the database _still_ did not diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -318,7 +318,6 @@ class Database(private val dbConfig: String, private val bankCurrency: String) { FROM bearer_tokens WHERE content=?; """) - stmt.setBytes(1, token) stmt.executeQuery().use { if (!it.next()) return null diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -51,21 +51,23 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.encodeStructure import kotlinx.serialization.json.* -import kotlinx.serialization.modules.SerializersModule import net.taler.common.errorcodes.TalerErrorCode import org.slf4j.Logger import org.slf4j.LoggerFactory import org.slf4j.event.Level import tech.libeufin.util.* import java.time.Duration +import java.time.Instant +import java.time.temporal.ChronoUnit import java.util.zip.InflaterInputStream import kotlin.system.exitProcess // GLOBALS private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.Main") const val GENERIC_UNDEFINED = -1 // Filler for ECs that don't exist yet. -val TOKEN_DEFAULT_DURATION_US = Duration.ofDays(1L).seconds * 1000000 +val TOKEN_DEFAULT_DURATION: java.time.Duration = Duration.ofDays(1L) /** @@ -115,6 +117,59 @@ data class BankApplicationContext( val spaCaptchaURL: String?, ) + +/** + * This custom (de)serializer interprets the Timestamp JSON + * type of the Taler common API. In particular, it is responsible + * for _serializing_ timestamps, as this datatype is so far + * only used to respond to clients. + */ +object TalerProtocolTimestampSerializer : KSerializer<TalerProtocolTimestamp> { + override fun serialize(encoder: Encoder, value: TalerProtocolTimestamp) { + // Thanks: https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/serializers.md#hand-written-composite-serializer + encoder.encodeStructure(descriptor) { + if (value.t_s == Instant.MAX) { + encodeStringElement(descriptor, 0, "never") + return@encodeStructure + } + val ts = value.t_s.toDbMicros() ?: throw internalServerError("Could not serialize timestamp") + encodeLongElement(descriptor, 0, ts) + } + } + + override fun deserialize(decoder: Decoder): TalerProtocolTimestamp { + val jsonInput = decoder as? JsonDecoder ?: throw internalServerError("TalerProtocolTimestamp had no JsonDecoder") + val json = try { + jsonInput.decodeJsonElement().jsonObject + } catch (e: Exception) { + throw badRequest( + "Did not find a JSON object for TalerProtocolTimestamp: ${e.message}", + TalerErrorCode.TALER_EC_GENERIC_JSON_INVALID + ) + } + val maybeTs = json["t_s"]?.jsonPrimitive ?: throw badRequest("Taler timestamp invalid: t_s field not found") + if (maybeTs.isString) { + if (maybeTs.content != "never") throw badRequest("Only 'never' allowed for t_s as string, but '${maybeTs.content}' was found") + return TalerProtocolTimestamp(t_s = Instant.MAX) + } + val ts: Long = maybeTs.longOrNull + ?: throw badRequest("Could not convert t_s '${maybeTs.content}' to a number") + val instant = try { + Instant.ofEpochSecond(ts) + } catch (e: Exception) { + logger.error("Could not get Instant from t_s: $ts: ${e.message}") + // Bank's fault. API doesn't allow clients to pass this datatype. + throw internalServerError("Could not serialize this t_s: ${ts}") + } + return TalerProtocolTimestamp(instant) + } + + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("TalerProtocolTimestamp") { + element<JsonElement>("t_s") + } +} + /** * This custom (de)serializer interprets the RelativeTime JSON * type. In particular, it is responsible for converting the @@ -122,10 +177,30 @@ data class BankApplicationContext( * is passed as is. */ object RelativeTimeSerializer : KSerializer<RelativeTime> { + /** + * Internal representation to JSON. + */ override fun serialize(encoder: Encoder, value: RelativeTime) { - throw internalServerError("Encoding of RelativeTime not implemented.") // API doesn't require this. + // Thanks: https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/serializers.md#hand-written-composite-serializer + encoder.encodeStructure(descriptor) { + if (value.d_us == ChronoUnit.FOREVER.duration) { + encodeStringElement(descriptor, 0, "forever") + return@encodeStructure + } + val dUs = try { + value.d_us.toNanos() + } catch (e: Exception) { + logger.error(e.message) + // Bank's fault, as each numeric value should be checked before entering the system. + throw internalServerError("Could not convert java.time.Duration to JSON") + } + encodeLongElement(descriptor, 0, dUs / 1000L) + } } + /** + * JSON to internal representation. + */ override fun deserialize(decoder: Decoder): RelativeTime { val jsonInput = decoder as? JsonDecoder ?: throw internalServerError("RelativeTime had no JsonDecoder") val json = try { @@ -139,15 +214,22 @@ object RelativeTimeSerializer : KSerializer<RelativeTime> { val maybeDUs = json["d_us"]?.jsonPrimitive ?: throw badRequest("Relative time invalid: d_us field not found") if (maybeDUs.isString) { if (maybeDUs.content != "forever") throw badRequest("Only 'forever' allowed for d_us as string, but '${maybeDUs.content}' was found") - return RelativeTime(d_us = Long.MAX_VALUE) + return RelativeTime(d_us = ChronoUnit.FOREVER.duration) + } + val dUs: Long = maybeDUs.longOrNull + ?: throw badRequest("Could not convert d_us: '${maybeDUs.content}' to a number") + val duration = try { + Duration.ofNanos(dUs * 1000L) + } catch (e: Exception) { + logger.error("Could not get Duration out of d_us content: ${dUs}. ${e.message}") + throw badRequest("Could not get Duration out of d_us content: ${dUs}") } - val dUs: Long = - maybeDUs.longOrNull ?: throw badRequest("Could not convert d_us: '${maybeDUs.content}' to a number") - return RelativeTime(d_us = dUs) + return RelativeTime(d_us = duration) } override val descriptor: SerialDescriptor = buildClassSerialDescriptor("RelativeTime") { + // JsonElement helps to obtain "union" type Long|String element<JsonElement>("d_us") } } @@ -232,15 +314,6 @@ fun Application.corebankWebApp(db: Database, ctx: BankApplicationContext) { encodeDefaults = true prettyPrint = true ignoreUnknownKeys = true - // Registering custom parser for RelativeTime - serializersModule = SerializersModule { - contextual(RelativeTime::class) { - RelativeTimeSerializer - } - contextual(TalerAmount::class) { - TalerAmountSerializer - } - } }) } install(RequestValidation) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt @@ -27,7 +27,6 @@ import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import net.taler.common.errorcodes.TalerErrorCode -import tech.libeufin.util.getNowUs fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) { get("/taler-wire-gateway/config") { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -30,6 +30,7 @@ import org.slf4j.Logger import org.slf4j.LoggerFactory import tech.libeufin.util.* import java.net.URL +import java.time.Instant import java.util.* const val FRACTION_BASE = 100000000 @@ -438,3 +439,6 @@ fun maybeCreateAdminAccount(db: Database, ctx: BankApplicationContext): Boolean } return true } + +fun getNowUs(): Long = Instant.now().toDbMicros() + ?: throw internalServerError("Could not get micros out of Instant.now()") +\ No newline at end of file diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -20,7 +20,6 @@ import org.junit.Test import tech.libeufin.bank.* import tech.libeufin.util.CryptoUtil -import tech.libeufin.util.getNowUs import java.util.Random import java.util.UUID @@ -136,7 +135,7 @@ class DatabaseTest { val token = BearerToken( bankCustomer = 1L, content = tokenBytes, - creationTime = getNowUs(), // make .toMicro()? implicit? + creationTime = getNowUs(), expirationTime = getNowUs(), scope = TokenScope.readonly ) diff --git a/bank/src/test/kotlin/JsonTest.kt b/bank/src/test/kotlin/JsonTest.kt @@ -1,12 +1,11 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import kotlinx.serialization.modules.SerializersModule import org.junit.Test -import tech.libeufin.bank.RelativeTime -import tech.libeufin.bank.RelativeTimeSerializer -import tech.libeufin.bank.TokenRequest -import tech.libeufin.bank.TokenScope +import tech.libeufin.bank.* +import java.time.Duration +import java.time.Instant +import java.time.temporal.ChronoUnit @Serializable data class MyJsonType( @@ -27,23 +26,24 @@ class JsonTest { """.trimIndent() Json.decodeFromString<MyJsonType>(serialized) } + + /** + * Testing the custom absolute and relative time serializers. + */ @Test - fun unionTypeTest() { - val jsonCfg = Json { - serializersModule = SerializersModule { - contextual(RelativeTime::class) { - RelativeTimeSerializer - } - } - } - assert(jsonCfg.decodeFromString<RelativeTime>("{\"d_us\": 3}").d_us == 3L) - assert(jsonCfg.decodeFromString<RelativeTime>("{\"d_us\": \"forever\"}").d_us == Long.MAX_VALUE) - val tokenReq = jsonCfg.decodeFromString<TokenRequest>(""" - { - "scope": "readonly", - "duration": {"d_us": 30} - } - """.trimIndent()) - assert(tokenReq.scope == TokenScope.readonly && tokenReq.duration?.d_us == 30L) + fun timeSerializers() { + // from JSON to time types + assert(Json.decodeFromString<RelativeTime>("{\"d_us\": 3}").d_us.toNanos() == 3000L) + assert(Json.decodeFromString<RelativeTime>("{\"d_us\": \"forever\"}").d_us == ChronoUnit.FOREVER.duration) + assert(Json.decodeFromString<TalerProtocolTimestamp>("{\"t_s\": 3}").t_s == Instant.ofEpochSecond(3)) + assert(Json.decodeFromString<TalerProtocolTimestamp>("{\"t_s\": \"never\"}").t_s == Instant.MAX) + + // from time types to JSON + val oneDay = RelativeTime(d_us = Duration.of(1, ChronoUnit.DAYS)) + val oneDaySerial = Json.encodeToString(oneDay) + assert(Json.decodeFromString<RelativeTime>(oneDaySerial).d_us == oneDay.d_us) + val forever = RelativeTime(d_us = ChronoUnit.FOREVER.duration) + val foreverSerial = Json.encodeToString(forever) + assert(Json.decodeFromString<RelativeTime>(foreverSerial).d_us == forever.d_us) } } \ No newline at end of file diff --git a/bank/src/test/kotlin/LibeuFinApiTest.kt b/bank/src/test/kotlin/LibeuFinApiTest.kt @@ -8,8 +8,8 @@ import net.taler.wallet.crypto.Base32Crockford import org.junit.Test import tech.libeufin.bank.* import tech.libeufin.util.CryptoUtil -import tech.libeufin.util.getNowUs import java.time.Duration +import java.time.Instant import kotlin.random.Random class LibeuFinApiTest { @@ -131,6 +131,30 @@ class LibeuFinApiTest { } } + // Creating token with "forever" duration. + @Test + fun tokenForeverTest() { + val db = initDb() + val ctx = getTestContext() + assert(db.customerCreate(customerFoo) != null) + testApplication { + application { + corebankWebApp(db, ctx) + } + val newTok = client.post("/accounts/foo/token") { + expectSuccess = true + contentType(ContentType.Application.Json) + basicAuth("foo", "pw") + setBody( + """ + {"duration": {"d_us": "forever"}, "scope": "readonly"} + """.trimIndent() + ) + } + val newTokObj = Json.decodeFromString<TokenSuccessResponse>(newTok.bodyAsText()) + assert(newTokObj.expiration.t_s == Instant.MAX) + } + } // Checking the POST /token handling. @Test fun tokenTest() { diff --git a/util/src/main/kotlin/Config.kt b/util/src/main/kotlin/Config.kt @@ -4,8 +4,10 @@ import ch.qos.logback.classic.Level import ch.qos.logback.classic.LoggerContext import ch.qos.logback.core.util.Loader import io.ktor.util.* +import org.slf4j.Logger import org.slf4j.LoggerFactory +val logger: Logger = LoggerFactory.getLogger("tech.libeufin.util") /** * Putting those values into the 'attributes' container because they * are needed by the util routines that do NOT have Sandbox and Nexus diff --git a/util/src/main/kotlin/DB.kt b/util/src/main/kotlin/DB.kt @@ -33,8 +33,6 @@ import org.slf4j.Logger import org.slf4j.LoggerFactory import java.net.URI -private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.util.DB") - fun getCurrentUser(): String = System.getProperty("user.name") fun isPostgres(): Boolean { diff --git a/util/src/main/kotlin/Ebics.kt b/util/src/main/kotlin/Ebics.kt @@ -45,8 +45,6 @@ import javax.xml.bind.JAXBElement import javax.xml.datatype.DatatypeFactory import javax.xml.datatype.XMLGregorianCalendar -private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.util") - data class EbicsProtocolError( val httpStatusCode: HttpStatusCode, val reason: String, diff --git a/util/src/main/kotlin/time.kt b/util/src/main/kotlin/time.kt @@ -20,7 +20,39 @@ package tech.libeufin.util import java.time.* -import java.time.temporal.ChronoUnit +import java.util.concurrent.TimeUnit +/** + * Converts the 'this' Instant to the number of nanoseconds + * since the Epoch. It returns the result as Long, or null + * if one arithmetic overflow occurred. + */ +private fun Instant.toNanos(): Long? { + val oneSecNanos = TimeUnit.SECONDS.toNanos(1) + val nanoBase: Long = this.epochSecond * oneSecNanos + if (nanoBase != 0L && nanoBase / this.epochSecond != oneSecNanos) + return null + val res = nanoBase + this.nano + if (res < nanoBase) + return null + return res +} -fun getNowUs(): Long = ChronoUnit.MICROS.between(Instant.EPOCH, Instant.now()) -\ No newline at end of file +/** + * This function converts an Instant input to the + * number of microseconds since the Epoch, except that + * it yields Long.MAX if the Input is Instant.MAX. + * + * Takes the name after the way timestamps are designed + * in the database: micros since Epoch, or Long.MAX for + * "never". + * + * Returns the Long representation of 'this' or null + * if that would overflow. + */ +fun Instant.toDbMicros(): Long? { + if (this == Instant.MAX) + return Long.MAX_VALUE + val nanos = this.toNanos() ?: return null + return nanos / 1000L +} +\ No newline at end of file