Skip to content

Commit 1936f77

Browse files
committed
feat(config): migrate to jtoml and refactor TOML parsing logic
- Replaced custom TOML parsing code with `jtoml` library for improved maintainability and consistency. - Refactored configuration loader to use `jtoml` API. - Added inline `sources` array support for `s3Uploads` and `gcsUploads` in test configurations. - Updated build configuration to include `jtoml` as a dependency.
1 parent 694e7c8 commit 1936f77

3 files changed

Lines changed: 84 additions & 117 deletions

File tree

core/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ plugins {
66
dependencies {
77
implementation(libs.bouncycastlePkix)
88
implementation(libs.bouncycastleProvider)
9+
implementation(libs.jtoml)
910
api(libs.testcontainers)
1011
api(libs.testcontainersPostgresql)
1112
api(libs.testcontainersMysql)

core/src/main/kotlin/org/openprojectx/bigdata/test/core/config/BigDataTestConfig.kt

Lines changed: 77 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package org.openprojectx.bigdata.test.core.config
22

3+
import io.ous.jtoml.JToml
4+
import java.math.BigDecimal
35
import java.nio.file.Files
46
import java.nio.file.Path
7+
import java.util.Date
58
import org.openprojectx.bigdata.test.core.BigDataHealthCheckMode
69
import org.openprojectx.bigdata.test.core.BigDataHealthCheckOptions
710
import org.openprojectx.bigdata.test.core.BigDataService
@@ -323,7 +326,7 @@ class BigDataTestConfigLoader(
323326
}
324327

325328
fun load(location: String): BigDataTestConfig {
326-
val tables = parseTables(readText(location))
329+
val tables = TomlConfigParser.parseTables(readText(location))
327330
val images = tables["images"].orEmpty()
328331
val services = tables["services"].orEmpty()
329332
val kerberos = tables["kerberos"].orEmpty()
@@ -466,84 +469,7 @@ class BigDataTestConfigLoader(
466469
else -> Files.readString(Path.of(location))
467470
}
468471

469-
private fun parseTables(text: String): Map<String, Map<String, TomlValue>> {
470-
val tables = linkedMapOf<String, MutableMap<String, TomlValue>>()
471-
var table = ""
472-
tables[table] = linkedMapOf()
473-
text.lineSequence().forEachIndexed { index, raw ->
474-
val line = stripComment(raw).trim()
475-
if (line.isBlank()) return@forEachIndexed
476-
if (line.startsWith("[") && line.endsWith("]")) {
477-
table = line.removePrefix("[").removeSuffix("]").trim()
478-
require(table.isNotBlank()) { "TOML table name is blank at line ${index + 1}" }
479-
tables.getOrPut(table) { linkedMapOf() }
480-
return@forEachIndexed
481-
}
482-
val separator = line.indexOf('=')
483-
require(separator > 0) { "TOML line ${index + 1}: expected key = value" }
484-
val key = parseKey(line.substring(0, separator).trim(), index + 1)
485-
val value = line.substring(separator + 1).trim()
486-
tables.getValue(table)[key] = parseValue(value, index + 1)
487-
}
488-
return tables
489-
}
490-
491-
private fun stripComment(raw: String): String {
492-
var inString = false
493-
var escaped = false
494-
raw.forEachIndexed { index, char ->
495-
when {
496-
escaped -> escaped = false
497-
char == '\\' && inString -> escaped = true
498-
char == '"' -> inString = !inString
499-
char == '#' && !inString -> return raw.substring(0, index)
500-
}
501-
}
502-
return raw
503-
}
504-
505-
private fun parseKey(key: String, line: Int): String =
506-
if (key.startsWith('"')) parseString(key, line) else key
507-
508-
private fun parseValue(value: String, line: Int): TomlValue =
509-
when {
510-
value.startsWith('"') -> TomlValue.StringValue(parseString(value, line))
511-
value == "true" -> TomlValue.BooleanValue(true)
512-
value == "false" -> TomlValue.BooleanValue(false)
513-
value.matches(Regex("[0-9]+")) -> TomlValue.IntValue(value.toInt())
514-
else -> error("TOML line $line: unsupported value '$value'")
515-
}
516-
517-
private fun parseString(value: String, line: Int): String {
518-
require(value.length >= 2 && value.first() == '"' && value.last() == '"') {
519-
"TOML line $line: string values must be quoted"
520-
}
521-
return buildString {
522-
var escaped = false
523-
value.substring(1, value.length - 1).forEach { char ->
524-
if (escaped) {
525-
append(
526-
when (char) {
527-
'"' -> '"'
528-
'\\' -> '\\'
529-
'n' -> '\n'
530-
'r' -> '\r'
531-
't' -> '\t'
532-
else -> char
533-
},
534-
)
535-
escaped = false
536-
} else if (char == '\\') {
537-
escaped = true
538-
} else {
539-
append(char)
540-
}
541-
}
542-
require(!escaped) { "TOML line $line: unterminated escape in string" }
543-
}
544-
}
545-
546-
private fun httpTls(values: Map<String, TomlValue>): BigDataTestHttpTlsConfig =
472+
private fun httpTls(values: Map<String, Any?>): BigDataTestHttpTlsConfig =
547473
BigDataTestHttpTlsConfig(
548474
enabled = values.boolean("enabled"),
549475
domain = values.string("domain"),
@@ -564,7 +490,7 @@ class BigDataTestConfigLoader(
564490
}
565491

566492
private fun parseContainerCustomizations(
567-
tables: Map<String, Map<String, TomlValue>>,
493+
tables: Map<String, Map<String, Any?>>,
568494
): Map<BigDataService, ContainerCustomizationOptions> {
569495
val customizations = linkedMapOf<BigDataService, ContainerCustomizationOptions>()
570496
tables.forEach { (table, values) ->
@@ -590,7 +516,7 @@ class BigDataTestConfigLoader(
590516
return customizations
591517
}
592518

593-
private fun parseContainerLogLevels(tables: Map<String, Map<String, TomlValue>>): Map<BigDataService, String> {
519+
private fun parseContainerLogLevels(tables: Map<String, Map<String, Any?>>): Map<BigDataService, String> {
594520
val values = tables["containerLogLevels"].orEmpty()
595521
return values.mapKeys { (serviceName, _) -> parseService(serviceName, "containerLogLevels") }
596522
.mapValues { (service, value) -> normalizeContainerLogLevel(value.asString("container log level '$service'")) }
@@ -605,7 +531,7 @@ class BigDataTestConfigLoader(
605531
return normalized
606532
}
607533

608-
private fun parseHealthChecks(tables: Map<String, Map<String, TomlValue>>): Map<BigDataService, BigDataHealthCheckOptions> {
534+
private fun parseHealthChecks(tables: Map<String, Map<String, Any?>>): Map<BigDataService, BigDataHealthCheckOptions> {
609535
val modes = tables["healthChecks"].orEmpty()
610536
val timeouts = tables["healthCheckTimeouts"].orEmpty()
611537
val healthChecks = linkedMapOf<BigDataService, BigDataHealthCheckOptions>()
@@ -625,7 +551,7 @@ class BigDataTestConfigLoader(
625551
return SERVICE_NAMES[normalized] ?: error("TOML table '$table' uses unknown service '$name'")
626552
}
627553

628-
private fun Map<String, TomlValue>.containerFiles(): List<ContainerFileTransferOptions> =
554+
private fun Map<String, Any?>.containerFiles(): List<ContainerFileTransferOptions> =
629555
map { (containerPath, value) ->
630556
val source = value.asString("container file '$containerPath'")
631557
when {
@@ -645,15 +571,15 @@ class BigDataTestConfigLoader(
645571
}
646572
}
647573

648-
private fun Map<String, TomlValue>.containerMounts(): List<ContainerMountOptions> =
574+
private fun Map<String, Any?>.containerMounts(): List<ContainerMountOptions> =
649575
map { (hostPath, value) ->
650576
val target = value.asString("container mount '$hostPath'")
651577
val readOnly = !target.endsWith(":rw")
652578
val containerPath = target.removeSuffix(":ro").removeSuffix(":rw")
653579
ContainerMountOptions(hostPath = hostPath, containerPath = containerPath, readOnly = readOnly)
654580
}
655581

656-
private fun Map<String, TomlValue>.containerPorts(table: String): List<ContainerPortOptions> =
582+
private fun Map<String, Any?>.containerPorts(table: String): List<ContainerPortOptions> =
657583
map { (containerPort, value) ->
658584
ContainerPortOptions(
659585
containerPort = containerPort.toIntOrNull()
@@ -662,41 +588,35 @@ class BigDataTestConfigLoader(
662588
)
663589
}
664590

665-
private fun Map<String, TomlValue>.stringMap(table: String): Map<String, String> =
591+
private fun Map<String, Any?>.stringMap(table: String): Map<String, String> =
666592
mapValues { (key, value) -> value.asString("TOML key '$table.$key'") }
667593

668-
private fun Map<String, TomlValue>.string(key: String): String? =
594+
private fun Map<String, Any?>.string(key: String): String? =
669595
this[key]?.let {
670-
require(it is TomlValue.StringValue) { "TOML key '$key' must be a quoted string" }
671-
it.value
596+
require(it is String) { "TOML key '$key' must be a quoted string" }
597+
it
672598
}
673599

674-
private fun Map<String, TomlValue>.boolean(key: String): Boolean? =
600+
private fun Map<String, Any?>.boolean(key: String): Boolean? =
675601
this[key]?.let {
676-
require(it is TomlValue.BooleanValue) { "TOML key '$key' must be true or false" }
677-
it.value
602+
require(it is Boolean) { "TOML key '$key' must be true or false" }
603+
it
678604
}
679605

680-
private fun Map<String, TomlValue>.int(key: String): Int? =
606+
private fun Map<String, Any?>.int(key: String): Int? =
681607
this[key]?.let {
682-
require(it is TomlValue.IntValue) { "TOML key '$key' must be an integer" }
683-
it.value
608+
require(it is Number) { "TOML key '$key' must be an integer" }
609+
it.toInt()
684610
}
685611

686-
private fun TomlValue.asString(name: String): String {
687-
require(this is TomlValue.StringValue) { "$name must be a quoted string" }
688-
return value
689-
}
690-
691-
private fun TomlValue.asInt(name: String): Int {
692-
require(this is TomlValue.IntValue) { "$name must be an integer" }
693-
return value
612+
private fun Any?.asString(name: String): String {
613+
require(this is String) { "$name must be a quoted string" }
614+
return this
694615
}
695616

696-
private sealed interface TomlValue {
697-
data class StringValue(val value: String) : TomlValue
698-
data class BooleanValue(val value: Boolean) : TomlValue
699-
data class IntValue(val value: Int) : TomlValue
617+
private fun Any?.asInt(name: String): Int {
618+
require(this is Number) { "$name must be an integer" }
619+
return toInt()
700620
}
701621

702622
private companion object {
@@ -713,3 +633,53 @@ class BigDataTestConfigLoader(
713633
)
714634
}
715635
}
636+
637+
private object TomlConfigParser {
638+
private val trailingArrayComma = Regex(",\\s*]")
639+
640+
fun parseTables(text: String): Map<String, Map<String, Any?>> {
641+
val root = JToml.parseString(text.replace(trailingArrayComma, "\n]"))
642+
val tables = linkedMapOf<String, Map<String, Any?>>()
643+
flattenTable("", root, tables)
644+
return tables
645+
}
646+
647+
private fun flattenTable(
648+
path: String,
649+
values: Map<*, *>,
650+
tables: MutableMap<String, Map<String, Any?>>,
651+
) {
652+
val scalars = linkedMapOf<String, Any?>()
653+
values.forEach { (rawKey, rawValue) ->
654+
require(rawKey is String) { "Unsupported TOML key type: ${rawKey?.javaClass}" }
655+
when (rawValue) {
656+
is Map<*, *> -> flattenTable(path.child(rawKey), rawValue, tables)
657+
else -> scalars[rawKey] = rawValue.normalizedTomlValue()
658+
}
659+
}
660+
tables[path] = scalars
661+
}
662+
663+
private fun String.child(name: String): String =
664+
if (isBlank()) name else "$this.$name"
665+
666+
private fun Any?.normalizedTomlValue(): Any? =
667+
when (this) {
668+
null -> null
669+
is String,
670+
is Boolean,
671+
is Int,
672+
is Long,
673+
is Float,
674+
is Double,
675+
is BigDecimal,
676+
is Date,
677+
-> this
678+
is Iterable<*> -> map { it.normalizedTomlValue() }
679+
is Map<*, *> -> entries.associate { (key, value) ->
680+
require(key is String) { "Unsupported TOML key type: ${key?.javaClass}" }
681+
key to value.normalizedTomlValue()
682+
}
683+
else -> this.toString()
684+
}
685+
}

example/spark/src/test/resources/spark-bigdata-extensions.toml

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,14 @@ records = [
1818
id = "spark-s3-upload-toml"
1919
bucket = "spark-upload-toml-s3"
2020
prefix = "uploaded-toml"
21-
22-
[[s3Uploads.sources]]
23-
source = "classpath:data/upload-seed.json"
24-
key = "uploaded-toml/s3-seed.json"
25-
contentType = "application/json"
21+
sources = [
22+
{ source = "classpath:data/upload-seed.json", key = "uploaded-toml/s3-seed.json", contentType = "application/json" },
23+
]
2624

2725
[[gcsUploads]]
2826
id = "spark-gcs-upload-toml"
2927
bucket = "spark-upload-toml-gcs"
3028
prefix = "uploaded-toml"
31-
32-
[[gcsUploads.sources]]
33-
source = "classpath:data/upload-seed.json"
34-
key = "uploaded-toml/gcs-seed.json"
35-
contentType = "application/json"
29+
sources = [
30+
{ source = "classpath:data/upload-seed.json", key = "uploaded-toml/gcs-seed.json", contentType = "application/json" },
31+
]

0 commit comments

Comments
 (0)