diff options
author | Antoine A <> | 2024-01-30 18:32:04 +0100 |
---|---|---|
committer | Antoine A <> | 2024-01-30 19:09:54 +0100 |
commit | 16e0190f5337ce4c0c5c61df204e811e84960d75 (patch) | |
tree | 3b56b67cc2269a4093f6ea89727f0dc49dc7fb7f | |
parent | d65afec46d35967c52fea9f145dab8ea0fffca3c (diff) | |
download | libeufin-16e0190f5337ce4c0c5c61df204e811e84960d75.tar.gz libeufin-16e0190f5337ce4c0c5c61df204e811e84960d75.tar.bz2 libeufin-16e0190f5337ce4c0c5c61df204e811e84960d75.zip |
Improve xml logic and fix ebics testbench
-rw-r--r-- | Makefile | 4 | ||||
-rw-r--r-- | common/src/main/kotlin/time.kt | 25 | ||||
-rw-r--r-- | ebics/src/main/kotlin/XMLUtil.kt | 10 | ||||
-rw-r--r-- | ebics/src/main/kotlin/XmlCombinators.kt | 180 | ||||
-rw-r--r-- | ebics/src/test/kotlin/XmlCombinatorsTest.kt | 50 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt | 6 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt | 321 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/Log.kt | 2 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt | 4 | ||||
-rw-r--r-- | nexus/src/test/kotlin/Parsing.kt | 15 | ||||
-rw-r--r-- | testbench/src/main/kotlin/Main.kt | 11 | ||||
-rw-r--r-- | testbench/src/test/kotlin/Iso20022Test.kt | 52 |
12 files changed, 268 insertions, 412 deletions
@@ -110,6 +110,10 @@ bank-test: install-nobuild-bank-files nexus-test: install-nobuild-nexus-files ./gradlew :nexus:test --tests $(test) -i +.PHONY: ebics-test +ebics-test: + ./gradlew :ebics:test --tests $(test) -i + .PHONY: testbench-test testbench-test: install-nobuild-bank-files install-nobuild-nexus-files ./gradlew :testbench:test --tests $(test) -i diff --git a/common/src/main/kotlin/time.kt b/common/src/main/kotlin/time.kt index fee05d01..269a911e 100644 --- a/common/src/main/kotlin/time.kt +++ b/common/src/main/kotlin/time.kt @@ -86,31 +86,6 @@ fun Long.microsToJavaInstant(): Instant? { } /** - * Parses timestamps found in camt.054 documents. They have - * the following format: yyy-MM-ddThh:mm:ss, without any timezone. - * - * @param timeFromXml input time string from the XML - * @return [Instant] in the UTC timezone - */ -fun parseCamtTime(timeFromCamt: String): Instant { - val t = LocalDateTime.parse(timeFromCamt) - val utc = ZoneId.of("UTC") - return t.toInstant(utc.rules.getOffset(t)) -} - -/** - * Parses a date string as found in the booking date of - * camt.054 reports. They have this format: yyyy-MM-dd. - * - * @param bookDate input to parse - * @return [Instant] to the UTC. - */ -fun parseBookDate(bookDate: String): Instant { - val l = LocalDate.parse(bookDate) - return Instant.from(l.atStartOfDay(ZoneId.of("UTC"))) -} - -/** * Returns the minimum instant between two. * * @param a input [Instant] diff --git a/ebics/src/main/kotlin/XMLUtil.kt b/ebics/src/main/kotlin/XMLUtil.kt index c294b7ef..63dbf35b 100644 --- a/ebics/src/main/kotlin/XMLUtil.kt +++ b/ebics/src/main/kotlin/XMLUtil.kt @@ -405,6 +405,16 @@ class XMLUtil private constructor() { return builder.parse(InputSource(xmlInputStream)) } + /** Parse [xml] into a XML DOM */ + fun parseBytesIntoDom(xml: ByteArray): Document { + val factory = DocumentBuilderFactory.newInstance().apply { + isNamespaceAware = true + } + val xmlInputStream = ByteArrayInputStream(xml) + val builder = factory.newDocumentBuilder() + return builder.parse(InputSource(xmlInputStream)) + } + fun signEbicsResponse(ebicsResponse: EbicsResponse, privateKey: RSAPrivateCrtKey): String { val doc = convertJaxbToDocument(ebicsResponse) signEbicsDocument(doc, privateKey) diff --git a/ebics/src/main/kotlin/XmlCombinators.kt b/ebics/src/main/kotlin/XmlCombinators.kt index d9cc77b2..f9f924b9 100644 --- a/ebics/src/main/kotlin/XmlCombinators.kt +++ b/ebics/src/main/kotlin/XmlCombinators.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2020 Taler Systems S.A. + * Copyright (C) 2020, 2024 Taler Systems S.A. * * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -25,28 +25,27 @@ import org.w3c.dom.Element import java.io.StringWriter import javax.xml.stream.XMLOutputFactory import javax.xml.stream.XMLStreamWriter +import java.time.format.* +import java.time.* -class XmlElementBuilder(val w: XMLStreamWriter) { - /** - * First consumes all the path's components, and _then_ starts applying f. - */ - fun element(path: MutableList<String>, f: XmlElementBuilder.() -> Unit = {}) { - /* the wanted path got constructed, go on with f's logic now. */ - if (path.isEmpty()) { - f() - return +class XmlBuilder(private val w: XMLStreamWriter) { + fun el(path: String, lambda: XmlBuilder.() -> Unit = {}) { + path.splitToSequence('/').forEach { + w.writeStartElement(it) + } + lambda() + path.splitToSequence('/').forEach { + w.writeEndElement() } - w.writeStartElement(path.removeAt(0)) - this.element(path, f) - w.writeEndElement() } - fun element(path: String, f: XmlElementBuilder.() -> Unit = {}) { - val splitPath = path.trim('/').split("/").toMutableList() - this.element(splitPath, f) + fun el(path: String, content: String) { + el(path) { + text(content) + } } - fun attribute(name: String, value: String) { + fun attr(name: String, value: String) { w.writeAttribute(name, value) } @@ -55,130 +54,89 @@ class XmlElementBuilder(val w: XMLStreamWriter) { } } -class XmlDocumentBuilder { - - private var maybeWriter: XMLStreamWriter? = null - internal var writer: XMLStreamWriter - get() { - val w = maybeWriter - return w ?: throw AssertionError("no writer set") - } - set(w: XMLStreamWriter) { - maybeWriter = w - } - - fun namespace(uri: String) { - writer.setDefaultNamespace(uri) - } - - fun namespace(prefix: String, uri: String) { - writer.setPrefix(prefix, uri) - } - - fun defaultNamespace(uri: String) { - writer.setDefaultNamespace(uri) - } - - fun root(name: String, f: XmlElementBuilder.() -> Unit) { - val elementBuilder = XmlElementBuilder(writer) - writer.writeStartElement(name) - elementBuilder.f() - writer.writeEndElement() - } -} - -fun constructXml(indent: Boolean = false, f: XmlDocumentBuilder.() -> Unit): String { - val b = XmlDocumentBuilder() +fun constructXml(root: String, f: XmlBuilder.() -> Unit): String { val factory = XMLOutputFactory.newFactory() - factory.setProperty(XMLOutputFactory.IS_REPAIRING_NAMESPACES, true) val stream = StringWriter() var writer = factory.createXMLStreamWriter(stream) - if (indent) { - writer = IndentingXMLStreamWriter(writer) - } - b.writer = writer /** * NOTE: commenting out because it wasn't obvious how to output the * "standalone = 'yes' directive". Manual forge was therefore preferred. */ - // writer.writeStartDocument() - f(b) + stream.write("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>") + XmlBuilder(writer).el(root) { + this.f() + } writer.writeEndDocument() - return "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n${stream.buffer.toString()}" + return stream.buffer.toString() } class DestructionError(m: String) : Exception(m) -private fun Element.getChildElements(ns: String, tag: String): List<Element> { - val elements = mutableListOf<Element>() - for (i in 0..this.childNodes.length) { - val el = this.childNodes.item(i) +private fun Element.childrenByTag(tag: String): Sequence<Element> = sequence { + for (i in 0..childNodes.length) { + val el = childNodes.item(i) if (el !is Element) { continue } - if (ns != "*" && el.namespaceURI != ns) { - continue - } - if (tag != "*" && el.localName != tag) { + if (el.localName != tag) { continue } - elements.add(el) + yield(el) } - return elements } -class XmlElementDestructor internal constructor(val focusElement: Element) { - fun <T> requireOnlyChild(f: XmlElementDestructor.(e: Element) -> T): T { - val children = focusElement.getChildElements("*", "*") - if (children.size != 1) throw DestructionError("expected singleton child tag") - val destr = XmlElementDestructor(children[0]) - return f(destr, children[0]) - } - - fun <T> mapEachChildNamed(s: String, f: XmlElementDestructor.() -> T): List<T> { - val res = mutableListOf<T>() - val els = focusElement.getChildElements("*", s) - for (child in els) { - val destr = XmlElementDestructor(child) - res.add(f(destr)) +class XmlDestructor internal constructor(private val el: Element) { + fun each(path: String, f: XmlDestructor.() -> Unit) { + el.childrenByTag(path).forEach { + f(XmlDestructor(it)) } - return res } - fun <T> requireUniqueChildNamed(s: String, f: XmlElementDestructor.() -> T): T { - val cl = focusElement.getChildElements("*", s) - if (cl.size != 1) { - throw DestructionError("expected exactly one unique $s child, got ${cl.size} instead at ${focusElement}") - } - val el = cl[0] - val destr = XmlElementDestructor(el) - return f(destr) + fun <T> map(path: String, f: XmlDestructor.() -> T): List<T> { + return el.childrenByTag(path).map { + f(XmlDestructor(it)) + }.toList() } - fun <T> maybeUniqueChildNamed(s: String, f: XmlElementDestructor.() -> T): T? { - val cl = focusElement.getChildElements("*", s) - if (cl.size > 1) { - throw DestructionError("expected at most one unique $s child, got ${cl.size} instead") + fun one(path: String): XmlDestructor { + val children = el.childrenByTag(path).iterator() + if (!children.hasNext()) { + throw DestructionError("expected a single $path child, got none instead at $el") } - if (cl.size == 1) { - val el = cl[0] - val destr = XmlElementDestructor(el) - return f(destr) + val el = children.next() + if (children.hasNext()) { + throw DestructionError("expected a single $path child, got ${children.asSequence() + 1} instead at $el") } - return null + return XmlDestructor(el) } -} - -class XmlDocumentDestructor internal constructor(val d: Document) { - fun <T> requireRootElement(name: String, f: XmlElementDestructor.() -> T): T { - if (this.d.documentElement.tagName != name) { - throw DestructionError("expected '$name' tag") + fun opt(path: String): XmlDestructor? { + val children = el.childrenByTag(path).iterator() + if (!children.hasNext()) { + return null } - val destr = XmlElementDestructor(d.documentElement) - return f(destr) + val el = children.next() + if (children.hasNext()) { + throw DestructionError("expected an optional $path child, got ${children.asSequence().count() + 1} instead at $el") + } + return XmlDestructor(el) } + + fun <T> one(path: String, f: XmlDestructor.() -> T): T = f(one(path)) + fun <T> opt(path: String, f: XmlDestructor.() -> T): T? = opt(path)?.run(f) + + fun text(): String = el.textContent + fun date(): LocalDate = LocalDate.parse(text(), DateTimeFormatter.ISO_DATE) + fun dateTime(): LocalDateTime = LocalDateTime.parse(text(), DateTimeFormatter.ISO_DATE_TIME) + inline fun <reified T : kotlin.Enum<T>> enum(): T = java.lang.Enum.valueOf(T::class.java, text()) + + fun attr(index: String): String = el.getAttribute(index) } -fun <T> destructXml(d: Document, f: XmlDocumentDestructor.() -> T): T { - return f(XmlDocumentDestructor(d)) +fun <T> destructXml(xml: ByteArray, root: String, f: XmlDestructor.() -> T): T { + val doc = XMLUtil.parseBytesIntoDom(xml) + if (doc.documentElement.tagName != root) { + throw DestructionError("expected root '$root' got '${doc.documentElement.tagName}'") + } + val destr = XmlDestructor(doc.documentElement) + return f(destr) } diff --git a/ebics/src/test/kotlin/XmlCombinatorsTest.kt b/ebics/src/test/kotlin/XmlCombinatorsTest.kt index c261187a..df219857 100644 --- a/ebics/src/test/kotlin/XmlCombinatorsTest.kt +++ b/ebics/src/test/kotlin/XmlCombinatorsTest.kt @@ -18,58 +18,50 @@ */ import org.junit.Test -import tech.libeufin.ebics.XmlElementBuilder +import tech.libeufin.ebics.XmlBuilder import tech.libeufin.ebics.constructXml +import kotlin.test.* class XmlCombinatorsTest { @Test fun testWithModularity() { - fun module(base: XmlElementBuilder) { - base.element("module") + fun module(base: XmlBuilder) { + base.el("module") } - val s = constructXml { - root("root") { - module(this) - } + val s = constructXml("root") { + module(this) } println(s) + assertEquals("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><root><module/></root>", s) } @Test fun testWithIterable() { - val s = constructXml(indent = true) { - namespace("iter", "able") - root("iterable") { - element("endOfDocument") { - for (i in 1..10) - element("$i") { - element("$i$i") { - text("$i$i$i") - } - } - } + val s = constructXml("iterable") { + el("endOfDocument") { + for (i in 1..10) + el("$i/$i$i", "$i$i$i") } } println(s) + assertEquals("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><iterable><endOfDocument><1><11>111</11></1><2><22>222</22></2><3><33>333</33></3><4><44>444</44></4><5><55>555</55></5><6><66>666</66></6><7><77>777</77></7><8><88>888</88></8><9><99>999</99></9><10><1010>101010</1010></10></endOfDocument></iterable>", s) } @Test fun testBasicXmlBuilding() { - val s = constructXml(indent = true) { - namespace("ebics", "urn:org:ebics:H004") - root("ebics:ebicsRequest") { - attribute("version", "H004") - element("a/b/c") { - attribute("attribute-of", "c") - element("//d/e/f//") { - attribute("nested", "true") - element("g/h/") - } + val s = constructXml("ebics:ebicsRequest") { + attr("version", "H004") + el("a/b/c") { + attr("attribute-of", "c") + el("//d/e/f//") { + attr("nested", "true") + el("g/h/") } - element("one more") } + el("one more") } println(s) + assertEquals("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><ebics:ebicsRequest version=\"H004\"><a><b><c attribute-of=\"c\"><><><d><e><f><>< nested=\"true\"><g><h></></h></g></></></f></e></d></></></c></b></a><one more/></ebics:ebicsRequest>", s) } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt index 6fc7a7fb..12a8d451 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt @@ -261,7 +261,7 @@ suspend fun ingestIncomingPayment( private fun ingestDocument( db: Database, currency: String, - xml: String, + xml: ByteArray, whichDocument: SupportedDocument ) { when (whichDocument) { @@ -315,7 +315,7 @@ private fun ingestDocuments( throw Exception("Could not open any ZIP archive", e) } } - SupportedDocument.PAIN_002_LOGS -> ingestDocument(db, currency, content.toString(Charsets.UTF_8), whichDocument) + SupportedDocument.PAIN_002_LOGS -> ingestDocument(db, currency, content, whichDocument) else -> logger.warn("Not ingesting ${whichDocument}. Only camt.054 notifications supported.") } } @@ -435,7 +435,7 @@ class EbicsFetch: CliktCommand("Fetches bank records. Defaults to camt.054 noti Database(dbCfg.dbConnStr).use { db -> if (import) { logger.debug("Reading from STDIN") - val stdin = generateSequence(::readLine).joinToString("\n") + val stdin = generateSequence(::readLine).joinToString("\n").toByteArray() ingestDocument(db, cfg.currency, stdin, whichDoc) return@cliCmd } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt index 005b010d..826ef4e2 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt @@ -21,10 +21,8 @@ package tech.libeufin.nexus import tech.libeufin.common.* import tech.libeufin.ebics.* import java.net.URLEncoder -import java.time.Instant -import java.time.ZoneId -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter +import java.time.* +import java.time.format.* /** @@ -85,82 +83,38 @@ fun createPain001( ) val zonedTimestamp = ZonedDateTime.ofInstant(initiationTimestamp, ZoneId.of("UTC")) val amountWithoutCurrency: String = getAmountNoCurrency(amount) - return constructXml { - root("Document") { - attribute( - "xmlns", - namespace.fullNamespace - ) - attribute( - "xmlns:xsi", - "http://www.w3.org/2001/XMLSchema-instance" - ) - attribute( - "xsi:schemaLocation", - "${namespace.fullNamespace} ${namespace.xsdFilename}" - ) - element("CstmrCdtTrfInitn") { - element("GrpHdr") { - element("MsgId") { - text(requestUid) - } - element("CreDtTm") { - val dateFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME - text(dateFormatter.format(zonedTimestamp)) - } - element("NbOfTxs") { - text("1") + return constructXml("Document") { + attr("xmlns", namespace.fullNamespace) + attr("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance") + attr("xsi:schemaLocation", "${namespace.fullNamespace} ${namespace.xsdFilename}") + el("CstmrCdtTrfInitn") { + el("GrpHdr") { + el("MsgId", requestUid) + el("CreDtTm", DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(zonedTimestamp)) + el("NbOfTxs", "1") + el("CtrlSum", amountWithoutCurrency) + el("InitgPty/Nm", debitAccount.name) + } + el("PmtInf") { + el("PmtInfId", "NOTPROVIDED") + el("PmtMtd", "TRF") + el("BtchBookg", "false") + el("ReqdExctnDt/Dt", DateTimeFormatter.ISO_DATE.format(zonedTimestamp)) + el("Dbtr/Nm", debitAccount.name) + el("DbtrAcct/Id/IBAN", debitAccount.iban) + el("DbtrAgt/FinInstnId/BICFI", debitAccount.bic) + el("CdtTrfTxInf") { + el("PmtId") { + el("InstrId", "NOTPROVIDED") + el("EndToEndId", "NOTPROVIDED") } - element("CtrlSum") { + el("Amt/InstdAmt") { + attr("Ccy", amount.currency) text(amountWithoutCurrency) } - element("InitgPty/Nm") { - text(debitAccount.name) - } - } - element("PmtInf") { - element("PmtInfId") { - text("NOTPROVIDED") - } - element("PmtMtd") { - text("TRF") - } - element("BtchBookg") { - text("false") - } - element("ReqdExctnDt") { - element("Dt") { - text(DateTimeFormatter.ISO_DATE.format(zonedTimestamp)) - } - } - element("Dbtr/Nm") { - text(debitAccount.name) - } - element("DbtrAcct/Id/IBAN") { - text(debitAccount.iban) - } - element("DbtrAgt/FinInstnId/BICFI") { - text(debitAccount.bic) - } - element("CdtTrfTxInf") { - element("PmtId") { - element("InstrId") { text("NOTPROVIDED") } - element("EndToEndId") { text("NOTPROVIDED") } - } - element("Amt/InstdAmt") { - attribute("Ccy", amount.currency) - text(amountWithoutCurrency) - } - element("Cdtr/Nm") { - text(creditAccount.receiverName) - } - element("CdtrAcct/Id/IBAN") { - text(creditAccount.payto.iban) - } - element("RmtInf/Ustrd") { - text(wireTransferSubject) - } - } + el("Cdtr/Nm", creditAccount.receiverName) + el("CdtrAcct/Id/IBAN", creditAccount.payto.iban) + el("RmtInf/Ustrd", wireTransferSubject) } } } @@ -185,48 +139,24 @@ data class CustomerAck( * * @param xml pain.002 input document */ -fun parseCustomerAck(xml: String): List<CustomerAck> { - val notifDoc = XMLUtil.parseStringIntoDom(xml) - return destructXml(notifDoc) { - requireRootElement("Document") { - requireUniqueChildNamed("CstmrPmtStsRpt") { - mapEachChildNamed("OrgnlPmtInfAndSts") { - val actionType = requireUniqueChildNamed("OrgnlPmtInfId") { - focusElement.textContent - } - - requireUniqueChildNamed("StsRsnInf") { - var timestamp: Instant? = null; - requireUniqueChildNamed("Orgtr") { - requireUniqueChildNamed("Id") { - requireUniqueChildNamed("OrgId") { - mapEachChildNamed("Othr") { - val value = requireUniqueChildNamed("Id") { - focusElement.textContent - } - val key = requireUniqueChildNamed("SchmeNm") { - requireUniqueChildNamed("Prtry") { - focusElement.textContent - } - } - when (key) { - "TimeStamp" -> { - timestamp = parseCamtTime(value.trimEnd('Z')) // TODO better date parsing - } - // TODO extract ids ? - } - } - } - } - } - val code = maybeUniqueChildNamed("Rsn") { - requireUniqueChildNamed("Cd") { - ExternalStatusReasonCode.valueOf(focusElement.textContent) - } +fun parseCustomerAck(xml: ByteArray): List<CustomerAck> { + return destructXml(xml, "Document") { + one("CstmrPmtStsRpt").map("OrgnlPmtInfAndSts") { + val actionType = one("OrgnlPmtInfId").text() + one("StsRsnInf") { + var timestamp: Instant? = null; + one("Orgtr").one("Id").one("OrgId").each("Othr") { + val value = one("Id") + val key = one("SchmeNm").one("Prtry").text() + when (key) { + "TimeStamp" -> { + timestamp = value.dateTime().toInstant(ZoneOffset.UTC) } - CustomerAck(actionType, code, timestamp!!) + // TODO extract ids ? } } + val code = opt("Rsn")?.one("Cd")?.enum<ExternalStatusReasonCode>() + CustomerAck(actionType, code, timestamp!!) } } } @@ -256,47 +186,34 @@ data class Reason ( * * @param xml pain.002 input document */ -fun parseCustomerPaymentStatusReport(xml: String): PaymentStatus { - val notifDoc = XMLUtil.parseStringIntoDom(xml) - fun XmlElementDestructor.reasons(): List<Reason> { - return mapEachChildNamed("StsRsnInf") { - val code = requireUniqueChildNamed("Rsn") { - requireUniqueChildNamed("Cd") { - ExternalStatusReasonCode.valueOf(focusElement.textContent) - } - } +fun parseCustomerPaymentStatusReport(xml: ByteArray): PaymentStatus { + fun XmlDestructor.reasons(): List<Reason> { + return map("StsRsnInf") { + val code = one("Rsn").one("Cd").enum<ExternalStatusReasonCode>() // TODO parse information Reason(code, "") } } - return destructXml(notifDoc) { - requireRootElement("Document") { - requireUniqueChildNamed("CstmrPmtStsRpt") { - val (msgId, msgCode, msgReasons) = requireUniqueChildNamed("OrgnlGrpInfAndSts") { - val id = requireUniqueChildNamed("OrgnlMsgId") { - focusElement.textContent - } - val code = maybeUniqueChildNamed("GrpSts") { - ExternalPaymentGroupStatusCode.valueOf(focusElement.textContent) - } - val reasons = reasons() - Triple(id, code, reasons) - } - val paymentInfo = maybeUniqueChildNamed("OrgnlPmtInfAndSts") { - val code = requireUniqueChildNamed("PmtInfSts") { - ExternalPaymentGroupStatusCode.valueOf(focusElement.textContent) - } - val reasons = reasons() - Pair(code, reasons) - } + return destructXml(xml, "Document") { + one("CstmrPmtStsRpt") { + val (msgId, msgCode, msgReasons) = one("OrgnlGrpInfAndSts") { + val id = one("OrgnlMsgId").text() + val code = opt("GrpSts")?.enum<ExternalPaymentGroupStatusCode>() + val reasons = reasons() + Triple(id, code, reasons) + } + val paymentInfo = opt("OrgnlPmtInfAndSts") { + val code = one("PmtInfSts").enum<ExternalPaymentGroupStatusCode>() + val reasons = reasons() + Pair(code, reasons) + } - // TODO handle multi level code better - if (paymentInfo != null) { - val (code, reasons) = paymentInfo - PaymentStatus(msgId, code, reasons) - } else { - PaymentStatus(msgId, msgCode!!, msgReasons) - } + // TODO handle multi level code better + if (paymentInfo != null) { + val (code, reasons) = paymentInfo + PaymentStatus(msgId, code, reasons) + } else { + PaymentStatus(msgId, msgCode!!, msgReasons) } } } @@ -311,39 +228,26 @@ fun parseCustomerPaymentStatusReport(xml: String): PaymentStatus { * @param outgoing list of outgoing payments */ fun parseTxNotif( - notifXml: String, + notifXml: ByteArray, acceptedCurrency: String, incoming: MutableList<IncomingPayment>, outgoing: MutableList<OutgoingPayment> ) { notificationForEachTx(notifXml) { bookDate -> - val kind = requireUniqueChildNamed("CdtDbtInd") { - focusElement.textContent - } - val amount: TalerAmount = requireUniqueChildNamed("Amt") { - val currency = focusElement.getAttribute("Ccy") + val kind = one("CdtDbtInd").text() + val amount: TalerAmount = one("Amt") { + val currency = attr("Ccy") /** * FIXME: test by sending non-CHF to PoFi and see which currency gets here. */ if (currency != acceptedCurrency) throw Exception("Currency $currency not supported") - TalerAmount("$currency:${focusElement.textContent}") + TalerAmount("$currency:${text()}") } when (kind) { "CRDT" -> { - val bankId: String = requireUniqueChildNamed("Refs") { - requireUniqueChildNamed("AcctSvcrRef") { - focusElement.textContent - } - } + val bankId: String = one("Refs").one("AcctSvcrRef").text() // Obtaining payment subject. - val subject = maybeUniqueChildNamed("RmtInf") { - val subject = StringBuilder() - mapEachChildNamed("Ustrd") { - val piece = focusElement.textContent - subject.append(piece) - } - subject - } + val subject = opt("RmtInf")?.map("Ustrd") { text() }?.joinToString() if (subject == null) { logger.debug("Skip notification '$bankId', missing subject") return@notificationForEachTx @@ -351,22 +255,14 @@ fun parseTxNotif( // Obtaining the payer's details val debtorPayto = StringBuilder("payto://iban/") - requireUniqueChildNamed("RltdPties") { - requireUniqueChildNamed("DbtrAcct") { - requireUniqueChildNamed("Id") { - requireUniqueChildNamed("IBAN") { - debtorPayto.append(focusElement.textContent) - } - } + one("RltdPties") { + one("DbtrAcct").one("Id").one("IBAN") { + debtorPayto.append(text()) } // warn: it might need the postal address too.. - requireUniqueChildNamed("Dbtr") { - maybeUniqueChildNamed("Pty") { - requireUniqueChildNamed("Nm") { - val urlEncName = URLEncoder.encode(focusElement.textContent, "utf-8") - debtorPayto.append("?receiver-name=$urlEncName") - } - } + one("Dbtr").opt("Pty")?.one("Nm") { + val urlEncName = URLEncoder.encode(text(), "utf-8") + debtorPayto.append("?receiver-name=$urlEncName") } } incoming.add( @@ -380,12 +276,7 @@ fun parseTxNotif( ) } "DBIT" -> { - val messageId = requireUniqueChildNamed("Refs") { - requireUniqueChildNamed("MsgId") { - focusElement.textContent - } - } - + val messageId = one("Refs").one("MsgId").text() outgoing.add( OutgoingPayment( amount = amount, @@ -403,40 +294,30 @@ fun parseTxNotif( * Navigates the camt.054 (Detailavisierung) until its leaves, where * then it invokes the related parser, according to the payment direction. * - * @param notifXml the input document. - * @return any incoming payment as a list of [IncomingPayment] + * @param xml the input document. */ private fun notificationForEachTx( - notifXml: String, - directionLambda: XmlElementDestructor.(Instant) -> Unit + xml: ByteArray, + directionLambda: XmlDestructor.(Instant) -> Unit ) { - val notifDoc = XMLUtil.parseStringIntoDom(notifXml) - destructXml(notifDoc) { - requireRootElement("Document") { - requireUniqueChildNamed("BkToCstmrDbtCdtNtfctn") { - mapEachChildNamed("Ntfctn") { - mapEachChildNamed("Ntry") { - requireUniqueChildNamed("Sts") { - if (focusElement.textContent != "BOOK") { - requireUniqueChildNamed("Cd") { - if (focusElement.textContent != "BOOK") - throw Exception("Found non booked transaction, " + - "stop parsing. Status was: ${focusElement.textContent}" - ) - } - } - } - val bookDate: Instant = requireUniqueChildNamed("BookgDt") { - requireUniqueChildNamed("Dt") { - parseBookDate(focusElement.textContent) - } - } - mapEachChildNamed("NtryDtls") { - mapEachChildNamed("TxDtls") { - directionLambda(this, bookDate) + destructXml(xml, "Document") { + one("BkToCstmrDbtCdtNtfctn") { + each("Ntfctn") { + each("Ntry") { + one("Sts") { + if (text() != "BOOK") { + one("Cd") { + if (text() != "BOOK") + throw Exception("Found non booked transaction, " + + "stop parsing. Status was: ${text()}" + ) } } } + val bookDate: Instant = one("BookgDt").one("Dt").dateTime().toInstant(ZoneOffset.UTC) + one("NtryDtls").one("TxDtls") { + directionLambda(this, bookDate) + } } } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Log.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Log.kt index ffd495f8..3299a16e 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/Log.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Log.kt @@ -68,7 +68,7 @@ class FileLogger(path: String?) { } else { // Write each ZIP entry in the combined dir. content.unzipForEach { fileName, xmlContent -> - subDir.resolve("${nowMs}_$fileName").writeText(xmlContent) + subDir.resolve("${nowMs}_$fileName").writeBytes(xmlContent) } } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt index 167954d0..57991e2d 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt @@ -97,12 +97,12 @@ enum class SupportedDocument { * @param lambda function that gets the (fileName, fileContent) pair * for each entry in the ZIP archive as input. */ -fun ByteArray.unzipForEach(lambda: (String, String) -> Unit) { +fun ByteArray.unzipForEach(lambda: (String, ByteArray) -> Unit) { val mem = SeekableInMemoryByteChannel(this) ZipFile(mem).use { file -> file.getEntriesInPhysicalOrder().iterator().forEach { lambda( - it.name, file.getInputStream(it).readAllBytes().toString(Charsets.UTF_8) + it.name, file.getInputStream(it).readAllBytes() ) } } diff --git a/nexus/src/test/kotlin/Parsing.kt b/nexus/src/test/kotlin/Parsing.kt index c3ba4015..8778c4c4 100644 --- a/nexus/src/test/kotlin/Parsing.kt +++ b/nexus/src/test/kotlin/Parsing.kt @@ -20,27 +20,12 @@ import org.junit.Test import tech.libeufin.nexus.* import tech.libeufin.common.* -import tech.libeufin.common.parseBookDate -import tech.libeufin.common.parseCamtTime import java.lang.StringBuilder import kotlin.test.* class Parsing { @Test - fun gregorianTime() { - parseCamtTime("2023-11-06T20:00:00") - assertFailsWith<Exception> { parseCamtTime("2023-11-06T20:00:00+01:00") } - assertFailsWith<Exception> { parseCamtTime("2023-11-06T20:00:00Z") } - } - - @Test - fun bookDateTest() { - parseBookDate("1970-01-01") - assertFailsWith<Exception> { parseBookDate("1970-01-01T00:00:01Z") } - } - - @Test fun reservePublicKey() { assertNull(removeSubjectNoise("does not contain any reserve")) // 4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0 diff --git a/testbench/src/main/kotlin/Main.kt b/testbench/src/main/kotlin/Main.kt index 076d5001..4b999072 100644 --- a/testbench/src/main/kotlin/Main.kt +++ b/testbench/src/main/kotlin/Main.kt @@ -116,9 +116,6 @@ class Cli : CliktCommand("Run integration tests on banks provider") { val clientKeysPath = cfg.requirePath("nexus-ebics", "client_private_keys_file") val bankKeysPath = cfg.requirePath("nexus-ebics", "bank_public_keys_file") - var hasClientKeys = clientKeysPath.exists() - var hasBankKeys = bankKeysPath.exists() - // Alternative payto ? val payto = "payto://iban/CH6208704048981247126?receiver-name=Grothoff%20Hans" @@ -157,8 +154,7 @@ class Cli : CliktCommand("Run integration tests on banks provider") { put("reset-keys", suspend { clientKeysPath.deleteIfExists() bankKeysPath.deleteIfExists() - hasClientKeys = false - hasBankKeys = false + Unit }) put("tx", suspend { step("Test submit one transaction") @@ -201,6 +197,9 @@ class Cli : CliktCommand("Run integration tests on banks provider") { } while (true) { + var hasClientKeys = clientKeysPath.exists() + var hasBankKeys = bankKeysPath.exists() + if (!hasClientKeys) { if (kind.test) { step("Test INI order") @@ -219,7 +218,7 @@ class Cli : CliktCommand("Run integration tests on banks provider") { .assertOk("ebics-setup should succeed the second time") } - val arg = ask("testbench >")!!.trim() + val arg = ask("testbench> ")!!.trim() if (arg == "exit") break val cmd = cmds[arg] if (cmd != null) { diff --git a/testbench/src/test/kotlin/Iso20022Test.kt b/testbench/src/test/kotlin/Iso20022Test.kt new file mode 100644 index 00000000..49d78d4d --- /dev/null +++ b/testbench/src/test/kotlin/Iso20022Test.kt @@ -0,0 +1,52 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +import tech.libeufin.nexus.* +import org.junit.Test +import java.nio.file.* +import kotlin.io.path.* + +class Iso20022Test { + @Test + fun logs() { + for (platform in Path("test").listDirectoryEntries()) { + for (file in platform.listDirectoryEntries()) { + val fetch = file.resolve("fetch") + if (file.isDirectory() && fetch.exists()) { + for (log in fetch.listDirectoryEntries()) { + val str = log.readBytes() + val name = log.toString() + println(name) + if (name.contains("HAC")) { + parseCustomerAck(str) + } else if (name.contains("pain.002")) { + parseCustomerPaymentStatusReport(str) + } else { + try { + parseTxNotif(str, "CHF", mutableListOf(), mutableListOf()) + } catch (e: Exception) { + println(e) // TODO import tx notif + } + } + } + } + } + } + } +}
\ No newline at end of file |