From 8e7c48d7e9c1a9473ff9a1b2aa3ab54a862158c2 Mon Sep 17 00:00:00 2001 From: Claude Brisson Date: Fri, 19 May 2023 10:27:42 +0200 Subject: [PATCH] Various debugging; OpenGotha import draft --- .../jeudego/pairgoth/api/TournamentHandler.kt | 12 +- .../org/jeudego/pairgoth/ext/OpenGotha.kt | 176 ++++++ .../kotlin/org/jeudego/pairgoth/model/Game.kt | 2 +- .../org/jeudego/pairgoth/model/Pairable.kt | 5 +- .../jeudego/pairgoth/pairing/SwissSolver.kt | 2 +- .../org/jeudego/pairgoth/util/XmlFormat.kt | 362 ++++++++++++ .../org/jeudego/pairgoth/util/XmlUtils.kt | 558 ++++++++++++++++++ .../org/jeudego/pairgoth/web/ApiServlet.kt | 49 +- webapp/src/test/kotlin/ImportTests.kt | 20 + webapp/src/test/kotlin/TestBase.kt | 5 +- webapp/src/test/kotlin/TestUtils.kt | 30 +- 11 files changed, 1184 insertions(+), 37 deletions(-) create mode 100644 webapp/src/main/kotlin/org/jeudego/pairgoth/ext/OpenGotha.kt create mode 100644 webapp/src/main/kotlin/org/jeudego/pairgoth/util/XmlFormat.kt create mode 100644 webapp/src/main/kotlin/org/jeudego/pairgoth/util/XmlUtils.kt create mode 100644 webapp/src/test/kotlin/ImportTests.kt diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/api/TournamentHandler.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/api/TournamentHandler.kt index 2b35985..a9f2c06 100644 --- a/webapp/src/main/kotlin/org/jeudego/pairgoth/api/TournamentHandler.kt +++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/api/TournamentHandler.kt @@ -1,11 +1,14 @@ package org.jeudego.pairgoth.api import com.republicate.kson.Json +import org.jeudego.pairgoth.api.ApiHandler.Companion.PAYLOAD_KEY import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest +import org.jeudego.pairgoth.ext.OpenGotha import org.jeudego.pairgoth.model.Tournament import org.jeudego.pairgoth.model.fromJson import org.jeudego.pairgoth.model.toJson import org.jeudego.pairgoth.store.Store +import org.w3c.dom.Element import javax.servlet.http.HttpServletRequest object TournamentHandler: PairgothApiHandler { @@ -18,10 +21,11 @@ object TournamentHandler: PairgothApiHandler { } override fun post(request: HttpServletRequest): Json { - val payload = getObjectPayload(request) - - // tournament parsing - val tournament = Tournament.fromJson(payload) + val tournament = when (val payload = request.getAttribute(PAYLOAD_KEY)) { + is Json.Object -> Tournament.fromJson(getObjectPayload(request)) + is Element -> OpenGotha.import(payload) + else -> badRequest("missing or invalid payload") + } Store.addTournament(tournament) return Json.Object("success" to true, "id" to tournament.id) diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/ext/OpenGotha.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/ext/OpenGotha.kt new file mode 100644 index 0000000..70377c5 --- /dev/null +++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/ext/OpenGotha.kt @@ -0,0 +1,176 @@ +package org.jeudego.pairgoth.ext + +import org.jeudego.pairgoth.model.CanadianByoyomi +import org.jeudego.pairgoth.model.FischerTime +import org.jeudego.pairgoth.model.Game +import org.jeudego.pairgoth.model.MacMahon +import org.jeudego.pairgoth.model.Pairable +import org.jeudego.pairgoth.model.Player +import org.jeudego.pairgoth.model.StandardByoyomi +import org.jeudego.pairgoth.model.SuddenDeath +import org.jeudego.pairgoth.model.Swiss +import org.jeudego.pairgoth.model.Tournament +import org.jeudego.pairgoth.model.parseRank +import org.jeudego.pairgoth.store.Store +import org.jeudego.pairgoth.util.XmlFormat +import org.jeudego.pairgoth.util.booleanAttr +import org.jeudego.pairgoth.util.childrenArrayOf +import org.jeudego.pairgoth.util.dateAttr +import org.jeudego.pairgoth.util.doubleAttr +import org.jeudego.pairgoth.util.find +import org.jeudego.pairgoth.util.get +import org.jeudego.pairgoth.util.intAttr +import org.jeudego.pairgoth.util.objectOf +import org.jeudego.pairgoth.util.optBoolean +import org.jeudego.pairgoth.util.stringAttr +import org.w3c.dom.Element +import java.util.* + +class OpenGothaFormat(xml: Element): XmlFormat(xml) { + + val Players by childrenArrayOf() + val Games by childrenArrayOf() + val TournamentParameterSet by objectOf() + + class Player(xml: Element): XmlFormat(xml) { + val agaId by stringAttr() + val club by stringAttr() + val country by stringAttr() + val egfPin by stringAttr() + val ffgLicence by stringAttr() + val firstName by stringAttr() + val name by stringAttr() + val participating by stringAttr() + val rank by stringAttr() + val rating by intAttr() + } + + class Game(xml: Element): XmlFormat(xml) { + val blackPlayer by stringAttr() + val whitePlayer by stringAttr() + val handicap by intAttr() + val knownColor by booleanAttr() + val result by stringAttr() + val roundNumber by intAttr() + } + + class Params(xml: Element): XmlFormat(xml) { + val GeneralParameterSet by objectOf() + val HandicapParameterSet by objectOf() + val PairingParameterSet by objectOf() + + class GenParams(xml: Element): XmlFormat(xml) { + val bInternet by optBoolean() + val basicTime by intAttr() + val beginDate by dateAttr() + val canByoYomiTime by intAttr() + val complementaryTimeSystem by stringAttr() + val endDate by dateAttr() + val fisherTime by intAttr() + val genCountNotPlayedGamesAsHalfPoint by booleanAttr() + val genMMBar by stringAttr() + val genMMFloor by stringAttr() + val komi by doubleAttr() + val location by stringAttr() + val name by stringAttr() + val nbMovesCanTime by intAttr() + val numberOfCategories by intAttr() + val numberOfRounds by intAttr() + val shortName by stringAttr() + val size by intAttr() + val stdByoYomiTime by intAttr() + } + class HandicapParams(xml: Element): XmlFormat(xml) { + val hdBasedOnMMS by booleanAttr() + val hdCeiling by intAttr() + val hdCorrection by intAttr() + val hdNoHdRankThreshold by stringAttr() + } + class PairingParams(xml: Element): XmlFormat(xml) { + val paiMaSeedSystem1 by stringAttr() + val paiMaSeedSystem2 by stringAttr() + } + } +} + +object OpenGotha { + fun import(element: Element): Tournament { + val imported = OpenGothaFormat(element) + val genParams = imported.TournamentParameterSet.GeneralParameterSet + val handParams = imported.TournamentParameterSet.HandicapParameterSet + val pairingParams = imported.TournamentParameterSet.PairingParameterSet + val tournament = Tournament( + id = Store.nextTournamentId, + type = Tournament.Type.INDIVIDUAL, // CB for now, TODO + name = genParams.name, + shortName = genParams.shortName, + startDate = genParams.beginDate, + endDate = genParams.endDate, + country = "FR", // no country in opengotha format + location = genParams.location, + online = genParams.bInternet ?: false, + timeSystem = when (genParams.complementaryTimeSystem) { + "SUDDENDEATH" -> SuddenDeath(genParams.basicTime) + "STDBYOYOMI" -> StandardByoyomi(genParams.basicTime, genParams.stdByoYomiTime, 1) // no periods? + "CANBYOYOMI" -> CanadianByoyomi(genParams.basicTime, genParams.canByoYomiTime, genParams.nbMovesCanTime) + "FISCHER" -> FischerTime(genParams.basicTime, genParams.fisherTime) + else -> throw Error("missing byoyomi type") + }, + pairing = when (handParams.hdCeiling) { + 0 -> Swiss( + when (pairingParams.paiMaSeedSystem1) { + "SPLITANDFOLD" -> Swiss.Method.SPLIT_AND_FOLD + "SPLITANDRANDOM" -> Swiss.Method.SPLIT_AND_RANDOM + "SPLITANDSLIP" -> Swiss.Method.SPLIT_AND_SLIP + else -> throw Error("unknown swiss pairing method") + }, + when (pairingParams.paiMaSeedSystem2) { + "SPLITANDFOLD" -> Swiss.Method.SPLIT_AND_FOLD + "SPLITANDRANDOM" -> Swiss.Method.SPLIT_AND_RANDOM + "SPLITANDSLIP" -> Swiss.Method.SPLIT_AND_SLIP + else -> throw Error("unknown swiss pairing method") + } + ) + else -> MacMahon() // TODO + }, + rounds = genParams.numberOfRounds + ) + val canonicMap = mutableMapOf() + imported.Players.map { player -> + Player( + id = Store.nextPlayerId, + name = player.name, + firstname = player.firstName, + rating = player.rating, + rank = Pairable.parseRank(player.rank), + country = player.country, + club = player.club + ).also { + canonicMap.put("${player.name}${player.firstName}".uppercase(Locale.ENGLISH), it.id) + } + }.associateByTo(tournament.pairables) { it.id } + val gamesPerRound = imported.Games.groupBy { + it.roundNumber + }.values.map { + it.map { game -> + Game( + id = Store.nextGameId, + black = canonicMap[game.blackPlayer] ?: throw Error("player not found: ${game.blackPlayer}"), + white = canonicMap[game.whitePlayer] ?: throw Error("player not found: ${game.whitePlayer}"), + handicap = game.handicap, + result = when (game.result) { + "RESULT_UNKNOWN" -> Game.Result.UNKNOWN + "RESULT_WHITEWINS" -> Game.Result.WHITE + "RESULT_BLACKWINS" -> Game.Result.BLACK + "RESULT_EQUAL" -> Game.Result.JIGO + "RESULT_BOTHWIN" -> Game.Result.BOTHWIN + "RESULT_BOTHLOOSE" -> Game.Result.BOTHLOOSE + else -> throw Error("unhandled result: ${game.result}") + } + ) + }.associateBy { it.id }.toMutableMap() + } + tournament.games.addAll(gamesPerRound) + return tournament + } +} diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Game.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Game.kt index 6d6b6a3..19ac567 100644 --- a/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Game.kt +++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Game.kt @@ -10,7 +10,7 @@ data class Game( val handicap: Int = 0, var result: Result = UNKNOWN ) { - enum class Result(val symbol: Char) { UNKNOWN('?'), BLACK('b'), WHITE('w'), JIGO('='), CANCELLED('x') } + enum class Result(val symbol: Char) { UNKNOWN('?'), BLACK('b'), WHITE('w'), JIGO('='), CANCELLED('x'), BOTHWIN('+'), BOTHLOOSE('-') } } // serialization diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairable.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairable.kt index a3523b6..72dddc7 100644 --- a/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairable.kt +++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairable.kt @@ -10,6 +10,7 @@ import kotlin.math.roundToInt // Pairable sealed class Pairable(val id: Int, val name: String, open val rating: Int, open val rank: Int) { + companion object {} abstract fun toJson(): Json.Object val skip = mutableSetOf() // skipped rounds } @@ -29,10 +30,10 @@ fun Pairable.displayRank(): String = when { private val rankRegex = Regex("(\\d+)([kdp])", RegexOption.IGNORE_CASE) -fun Pairable.setRank(rankStr: String): Int { +fun Pairable.Companion.parseRank(rankStr: String): Int { val (level, letter) = rankRegex.matchEntire(rankStr)?.destructured ?: throw Error("invalid rank: $rankStr") val num = level.toInt() - if (num < 0 || num > 9) throw Error("invalid rank: $rankStr") + if (num < 0 || letter != "k" && letter != "K" && num > 9) throw Error("invalid rank: $rankStr") return when (letter.lowercase()) { "k" -> -num "d" -> num - 1 diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/SwissSolver.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/SwissSolver.kt index 17294c7..2edd5a3 100644 --- a/webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/SwissSolver.kt +++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/SwissSolver.kt @@ -30,7 +30,7 @@ class SwissSolver(history: List, pairables: List, val method: Sw SPLIT_AND_FOLD -> if (p.placeInGroup.first > q.placeInGroup.first) abs(p.placeInGroup.first - (q.placeInGroup.second - q.placeInGroup.first)) * PLACE_WEIGHT else abs(q.placeInGroup.first - (p.placeInGroup.second - p.placeInGroup.first)) * PLACE_WEIGHT - SPLIT_AND_RANDOM -> rand.nextDouble(p.placeInGroup.second.toDouble()) * PLACE_WEIGHT + SPLIT_AND_RANDOM -> rand.nextDouble() * p.placeInGroup.second * PLACE_WEIGHT SPLIT_AND_SLIP -> abs(abs(p.placeInGroup.first - q.placeInGroup.first) - p.placeInGroup.second) * PLACE_WEIGHT else -> throw Error("unhandled case") } diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/util/XmlFormat.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/util/XmlFormat.kt new file mode 100644 index 0000000..8d3f026 --- /dev/null +++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/util/XmlFormat.kt @@ -0,0 +1,362 @@ +package org.jeudego.pairgoth.util + +import com.republicate.kson.Json +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import org.w3c.dom.Element +import org.w3c.dom.ElementTraversal +import org.w3c.dom.Node +import org.w3c.dom.NodeList +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeFormatterBuilder +import java.time.temporal.ChronoField +import kotlin.reflect.KClass +import kotlin.reflect.KProperty + +// "XMLFormat" xml parsing and formatting utility class +// It is currently being packaged as an external open source library. +// See opengotha import code as a self-documenting example for how to use this class + +open class XmlFormat(val xml: Element) + +// standard types delegates + +fun XmlFormat.string() = StringXmlDelegate(xml.element()) +fun XmlFormat.optString() = OptionalStringXmlDelegate(xml.element()) +fun XmlFormat.boolean() = BooleanXmlDelegate(xml.element()) +fun XmlFormat.optBoolean() = OptionalBooleanXmlDelegate(xml.element()) +fun XmlFormat.int() = IntXmlDelegate(xml.element()) +fun XmlFormat.optInt() = OptionalIntXmlDelegate(xml.element()) +fun XmlFormat.long() = LongXmlDelegate(xml.element()) +fun XmlFormat.optLong() = OptionalLongXmlDelegate(xml.element()) +fun XmlFormat.double() = DoubleXmlDelegate(xml.element()) +//fun XmlFormat.optDouble() = OptinalDoubleXmlDelegate(xml.element()) +inline fun > XmlFormat.enum() = EnumXmlDelegate(xml.element(), E::class) +fun XmlFormat.date(format: String = ISO_LOCAL_DATE_FORMAT) = DateXmlDelegate(xml.element(), format) +fun XmlFormat.datetime(format: String = ISO_LOCAL_DATETIME_FORMAT) = DateTimeXmlDelegate(xml.element(), format) +inline fun XmlFormat.childrenArrayOf() = ArrayXmlDelegate(xml, T::class) +inline fun XmlFormat.childrenArrayOf(tagName: String) = ChildrenArrayXmlDelegate(xml, tagName, T::class) +inline fun XmlFormat.mutableArrayOf() = MutableArrayXmlDelegate(xml, T::class) +inline fun XmlFormat.objectOf() = ObjectXmlDelegate(xml, T::class) + +// standard type delegates for attributes + +fun XmlFormat.stringAttr() = StringXmlAttrDelegate(xml.element()) +fun XmlFormat.booleanAttr() = BooleanXmlAttrDelegate(xml.element()) +fun XmlFormat.intAttr() = IntXmlAttrDelegate(xml.element()) +fun XmlFormat.longAttr() = LongXmlAttrDelegate(xml.element()) +fun XmlFormat.doubleAttr() = DoubleXmlAttrDelegate(xml.element()) +fun XmlFormat.dateAttr(format: String = ISO_LOCAL_DATE_FORMAT) = DateXmlAttrDelegate(xml.element(), format) + +// xpath delegates + +fun XmlFormat.string(xpath: String) = StringXmlAttrDelegate(xml.element().find(xpath)[0].element()) +fun XmlFormat.boolean(xpath: String) = StringXmlAttrDelegate(xml.element().find(xpath)[0].element()) +fun XmlFormat.int(xpath: String) = StringXmlAttrDelegate(xml.element().find(xpath)[0].element()) +fun XmlFormat.long(xpath: String) = StringXmlAttrDelegate(xml.element().find(xpath)[0].element()) + +// Helper classes and functions + +private fun error(propName: String): Nothing { throw Error("missing property $propName") } + +open class OptionalStringXmlDelegate(val xml: Element) { + open operator fun getValue(thisRef: Any?, property: KProperty<*>): String? = xml.childOrNull(property.name)?.value() + open operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String?) { value?.let { xml.child(property.name).textContent = value } } +} + +open class StringXmlDelegate(val xml: Element) { + open operator fun getValue(thisRef: Any?, property: KProperty<*>): String = xml.childOrNull(property.name)?.value() ?: error(property.name) + open operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) { value?.let { xml.child(property.name).textContent = value } } +} + +open class OptionalBooleanXmlDelegate(val xml: Element) { + open operator fun getValue(thisRef: Any?, property: KProperty<*>): Boolean? = Json.TypeUtils.toBoolean(xml.childOrNull(property.name)?.value()) + open operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Boolean?) { value?.let { xml.child(property.name).textContent = value.toString() } } +} + +open class BooleanXmlDelegate(val xml: Element) { + open operator fun getValue(thisRef: Any?, property: KProperty<*>): Boolean = Json.TypeUtils.toBoolean(xml.childOrNull(property.name)?.value()) ?: error(property.name) + open operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Boolean) { value?.let { xml.child(property.name).textContent = value.toString() } } +} + +open class OptionalIntXmlDelegate(val xml: Element) { + open operator fun getValue(thisRef: Any?, property: KProperty<*>): Int? = Json.TypeUtils.toInt(xml.childOrNull(property.name)?.value()) + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Int?) { value?.let { xml.child(property.name).textContent = value.toString() } } +} + +open class IntXmlDelegate(val xml: Element) { + open operator fun getValue(thisRef: Any?, property: KProperty<*>): Int = Json.TypeUtils.toInt(xml.childOrNull(property.name)?.value()) ?: error(property.name) + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) { value?.let { xml.child(property.name).textContent = value.toString() } } +} + +open class OptionalLongXmlDelegate(val xml: Element) { + open operator fun getValue(thisRef: Any?, property: KProperty<*>): Long? = Json.TypeUtils.toLong(xml.childOrNull(property.name)?.value()) + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Long?) { value?.let { xml.child(property.name).textContent = value.toString() } } +} + +open class LongXmlDelegate(val xml: Element) { + open operator fun getValue(thisRef: Any?, property: KProperty<*>): Long = Json.TypeUtils.toLong(xml.childOrNull(property.name)?.value()) ?: error(property.name) + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Long) { value?.let { xml.child(property.name).textContent = value.toString() } } +} + +open class OptionalDoubleXmlDelegate(val xml: Element) { + open operator fun getValue(thisRef: Any?, property: KProperty<*>): Double? = Json.TypeUtils.toDouble(xml.childOrNull(property.name)?.value()) + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Double?) { value?.let { xml.child(property.name).textContent = value.toString() } } +} + +open class DoubleXmlDelegate(val xml: Element) { + open operator fun getValue(thisRef: Any?, property: KProperty<*>): Double = Json.TypeUtils.toDouble(xml.childOrNull(property.name)?.value()) ?: error(property.name) + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Double) { value?.let { xml.child(property.name).textContent = value.toString() } } +} + +open class OptionalEnumXmlDelegate> (val xml: Element, private val kclass: KClass) { + operator fun getValue(thisRef: Any?, property: KProperty<*>): E? { + val enumValues = kclass.java.enumConstants as Array + val xmlValue = xml.childOrNull(property.name)?.textContent + return enumValues.firstOrNull() { it.name == xmlValue } + } + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: E?) { value?.let { xml.child(property.name).textContent = value.toString() } } +} + +open class EnumXmlDelegate> (val xml: Element, private val kclass: KClass) { + operator fun getValue(thisRef: Any?, property: KProperty<*>): E { + val enumValues = kclass.java.enumConstants as Array + val xmlValue = xml.childOrNull(property.name)?.textContent + return enumValues.firstOrNull() { it.name == xmlValue } ?: error(property.name) + } + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: E) { value?.let { xml.child(property.name).textContent = value.toString() } } +} + +const val ISO_LOCAL_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss" +const val ISO_LOCAL_DATETIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss" +const val ISO_UTC_DATETIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'Z'" +const val ISO_ZONED_DATETIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ssXXX" +const val ISO_LOCAL_YMD = "yyyyMMdd" +const val ISO_LOCAL_YMDHM = "yyyyMMddHHmm" +const val LOCAL_FRENCHY = "dd/MM/yyyy HH:mm" + +internal fun dateTimeFormat(format: String): DateTimeFormatter { + val builder = DateTimeFormatterBuilder() + if (format.startsWith("yyyy")) { + // workaround Java bug + builder.appendValue(ChronoField.YEAR_OF_ERA, 4) + .appendPattern(format.substring(4)) + } else { + builder.appendPattern(format) + } + builder.parseDefaulting(ChronoField.HOUR_OF_DAY, 0) + .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0) + .parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0) + return builder.toFormatter() +} + +open class OptionalDateXmlDelegate(val xml: Element, formatString: String = ISO_LOCAL_DATE_FORMAT) { + private val format = dateTimeFormat(formatString) + open operator fun getValue(thisRef: Any?, property: KProperty<*>): LocalDate? = xml.childOrNull(property.name)?.value()?.let { LocalDate.parse(it /*, format */) } + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: LocalDate?) { value?.let { xml.child(property.name).textContent = value?.let { /* format.format(value)*/ value.toString() } } } +} + +open class DateXmlDelegate(val xml: Element, formatString: String = ISO_LOCAL_DATE_FORMAT) { + private val format = dateTimeFormat(formatString) + open operator fun getValue(thisRef: Any?, property: KProperty<*>): LocalDate = xml.childOrNull(property.name)?.value()?.let { LocalDate.parse(it /*, format */) } ?: error(property.name) + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: LocalDate) { value?.let { xml.child(property.name).textContent = value?.let { /* format.format(value)*/ value.toString() } } } +} + +open class OptionalDateTimeXmlDelegate(val xml: Element, formatString: String = ISO_LOCAL_DATETIME_FORMAT) { + private val format = dateTimeFormat(formatString) + //FB TODO ** To rewrite + //open operator fun getValue(thisRef: Any?, property: KProperty<*>): LocalDateTime? = xml.childOrNull(property.name)?.value()?.let { LocalDateTime.parse(it, format) } + open operator fun getValue(thisRef: Any?, property: KProperty<*>): LocalDateTime? { + var inputString : String? = xml.childOrNull(property.name)?.value()?.replace("_","0") + if (inputString?.length == 16) inputString = inputString.plus(":00") + return inputString?.let { LocalDateTime.parse(it /*, format*/) } // CB TODO format handling + } + //** + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: LocalDateTime?) { value?.let { xml.child(property.name).textContent = value?.let { value.toString() /* format.format(value)*/ } } } +} + +open class DateTimeXmlDelegate(val xml: Element, formatString: String = ISO_LOCAL_DATETIME_FORMAT) { + private val format = dateTimeFormat(formatString) + //FB TODO ** To rewrite + //open operator fun getValue(thisRef: Any?, property: KProperty<*>): LocalDateTime? = xml.childOrNull(property.name)?.value()?.let { LocalDateTime.parse(it, format) } + open operator fun getValue(thisRef: Any?, property: KProperty<*>): LocalDateTime { + var inputString : String? = xml.childOrNull(property.name)?.value()?.replace("_","0") + if (inputString?.length == 16) inputString = inputString.plus(":00") + return inputString?.let { LocalDateTime.parse(it /*, format*/) } ?: error(property.name) // CB TODO format handling + } + //** + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: LocalDateTime) { value?.let { xml.child(property.name).textContent = value?.let { value.toString() /* format.format(value)*/ } } } +} + +fun KClass.instantiate(content: T): F = constructors.first().call(content) + +open class ObjectXmlDelegate (val xml: Node, private val klass: KClass) { + operator fun getValue(thisRef: Any?, property: KProperty<*>): T { + val obj = xml.element().child(property.name) + return obj.let { + klass.instantiate(it) + } + } +} + +// standard types attributes delegates +open class OptionalStringXmlAttrDelegate(val xml: Element) { + open operator fun getValue(thisRef: Any?, property: KProperty<*>): String? = xml.attr(property.name) + open operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String?) { value?.let { xml.setAttr(property.name, value) } } +} + +open class StringXmlAttrDelegate(val xml: Element) { + open operator fun getValue(thisRef: Any?, property: KProperty<*>): String = xml.attr(property.name) ?: error(property.name) + open operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) { value?.let { xml.setAttr(property.name, value) } } +} + +open class OptionalBooleanXmlAttrDelegate(val xml: Element) { + open operator fun getValue(thisRef: Any?, property: KProperty<*>): Boolean? = xml.boolAttr(property.name) + open operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Boolean?) { value?.let { xml.setAttr(property.name, value) } } +} + +open class BooleanXmlAttrDelegate(val xml: Element) { + open operator fun getValue(thisRef: Any?, property: KProperty<*>): Boolean = xml.boolAttr(property.name) ?: error(property.name) + open operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Boolean) { value?.let { xml.setAttr(property.name, value) } } +} + +open class OptionalIntXmlAttrDelegate(val xml: Element) { + open operator fun getValue(thisRef: Any?, property: KProperty<*>): Int? = xml.intAttr(property.name) + open operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Int?) { value?.let { xml.setAttr(property.name, value) } } +} + +open class IntXmlAttrDelegate(val xml: Element) { + open operator fun getValue(thisRef: Any?, property: KProperty<*>): Int = xml.intAttr(property.name) ?: error(property.name) + open operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) { value?.let { xml.setAttr(property.name, value) } } +} + +open class OptionalLongXmlAttrDelegate(val xml: Element) { + open operator fun getValue(thisRef: Any?, property: KProperty<*>): Long? = xml.longAttr(property.name) + open operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Long?) { value?.let { xml.setAttr(property.name, value) } } +} + +open class LongXmlAttrDelegate(val xml: Element) { + open operator fun getValue(thisRef: Any?, property: KProperty<*>): Long = xml.longAttr(property.name) ?: error(property.name) + open operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Long) { value?.let { xml.setAttr(property.name, value) } } +} + +open class OptionalDoubleXmlAttrDelegate(val xml: Element) { + open operator fun getValue(thisRef: Any?, property: KProperty<*>): Double? = xml.doubleAttr(property.name) + open operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Double?) { value?.let { xml.setAttr(property.name, value) } } +} + +open class DoubleXmlAttrDelegate(val xml: Element) { + open operator fun getValue(thisRef: Any?, property: KProperty<*>): Double = xml.doubleAttr(property.name) ?: error(property.name) + open operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Double) { value?.let { xml.setAttr(property.name, value) } } +} + +open class OptionalDateXmlAttrDelegate(val xml: Element, formatString: String = ISO_LOCAL_DATE_FORMAT) { + private val format = dateTimeFormat(formatString) + open operator fun getValue(thisRef: Any?, property: KProperty<*>): LocalDate? = xml.attr(property.name)?.let { LocalDate.parse(it/*, format*/) } + open operator fun setValue(thisRef: Any?, property: KProperty<*>, value: LocalDate?) { value?.let { xml.setAttr(property.name, value.toString() /* format.format(value) */) } } +} + +open class DateXmlAttrDelegate(val xml: Element, formatString: String = ISO_LOCAL_DATE_FORMAT) { + private val format = dateTimeFormat(formatString) + open operator fun getValue(thisRef: Any?, property: KProperty<*>): LocalDate = xml.attr(property.name)?.let { LocalDate.parse(it/*, format*/) } ?: error(property.name) + open operator fun setValue(thisRef: Any?, property: KProperty<*>, value: LocalDate) { value?.let { xml.setAttr(property.name, value.toString() /* format.format(value) */) } } +} + +// containers delegates + +open class XmlArrayAdapter(val parent: Node, protected val functor: (Node)->T): List { + override val size = (parent as ElementTraversal).childElementCount + override fun contains(element: T): Boolean { throw Error("not implemented") } + override fun containsAll(elements: Collection): Boolean { throw Error("not implemented") } + override fun get(index: Int): T { + try { + return functor(parent.element().children()[index]) + } catch (e: Exception) { + throw Error("could not get child element", e) + } + } + override fun indexOf(element: T): Int { throw Error("not implemented") } + override fun isEmpty() = parent.childNodes.length == 0 + override fun iterator(): Iterator = object: Iterator { + private val it = parent.element().children().iterator() + override fun hasNext() = it.hasNext() + override fun next() = functor(it.next()) + } + override fun lastIndexOf(element: T): Int { throw Error("not implemented") } + override fun listIterator() = listIterator(0) + override fun listIterator(index: Int): ListIterator { throw Error("not implemented") } + override fun subList(fromIndex: Int, toIndex: Int): List { throw Error("not implemented") } +} + +class MutableXmlArrayAdapter(parent: Node, functor: (Node)->T): XmlArrayAdapter(parent, functor), MutableList { + override fun iterator(): MutableIterator = object: MutableIterator { + private val it = parent.element().children().iterator() + override fun hasNext() = it.hasNext() + override fun next() = functor(it.next()) + override fun remove() { throw Error("not implemented") } + } + override fun add(element: T): Boolean { parent.appendChild(element.xml); return true } + override fun add(index: Int, element: T) { throw Error("not implemented") } + override fun addAll(index: Int, elements: Collection): Boolean { throw Error("not implemented") } + override fun addAll(elements: Collection): Boolean { throw Error("not implemented") } + override fun clear() { + while (parent.firstChild != null) { + parent.removeChild(parent.firstChild) + } + } + override fun listIterator() = listIterator(0) + override fun listIterator(index: Int): MutableListIterator { throw Error("not implemented") } + override fun remove(element: T): Boolean { throw Error("not implemented") } + override fun removeAll(elements: Collection): Boolean { throw Error("not implemented") } + override fun removeAt(index: Int): T { throw Error("not implemented") } + override fun retainAll(elements: Collection): Boolean { throw Error("not implemented") } + override fun set(index: Int, element: T): T { throw Error("not implemented") } + override fun subList(fromIndex: Int, toIndex: Int): MutableList { + throw NotImplementedError("Not implemented") + } +} + +open class XmlArrayInlineAdapter(val list: NodeList, protected val functor: (Node)->T): List { + override val size = list.length + override fun contains(element: T): Boolean { throw Error("not implemented") } + override fun containsAll(elements: Collection): Boolean { throw Error("not implemented") } + override fun get(index: Int): T { + try { + return functor(list[index]) + } catch (e: Exception) { + throw Error("could not get child element", e) + } + } + override fun indexOf(element: T): Int { throw Error("not implemented") } + override fun isEmpty() = list.length == 0 + override fun iterator(): Iterator = object: Iterator { + private val it = list.iterator() + override fun hasNext() = it.hasNext() + override fun next() = functor(it.next()) + } + override fun lastIndexOf(element: T): Int { throw Error("not implemented") } + override fun listIterator() = listIterator(0) + override fun listIterator(index: Int): ListIterator { throw Error("not implemented") } + override fun subList(fromIndex: Int, toIndex: Int): List { throw Error("not implemented") } +} + +inline fun MutableXmlArrayAdapter.newChild(): T { + val node = parent.document().createElement(T::class.simpleName!!.lowercase()) + // should be done explicitely in client code + // parent.element().appendChild(node) + return T::class.instantiate(node) +} + +open class ArrayXmlDelegate (val xml: Node, private val klass: KClass) { + operator fun getValue(thisRef: Any?, property: KProperty<*>): List = + XmlArrayAdapter(xml.element().child(property.name), klass::instantiate) +} + +open class MutableArrayXmlDelegate (val xml: Node, private val klass: KClass) { + operator fun getValue(thisRef: Any?, property: KProperty<*>): MutableXmlArrayAdapter = + MutableXmlArrayAdapter(xml.element().child(property.name), klass::instantiate) +} + +open class ChildrenArrayXmlDelegate (val xml: Node, private val tagName: String, private val klass: KClass) { + operator fun getValue(thisRef: Any?, property: KProperty<*>): List = XmlArrayInlineAdapter(xml.element().getElementsByTagName(tagName), klass::instantiate) +} diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/util/XmlUtils.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/util/XmlUtils.kt new file mode 100644 index 0000000..f9fa6e8 --- /dev/null +++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/util/XmlUtils.kt @@ -0,0 +1,558 @@ +package org.jeudego.pairgoth.util + +import org.apache.commons.lang3.StringEscapeUtils +import org.slf4j.LoggerFactory +import org.w3c.dom.* +import org.xml.sax.ErrorHandler +import org.xml.sax.InputSource +import org.xml.sax.SAXException +import org.xml.sax.SAXParseException +import java.io.Reader +import java.io.StringReader +import java.io.StringWriter +import java.lang.ref.SoftReference +import java.nio.charset.Charset +import java.util.* +import java.util.concurrent.LinkedBlockingDeque +import javax.xml.XMLConstants +import javax.xml.parsers.DocumentBuilder +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.parsers.ParserConfigurationException +import javax.xml.transform.OutputKeys +import javax.xml.transform.TransformerException +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult +import javax.xml.xpath.XPathConstants +import javax.xml.xpath.XPathExpressionException +import javax.xml.xpath.XPathFactory + + +/** + * + * Utility class for simplifying parsing of xml documents. Documents are not validated, and + * loading of external files (xinclude, external entities, DTDs, etc.) are disabled. + * + * @author Claude Brisson + */ +object XmlUtils { + /* several pieces of code were borrowed from the Apache Shindig XmlUtil class.*/ + private val LOGGER = LoggerFactory.getLogger(XmlUtils::class.java) + + /** + * Handles xml errors so that they're not logged to stderr. + */ + private val errorHandler: ErrorHandler = object : ErrorHandler { + @Throws(SAXException::class) + override fun error(exception: SAXParseException) { + throw exception + } + + @Throws(SAXException::class) + override fun fatalError(exception: SAXParseException) { + throw exception + } + + override fun warning(exception: SAXParseException) { + LOGGER.info("warning during parsing", exception) + } + } + + private var canReuseBuilders = false + private val builderFactory = createDocumentBuilderFactory() + private fun createDocumentBuilderFactory(): DocumentBuilderFactory { + val builderFactory = DocumentBuilderFactory.newInstance() + // Namespace support is required for elements + builderFactory.isNamespaceAware = true + + // Disable various insecure and/or expensive options. + builderFactory.isValidating = false + + // Can't disable doctypes entirely because they're usually harmless. External entity + // resolution, however, is both expensive and insecure. + try { + builderFactory.setAttribute("http://xml.org/sax/features/external-general-entities", false) + } catch (e: IllegalArgumentException) { + // Not supported by some very old parsers. + LOGGER.info("Error parsing external general entities: ", e) + } + try { + builderFactory.setAttribute("http://xml.org/sax/features/external-parameter-entities", false) + } catch (e: IllegalArgumentException) { + // Not supported by some very old parsers. + LOGGER.info("Error parsing external parameter entities: ", e) + } + try { + builderFactory.setAttribute("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) + } catch (e: IllegalArgumentException) { + // Only supported by Apache's XML parsers. + LOGGER.info("Error parsing external DTD: ", e) + } + try { + builderFactory.setAttribute(XMLConstants.FEATURE_SECURE_PROCESSING, true) + } catch (e: IllegalArgumentException) { + // Not supported by older parsers. + LOGGER.info("Error parsing secure XML: ", e) + } + return builderFactory + } + + private val reusableBuilder: ThreadLocal = object : ThreadLocal() { + override fun initialValue(): DocumentBuilder { + return try { + LOGGER.trace("Created a new document builder") + builderFactory.newDocumentBuilder() + } catch (e: ParserConfigurationException) { + throw Error(e) + } + } + } + + init { + try { + val builder = builderFactory.newDocumentBuilder() + builder.reset() + canReuseBuilders = true + LOGGER.trace("reusing document builders") + } catch (e: UnsupportedOperationException) { + // Only supported by newer parsers (xerces 2.8.x+ for instance). + canReuseBuilders = false + LOGGER.trace("not reusing document builders") + } catch (e: ParserConfigurationException) { + // Only supported by newer parsers (xerces 2.8.x+ for instance). + canReuseBuilders = false + LOGGER.trace("not reusing document builders") + } + } + + private val builderPool = LinkedBlockingDeque>() // contains only idle builders + private val maxBuildersCount = 100 + private var currentBuildersCount = 0 + + /** + * Get a document builder + * @return document builder + */ + @Synchronized + private fun getDocumentBuilder(): DocumentBuilder { + var builder: DocumentBuilder? = null + if (canReuseBuilders && builderPool.size > 0) { + builder = builderPool.pollFirst().get() + } + if (builder == null) { + if (!canReuseBuilders || currentBuildersCount < maxBuildersCount) { + try { + builder = builderFactory.newDocumentBuilder() + builder.setErrorHandler(errorHandler) + ++currentBuildersCount + } catch (e: Exception) { + /* this is a fatal error */ + throw Error("could not create a new XML DocumentBuilder instance", e) + } + } else { + try { + LOGGER.warn( + "reached XML DocumentBuilder pool size limit, current thread needs to wait", + ) + builder = builderPool.takeFirst().get() + } catch (ie: InterruptedException) { + LOGGER.warn("caught an InterruptedException while waiting for a DocumentBuilder instance") + } + } + } + return builder ?: throw Error("could not create a new XML DocumentBuilder instance") + } + + /** + * Release the given document builder + * @param builder document builder + */ + @Synchronized + private fun releaseBuilder(builder: DocumentBuilder?) { + builder!!.reset() + builderPool.addLast(SoftReference(builder)) + } + + /** + * Creates an empty document + */ + fun createDocument(): Document { + val builder = getDocumentBuilder() + val doc = builder.newDocument() + releaseBuilder(builder) + return doc + } + + /** + * Extracts an attribute from a node. + * + * @param node target node + * @param attr attribute name + * @param def default value + * @return The value of the attribute, or def + */ + fun getAttribute(node: Node, attr: String?, def: String?): String? { + val attrs = node.attributes + val `val` = attrs.getNamedItem(attr) + return if (`val` != null) { + `val`.nodeValue + } else def + } + + /** + * @param node target node + * @param attr attribute name + * @return The value of the given attribute, or null if not present. + */ + fun getAttribute(node: Node, attr: String?): String? { + return getAttribute(node, attr, null) + } + + /** + * Retrieves an attribute as a boolean. + * + * @param node target node + * @param attr attribute name + * @param def default value + * @return True if the attribute exists and is not equal to "false" + * false if equal to "false", and def if not present. + */ + fun getBoolAttribute(node: Node, attr: String?, def: Boolean): Boolean { + val value = getAttribute(node, attr) ?: return def + return java.lang.Boolean.parseBoolean(value) + } + + /** + * @param node target node + * @param attr attribute name + * @return True if the attribute exists and is not equal to "false" + * false otherwise. + */ + fun getBoolAttribute(node: Node, attr: String?): Boolean { + return getBoolAttribute(node, attr, false) + } + + /** + * @param node target node + * @param attr attribute name + * @param def default value + * @return An attribute coerced to an integer. + */ + fun getIntAttribute(node: Node, attr: String?, def: Int): Int { + val value = getAttribute(node, attr) ?: return def + return try { + value.toInt() + } catch (e: NumberFormatException) { + def + } + } + + /** + * @param node target node + * @param attr attribute name + * @return An attribute coerced to an integer. + */ + fun getIntAttribute(node: Node, attr: String?): Int { + return getIntAttribute(node, attr, 0) + } + + /** + * Attempts to parse the input xml into a single element. + * @param xml xml stream reader + * @return The document object + */ + fun parse(xml: Reader): Element { + val builder = getDocumentBuilder() + try { + val doc = builder.parse(InputSource(xml)) + return doc.documentElement + } finally { + releaseBuilder(builder) + } + } + + /** + * Attempts to parse the input xml into a single element. + * @param xml xml string + * @return The document object + */ + fun parse(xml: String): Element = parse(StringReader(xml)) + + /** + * Search for nodes using an XPath expression + * @param xpath XPath expression + * @param context evaluation context + * @return org.w3c.NodeList of found nodes + * @throws XPathExpressionException + */ + @Throws(XPathExpressionException::class) + fun search(xpath: String?, context: Node?): NodeList { + var ret: NodeList? = null + val xp = XPathFactory.newInstance().newXPath() + val exp = xp.compile(xpath) + ret = exp.evaluate(context, XPathConstants.NODESET) as NodeList + return ret + } + + /** + * Search for nodes using an XPath expression + * @param xpath XPath expression + * @param context evaluation context + * @return List of found nodes + * @throws XPathExpressionException + */ + @Throws(XPathExpressionException::class) + fun getNodes(xpath: String?, context: Node?): List { + val ret: MutableList = ArrayList() + val lst = search(xpath, context) + for (i in 0 until lst!!.length) { + ret.add(lst.item(i)) + } + return ret + } + + /** + * Search for elements using an XPath expression + * @param xpath XPath expression + * @param context evaluation context + * @return List of found elements + * @throws XPathExpressionException + */ + @Throws(XPathExpressionException::class) + fun getElements(xpath: String?, context: Node?): List { + val ret: MutableList = ArrayList() + val lst = search(xpath, context) + for (i in 0 until lst!!.length) { + // will throw a ClassCastExpression if Node is not an Element, + // that's what we want + ret.add(lst.item(i) as Element) + } + return ret + } + + /** + * + * Builds the xpath expression for a node (tries to use id/name nodes when possible to get a unique path) + * @param n target node + * @return node xpath + */ + // (borrow from http://stackoverflow.com/questions/5046174/get-xpath-from-the-org-w3c-dom-node ) + fun nodePath(n: Node): String { + + // declarations + var parent: Node? = null + val hierarchy = Stack() + val buffer = StringBuffer("/") + + // push element on stack + hierarchy.push(n) + parent = when (n.nodeType) { + Node.ATTRIBUTE_NODE -> (n as Attr).ownerElement + Node.COMMENT_NODE, Node.ELEMENT_NODE, Node.DOCUMENT_NODE -> n.parentNode + else -> throw IllegalStateException("Unexpected Node type" + n.nodeType) + } + while (null != parent && parent.nodeType != Node.DOCUMENT_NODE) { + // push on stack + hierarchy.push(parent) + + // get parent of parent + parent = parent.parentNode + } + + // construct xpath + var obj: Any? = null + while (!hierarchy.isEmpty() && null != hierarchy.pop().also { obj = it }) { + val node = obj as Node? + var handled = false + if (node!!.nodeType == Node.ELEMENT_NODE) { + val e = node as Element? + + // is this the root element? + if (buffer.length == 1) { + // root element - simply append element name + buffer.append(node.nodeName) + } else { + // child element - append slash and element name + buffer.append("/") + buffer.append(node.nodeName) + if (node.hasAttributes()) { + // see if the element has a name or id attribute + if (e!!.hasAttribute("id")) { + // id attribute found - use that + buffer.append("[@id='" + e.getAttribute("id") + "']") + handled = true + } else if (e.hasAttribute("name")) { + // name attribute found - use that + buffer.append("[@name='" + e.getAttribute("name") + "']") + handled = true + } + } + if (!handled) { + // no known attribute we could use - get sibling index + var prev_siblings = 1 + var prev_sibling = node.previousSibling + while (null != prev_sibling) { + if (prev_sibling.nodeType == node.nodeType) { + if (prev_sibling.nodeName.equals( + node.nodeName, ignoreCase = true + ) + ) { + prev_siblings++ + } + } + prev_sibling = prev_sibling.previousSibling + } + buffer.append("[$prev_siblings]") + } + } + } else if (node.nodeType == Node.ATTRIBUTE_NODE) { + buffer.append("/@") + buffer.append(node.nodeName) + } + } + // return buffer + return buffer.toString() + } + + /** + * XML Node to string + * @param node XML node + * @return XML node string representation + */ + fun nodeToString(node: Node?, encoding: Charset = Charsets.UTF_8): String { + val sw = StringWriter() + try { + val t = TransformerFactory.newInstance().newTransformer() + t.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no") + t.setOutputProperty(OutputKeys.INDENT, "no") + t.setOutputProperty(OutputKeys.ENCODING, encoding.name()) + t.transform(DOMSource(node), StreamResult(sw)) + } catch (te: TransformerException) { + LOGGER.error("could not convert XML node to string", te) + } + return sw.toString() + } + + /** + * XML Node to string + * @param node XML node + * @return XML node string representation + */ + fun nodeToPrettyString(node: Node, encoding: Charset = Charsets.UTF_8): String { + val sw = StringWriter() + try { + val t = TransformerFactory.newInstance().newTransformer() + t.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no") + t.setOutputProperty(OutputKeys.INDENT, "yes") + t.setOutputProperty(OutputKeys.ENCODING, encoding.name()) + t.transform(DOMSource(node), StreamResult(sw)) + } catch (te: TransformerException) { + LOGGER.error("could not convert XML node to string", te) + } + return sw.toString() + } + + /** + * Checkes whether the given mime type is an XML format + * @param mimeType mime type + * @return `true` if this mime type is an XML format + */ + fun isXmlMimeType(mimeType: String?): Boolean { + return mimeType != null && + ("text/xml" == mimeType || "application/xml" == mimeType || + mimeType.endsWith("+xml")) + } +} + +// utility extension functions + +fun emptyDocument(root: String) = XmlUtils.createDocument().also { it.appendChild(it.createElement(root)) } + +fun Node.element(): Element = + when (this) { + is Element -> this + is Document -> documentElement + else -> throw Error("invalid xml node") + } + +fun Element.children(): List { + val ret = mutableListOf() + for (i in 0..childNodes.length) { + val child = childNodes[i] + if (child is Element) ret.add(child) + } + return ret +} + +fun Node.document(): Document = ownerDocument ?: this as Document + +fun Element.childOrNull(key: String): Element? = children().firstOrNull { it.tagName == key } + +fun Element.child(key: String): Element = childOrNull(key) ?: addChild(key) + +fun Node.addChild(tag: String): Element = appendChild(document().createElement(tag)) as Element + +fun Node.value(): String? = textContent.let { if (it.isEmpty()) null else it } + +fun Node.attr(key: String, def: String? = null) = attributes.getNamedItem(key)?.nodeValue ?: def + +fun Element.setAttr(key: String, value: Any) { + setAttribute(key, value.toString()) +} + +fun Node.boolAttr(key: String, def: Boolean? = null) = attr(key)?.toBoolean() ?: def + +fun Node.intAttr(key: String, def: Int? = null) = attr(key)?.toInt() ?: def + +fun Node.longAttr(key: String, def: Long? = null) = attr(key)?.toLong() ?: def + +fun Node.doubleAttr(key: String, def: Double? = null) = attr(key)?.toDouble() ?: def + +fun Node.path() = XmlUtils.nodePath(this) + +fun Node.find(xpath: String): NodeList { + return XPathFactory.newInstance().newXPath().compile(xpath).evaluate(this, XPathConstants.NODESET) as NodeList +} + +fun Node.print(encoding: Charset = Charsets.UTF_8) : String { + trimTextNodes() + return XmlUtils.nodeToString(this, encoding) + /* previous implementation, without charset + val domImplLS = document().implementation as DOMImplementationLS + val serializer = domImplLS.createLSSerializer() + return serializer.writeToString(this) + */ +} + +fun Node.trimTextNodes() { + val children: NodeList = getChildNodes() + for (i in 0 until children.length) { + val child = children.item(i) + if (child.nodeType === Node.TEXT_NODE) { + child.textContent = child.textContent.trim() + } + else child.trimTextNodes() + } +} + +fun Node.prettyPrint(encoding: Charset = Charsets.UTF_8): String { + trimTextNodes() + return XmlUtils.nodeToPrettyString(this, encoding) +} + + +// node list iteration and random access + +class NodeListIterator(private val lst: NodeList): Iterator { + private var nextPos = 0 + override fun hasNext() = nextPos < lst.length + override fun next() = lst.item(nextPos++) +} + +operator fun NodeList.iterator() = NodeListIterator(this) + +operator fun NodeList.get(i: Int) = item(i) + +// Encode XML entities in a string +fun String.encodeXmlEntities() = StringEscapeUtils.escapeXml(this) + diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/web/ApiServlet.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/web/ApiServlet.kt index d2e6fff..e4ba9cc 100644 --- a/webapp/src/main/kotlin/org/jeudego/pairgoth/web/ApiServlet.kt +++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/web/ApiServlet.kt @@ -5,11 +5,14 @@ import org.jeudego.pairgoth.api.ApiHandler import org.jeudego.pairgoth.api.PairingHandler import org.jeudego.pairgoth.api.PlayerHandler import org.jeudego.pairgoth.api.TournamentHandler +import org.jeudego.pairgoth.util.Colorizer.blue import org.jeudego.pairgoth.util.Colorizer.green import org.jeudego.pairgoth.util.Colorizer.red +import org.jeudego.pairgoth.util.XmlUtils import org.jeudego.pairgoth.util.parse import org.jeudego.pairgoth.util.toString import org.slf4j.LoggerFactory +import org.w3c.dom.Element import java.io.IOException import java.util.* import java.util.concurrent.locks.ReadWriteLock @@ -60,8 +63,8 @@ class ApiServlet : HttpServlet() { if ("dev" == WebappManager.getProperty("webapp.env")) { response.addHeader("Access-Control-Allow-Origin", "*") } - validateContentType(request) validateAccept(request); + validateContentType(request) // parse request URI @@ -169,22 +172,36 @@ class ApiServlet : HttpServlet() { ) // check content type - if (!isJson(mimeType)) throw ApiException( + if (isJson(mimeType)) { + // put Json body as request attribute + try { + Json.parse(request.reader)?.let { payload: Json -> + request.setAttribute(ApiHandler.PAYLOAD_KEY, payload) + if (logger.isInfoEnabled) { + logger.logPayload("<< ", payload, true) + } + } + } catch (ioe: IOException) { + throw ApiException(HttpServletResponse.SC_BAD_REQUEST, ioe) + } + } else if (isXml(mimeType)) { + // some API calls like opengotha import accept xml docs as body + // CB TODO - limit to those calls + try { + XmlUtils.parse(request.reader)?.let { payload: Element -> + request.setAttribute(ApiHandler.PAYLOAD_KEY, payload) + logger.info(blue("<< (xml document)")) + + } + } catch(ioe: IOException) { + throw ApiException(HttpServletResponse.SC_BAD_REQUEST, ioe) + } + } + else throw ApiException( HttpServletResponse.SC_BAD_REQUEST, "JSON content expected" ) - // put Json body as request attribute - try { - Json.parse(request.reader)?.let { payload: Json -> - request.setAttribute(ApiHandler.PAYLOAD_KEY, payload) - if (logger.isInfoEnabled) { - logger.logPayload("<< ", payload, true) - } - } - } catch (ioe: IOException) { - throw ApiException(HttpServletResponse.SC_BAD_REQUEST, ioe) - } } @Throws(ApiException::class) @@ -241,9 +258,7 @@ class ApiServlet : HttpServlet() { private const val EXPECTED_CHARSET = "utf8" const val AUTH_HEADER = "Authorization" const val AUTH_PREFIX = "Bearer" - private fun isJson(mimeType: String): Boolean { - return "text/json" == mimeType || "application/json" == mimeType || - mimeType.endsWith("+json") - } + private fun isJson(mimeType: String) = "text/json" == mimeType || "application/json" == mimeType || mimeType.endsWith("+json") + private fun isXml(mimeType: String) = "text/xml" == mimeType || "application/xml" == mimeType || mimeType.endsWith("+xml") } } diff --git a/webapp/src/test/kotlin/ImportTests.kt b/webapp/src/test/kotlin/ImportTests.kt new file mode 100644 index 0000000..324c37f --- /dev/null +++ b/webapp/src/test/kotlin/ImportTests.kt @@ -0,0 +1,20 @@ +package org.jeudego.pairgoth.test + +import org.junit.jupiter.api.Test +import java.io.InputStreamReader +import java.nio.charset.StandardCharsets + +class ImportTests: TestBase() { + + @Test + fun `001 test imports`() { + getTestResources("opengotha").forEach { file -> + logger.info("reading resource ${file.canonicalPath}") + val resource = file.readText(StandardCharsets.UTF_8) + val resp = TestAPI.post("/api/tour", resource) + val id = resp.asObject().getInt("id") + val tournament = TestAPI.get("/api/tour/$id") + logger.info(tournament.toString()) + } + } +} \ No newline at end of file diff --git a/webapp/src/test/kotlin/TestBase.kt b/webapp/src/test/kotlin/TestBase.kt index c77cb07..7fcbefd 100644 --- a/webapp/src/test/kotlin/TestBase.kt +++ b/webapp/src/test/kotlin/TestBase.kt @@ -4,22 +4,21 @@ import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.TestInfo import org.slf4j.LoggerFactory +import java.io.File abstract class TestBase { companion object { val logger = LoggerFactory.getLogger("test") - private var testClassName: String? = null @BeforeAll @JvmStatic fun prepare() { - testClassName = this::class.simpleName } } @BeforeEach fun before(testInfo: TestInfo) { val testName = testInfo.displayName.removeSuffix("()") - logger.info("===== Running $testClassName.$testName =====") + logger.info("===== Running $testName =====") } } \ No newline at end of file diff --git a/webapp/src/test/kotlin/TestUtils.kt b/webapp/src/test/kotlin/TestUtils.kt index a4e7166..ab2b22d 100644 --- a/webapp/src/test/kotlin/TestUtils.kt +++ b/webapp/src/test/kotlin/TestUtils.kt @@ -6,25 +6,33 @@ import org.jeudego.pairgoth.web.ApiServlet import org.jeudego.pairgoth.web.WebappManager import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer -import org.mockito.kotlin.doNothing import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock import java.io.BufferedReader +import java.io.File +import java.io.IOException import java.io.PrintWriter import java.io.StringReader import java.io.StringWriter import java.util.* +import java.util.regex.Pattern +import java.util.zip.ZipEntry +import java.util.zip.ZipException +import java.util.zip.ZipFile import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse + +// J2EE server basic mocking + object TestAPI { fun Any?.toUnit() = Unit private val apiServlet = ApiServlet() - private fun testRequest(reqMethod: String, uri: String, payload: Json? = null): Json { + private fun testRequest(reqMethod: String, uri: String, payload: T? = null): Json { WebappManager.properties["webapp.env"] = "test" @@ -48,7 +56,11 @@ object TestAPI { on { localName } doReturn "pairgoth" on { localPort } doReturn 80 on { contextPath } doReturn "" - on { contentType } doReturn if (reqMethod == "GET") null else "application/json; charset=UTF-8" + on { contentType } doReturn if (reqMethod == "GET") null else when (payload) { + is Json -> "application/json; charset=UTF-8" + is String -> "application/xml; charset=UTF-8" + else -> throw Error("unhandled case") + } on { headerNames } doReturn Collections.enumeration(myHeaderNames) on { getHeader(eq("Accept")) } doReturn "application/json" } @@ -72,12 +84,12 @@ object TestAPI { return Json.parse(buffer.toString()) ?: throw Error("no response payload") } - fun get(uri: String) = testRequest("GET", uri) - fun post(uri: String, payload: Json) = testRequest("POST", uri, payload) - fun put(uri: String, payload: Json) = testRequest("PUT", uri, payload) - fun delete(uri: String, payload: Json) = testRequest("DELETE", uri, payload) + fun get(uri: String) = testRequest("GET", uri) + fun post(uri: String, payload: T) = testRequest("POST", uri, payload) + fun put(uri: String, payload: T) = testRequest("PUT", uri, payload) + fun delete(uri: String, payload: T) = testRequest("DELETE", uri, payload) } -fun expectSuccess() { +// Get a list of resources -} \ No newline at end of file +fun getTestResources(path: String) = File("${System.getProperty("user.dir")}/src/test/resources/$path").listFiles()