Skip to content

Commit 9b090c1

Browse files
committed
feat(config): simplify TOML parsing with tomlj and enhance upload source support
- Replaced custom TOML parser with `tomlj` library for improved maintainability and compliance. - Added support for inline `sources` arrays in `s3Uploads` and `gcsUploads` configurations. - Updated tests to validate new configuration styles. - Enhanced user documentation with guidance on using inline and table-array forms.
1 parent cdb5ea9 commit 9b090c1

5 files changed

Lines changed: 93 additions & 240 deletions

File tree

doc/user-guide.adoc

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1351,6 +1351,30 @@ Outputs:
13511351
The S3 and GCS upload extensions create the target bucket by default and upload local files or folders after the object-store services start.
13521352
They use the AWS S3 SDK and Google Cloud Storage SDK against the endpoints exposed by `BigDataTestKit`.
13531353

1354+
[source,toml]
1355+
----
1356+
[[s3Uploads]]
1357+
id = "seed-s3"
1358+
bucket = "demo-s3"
1359+
prefix = "input"
1360+
sources = [
1361+
{ source = "file:src/test/resources/data/users.json", key = "input/users.json", contentType = "application/json" },
1362+
{ source = "src/test/resources/data/events", prefix = "events" },
1363+
]
1364+
1365+
[[gcsUploads]]
1366+
id = "seed-gcs"
1367+
bucket = "demo-gcs"
1368+
project = "bigdata-test"
1369+
prefix = "input"
1370+
createBucket = true
1371+
sources = [
1372+
{ source = "classpath:data/products.json", key = "input/products.json", contentType = "application/json" },
1373+
]
1374+
----
1375+
1376+
The equivalent table-array form is also supported and is easier to edit when each source has several fields:
1377+
13541378
[source,toml]
13551379
----
13561380
[[s3Uploads]]
@@ -1380,8 +1404,8 @@ key = "input/products.json"
13801404
contentType = "application/json"
13811405
----
13821406

1383-
Use `[[s3Uploads.sources]]` and `[[gcsUploads.sources]]` for upload sources.
1384-
Do not use an inline `sources = [ ... ]` array in project examples; the documented and tested TOML form is a table array, which is easier to extend and avoids parser differences around inline tables.
1407+
Both `sources = [ { ... } ]` and `[[s3Uploads.sources]]` / `[[gcsUploads.sources]]` are valid TOML and supported by the extension config loader.
1408+
Use inline arrays for short declarations, and table arrays when the source list gets longer.
13851409

13861410
Programmatic config:
13871411

extensions/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ dependencies {
4646
compileOnly(libs.sparkHive)
4747
compileOnly(libs.servletApi)
4848
implementation(libs.kotlinxSerialization)
49+
implementation(libs.tomlj)
4950

5051
shadedRuntime(libs.hadoopClientApi)
5152
shadedRuntime(libs.hadoopClientRuntime)
@@ -71,6 +72,7 @@ dependencies {
7172
shadedRuntime(libs.icebergAwsBundle)
7273
shadedRuntime(libs.servletApi)
7374
shadedRuntime(libs.kotlinxSerialization)
75+
shadedRuntime(libs.tomlj)
7476

7577
testImplementation(libs.junitJupiterApi)
7678
testImplementation(libs.hadoopClientApi)

extensions/src/main/kotlin/org/openprojectx/bigdata/test/extensions/config/BigDataExtensionsConfigLoader.kt

Lines changed: 37 additions & 237 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ import org.openprojectx.bigdata.test.extensions.objectstore.ObjectStoreUploadSou
2525
import org.openprojectx.bigdata.test.extensions.objectstore.S3BucketExtension
2626
import org.openprojectx.bigdata.test.extensions.objectstore.S3UploadExtension
2727
import org.openprojectx.bigdata.test.extensions.spark.SparkSqlPreparationExtensionProvider
28+
import org.tomlj.Toml
29+
import org.tomlj.TomlArray
30+
import org.tomlj.TomlTable
31+
import java.math.BigDecimal
32+
import java.time.LocalDate
33+
import java.time.LocalDateTime
34+
import java.time.LocalTime
35+
import java.time.OffsetDateTime
2836
import java.util.ServiceLoader
2937

3038
class BigDataExtensionsConfigLoader(
@@ -180,242 +188,34 @@ class BigDataExtensionsConfigLoader(
180188

181189
private object TomlConfigParser {
182190
fun parse(text: String): JsonObject {
183-
val root: MutableMap<String, Any> = linkedMapOf()
184-
var current: MutableMap<String, Any> = root
185-
logicalLines(text).forEach { logicalLine ->
186-
val index = logicalLine.line
187-
val line = logicalLine.text
188-
when {
189-
line.isEmpty() -> Unit
190-
line.startsWith("[[") && line.endsWith("]]") ->
191-
current = resolveArrayTable(root, line.substring(2, line.length - 2).trim(), index)
192-
line.startsWith("[") && line.endsWith("]") ->
193-
current = resolveTable(root, line.substring(1, line.length - 1).trim(), index)
194-
else -> {
195-
val (key, value) = splitKeyValue(line, index)
196-
current[key] = parseValue(value, index)
197-
}
198-
}
199-
}
200-
return toJson(root).jsonObject
201-
}
202-
203-
private data class TomlLine(val text: String, val line: Int)
204-
205-
private fun logicalLines(text: String): List<TomlLine> {
206-
val lines = mutableListOf<TomlLine>()
207-
val buffer = StringBuilder()
208-
var startLine = 0
209-
var depth = 0
210-
text.lineSequence().forEachIndexed { index, raw ->
211-
val line = stripComment(raw).trim()
212-
if (line.isEmpty() && buffer.isEmpty()) return@forEachIndexed
213-
if (buffer.isEmpty()) startLine = index else buffer.append(' ')
214-
buffer.append(line)
215-
depth += nestingDelta(line)
216-
if (depth == 0) {
217-
lines += TomlLine(buffer.toString().trim(), startLine)
218-
buffer.clear()
219-
}
220-
}
221-
require(buffer.isEmpty()) { "TOML line ${startLine + 1}: unclosed array or inline table" }
222-
return lines
223-
}
224-
225-
private fun resolveTable(root: MutableMap<String, Any>, path: String, line: Int): MutableMap<String, Any> {
226-
var current = root
227-
parsePath(path, line).forEach { segment ->
228-
current = when (val existing = current[segment]) {
229-
null -> linkedMapOf<String, Any>().also { current[segment] = it }
230-
is MutableMap<*, *> -> existing.asStringMap(line)
231-
is MutableList<*> -> existing.lastOrNull().asStringMap(line)
232-
else -> error("TOML line ${line + 1}: '$segment' is not a table")
233-
}
234-
}
235-
return current
236-
}
237-
238-
private fun resolveArrayTable(root: MutableMap<String, Any>, path: String, line: Int): MutableMap<String, Any> {
239-
val segments = parsePath(path, line)
240-
var current = root
241-
segments.dropLast(1).forEach { segment ->
242-
current = when (val existing = current[segment]) {
243-
null -> linkedMapOf<String, Any>().also { current[segment] = it }
244-
is MutableMap<*, *> -> existing.asStringMap(line)
245-
is MutableList<*> -> existing.lastOrNull().asStringMap(line)
246-
else -> error("TOML line ${line + 1}: '$segment' is not a table")
247-
}
191+
val result = Toml.parse(text)
192+
require(!result.hasErrors()) {
193+
result.errors().joinToString(separator = System.lineSeparator()) { it.toString() }
194+
}
195+
return result.toJsonObject()
196+
}
197+
198+
private fun TomlTable.toJsonObject(): JsonObject =
199+
JsonObject(keySet().associateWith { key -> get(key).toJsonElement() })
200+
201+
private fun TomlArray.toJsonArray(): JsonArray =
202+
JsonArray((0 until size()).map { index -> get(index).toJsonElement() })
203+
204+
private fun Any?.toJsonElement(): JsonElement =
205+
when (this) {
206+
null -> JsonPrimitive("null")
207+
is String -> JsonPrimitive(this)
208+
is Boolean -> JsonPrimitive(this)
209+
is Long -> JsonPrimitive(this)
210+
is Double -> JsonPrimitive(this)
211+
is BigDecimal -> JsonPrimitive(this)
212+
is TomlTable -> toJsonObject()
213+
is TomlArray -> toJsonArray()
214+
is OffsetDateTime,
215+
is LocalDateTime,
216+
is LocalDate,
217+
is LocalTime,
218+
-> JsonPrimitive(toString())
219+
else -> error("Unsupported TOML value type: ${this::class}")
248220
}
249-
val name = segments.last()
250-
val array = when (val existing = current[name]) {
251-
null -> mutableListOf<MutableMap<String, Any>>().also { current[name] = it }
252-
is MutableList<*> -> existing.asMutableList(line)
253-
else -> error("TOML line ${line + 1}: '$name' is not an array of tables")
254-
}
255-
return linkedMapOf<String, Any>().also { array += it }
256-
}
257-
258-
private fun parseValue(value: String, line: Int): Any {
259-
val trimmed = value.trim()
260-
return when {
261-
trimmed.startsWith('"') && trimmed.endsWith('"') -> parseString(trimmed, line)
262-
trimmed.startsWith("{") && trimmed.endsWith("}") -> parseInlineTable(trimmed, line)
263-
trimmed.startsWith("[") && trimmed.endsWith("]") -> parseArray(trimmed, line)
264-
trimmed == "true" -> true
265-
trimmed == "false" -> false
266-
trimmed.toIntOrNull() != null -> trimmed.toInt()
267-
trimmed.toLongOrNull() != null -> trimmed.toLong()
268-
trimmed.toDoubleOrNull() != null -> trimmed.toDouble()
269-
else -> error("TOML line ${line + 1}: unsupported value '$trimmed'")
270-
}
271-
}
272-
273-
private fun parseInlineTable(value: String, line: Int): Map<String, Any> {
274-
val body = value.substring(1, value.length - 1).trim()
275-
if (body.isEmpty()) return emptyMap()
276-
return splitTopLevel(body, ',').associate { item ->
277-
val (key, itemValue) = splitKeyValue(item, line)
278-
key to parseValue(itemValue, line)
279-
}
280-
}
281-
282-
private fun parseArray(value: String, line: Int): List<Any> {
283-
val body = value.substring(1, value.length - 1).trim()
284-
if (body.isEmpty()) return emptyList()
285-
return splitTopLevel(body, ',').map { parseValue(it, line) }
286-
}
287-
288-
private fun splitKeyValue(lineText: String, line: Int): Pair<String, String> {
289-
val index = lineText.indexOfTopLevel('=')
290-
require(index > 0) { "TOML line ${line + 1}: expected key = value" }
291-
return parseKey(lineText.substring(0, index).trim(), line) to lineText.substring(index + 1).trim()
292-
}
293-
294-
private fun parseKey(key: String, line: Int): String {
295-
require(key.isNotEmpty()) { "TOML line ${line + 1}: empty key" }
296-
return if (key.startsWith('"') && key.endsWith('"')) parseString(key, line) else key
297-
}
298-
299-
private fun parsePath(path: String, line: Int): List<String> =
300-
path.split('.').map { parseKey(it.trim(), line) }.also {
301-
require(it.isNotEmpty() && it.none(String::isEmpty)) { "TOML line ${line + 1}: invalid table path '$path'" }
302-
}
303-
304-
private fun parseString(value: String, line: Int): String {
305-
val body = value.substring(1, value.length - 1)
306-
val result = StringBuilder()
307-
var escaped = false
308-
body.forEach { char ->
309-
if (escaped) {
310-
result.append(
311-
when (char) {
312-
'b' -> '\b'
313-
't' -> '\t'
314-
'n' -> '\n'
315-
'f' -> '\u000C'
316-
'r' -> '\r'
317-
'"' -> '"'
318-
'\\' -> '\\'
319-
else -> error("TOML line ${line + 1}: unsupported escape '\\$char'")
320-
},
321-
)
322-
escaped = false
323-
} else if (char == '\\') {
324-
escaped = true
325-
} else {
326-
result.append(char)
327-
}
328-
}
329-
require(!escaped) { "TOML line ${line + 1}: dangling string escape" }
330-
return result.toString()
331-
}
332-
333-
private fun stripComment(raw: String): String {
334-
var inString = false
335-
var escaped = false
336-
raw.forEachIndexed { index, char ->
337-
when {
338-
escaped -> escaped = false
339-
char == '\\' && inString -> escaped = true
340-
char == '"' -> inString = !inString
341-
char == '#' && !inString -> return raw.substring(0, index)
342-
}
343-
}
344-
return raw
345-
}
346-
347-
private fun String.indexOfTopLevel(target: Char): Int {
348-
var inString = false
349-
var escaped = false
350-
var depth = 0
351-
forEachIndexed { index, char ->
352-
when {
353-
escaped -> escaped = false
354-
char == '\\' && inString -> escaped = true
355-
char == '"' -> inString = !inString
356-
!inString && (char == '{' || char == '[') -> depth++
357-
!inString && (char == '}' || char == ']') -> depth--
358-
!inString && depth == 0 && char == target -> return index
359-
}
360-
}
361-
return -1
362-
}
363-
364-
private fun splitTopLevel(value: String, delimiter: Char): List<String> {
365-
val items = mutableListOf<String>()
366-
var start = 0
367-
var inString = false
368-
var escaped = false
369-
var depth = 0
370-
value.forEachIndexed { index, char ->
371-
when {
372-
escaped -> escaped = false
373-
char == '\\' && inString -> escaped = true
374-
char == '"' -> inString = !inString
375-
!inString && (char == '{' || char == '[') -> depth++
376-
!inString && (char == '}' || char == ']') -> depth--
377-
!inString && depth == 0 && char == delimiter -> {
378-
items += value.substring(start, index).trim()
379-
start = index + 1
380-
}
381-
}
382-
}
383-
items += value.substring(start).trim()
384-
return items.filter { it.isNotEmpty() }
385-
}
386-
387-
private fun nestingDelta(value: String): Int {
388-
var inString = false
389-
var escaped = false
390-
var delta = 0
391-
value.forEach { char ->
392-
when {
393-
escaped -> escaped = false
394-
char == '\\' && inString -> escaped = true
395-
char == '"' -> inString = !inString
396-
!inString && (char == '{' || char == '[') -> delta++
397-
!inString && (char == '}' || char == ']') -> delta--
398-
}
399-
}
400-
return delta
401-
}
402-
403-
private fun toJson(value: Any): JsonElement = when (value) {
404-
is String -> JsonPrimitive(value)
405-
is Boolean -> JsonPrimitive(value)
406-
is Int -> JsonPrimitive(value)
407-
is Long -> JsonPrimitive(value)
408-
is Double -> JsonPrimitive(value)
409-
is Map<*, *> -> JsonObject(value.mapKeys { it.key as String }.mapValues { toJson(it.value ?: "null") })
410-
is List<*> -> JsonArray(value.map { toJson(it ?: "null") })
411-
else -> error("Unsupported TOML value type: ${value::class}")
412-
}
413-
414-
@Suppress("UNCHECKED_CAST")
415-
private fun Any?.asStringMap(line: Int): MutableMap<String, Any> =
416-
this as? MutableMap<String, Any> ?: error("TOML line ${line + 1}: expected table")
417-
418-
@Suppress("UNCHECKED_CAST")
419-
private fun Any?.asMutableList(line: Int): MutableList<MutableMap<String, Any>> =
420-
this as? MutableList<MutableMap<String, Any>> ?: error("TOML line ${line + 1}: expected array of tables")
421221
}

extensions/src/test/kotlin/org/openprojectx/bigdata/test/extensions/objectstore/ObjectStoreUploadExtensionsTest.kt

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class ObjectStoreUploadExtensionsTest {
2828
}
2929

3030
@Test
31-
fun `loads upload extensions from toml`() {
31+
fun `loads upload extensions with inline sources from toml`() {
3232
val extensions = BigDataExtensionsConfigLoader().load(
3333
"""
3434
[[s3Uploads]]
@@ -58,6 +58,31 @@ class ObjectStoreUploadExtensionsTest {
5858
assertEquals("seed-gcs", extensions[1].id)
5959
}
6060

61+
@Test
62+
fun `loads upload extensions with source table arrays from toml`() {
63+
val extensions = BigDataExtensionsConfigLoader().load(
64+
"""
65+
[[s3Uploads]]
66+
id = "seed-s3"
67+
bucket = "demo"
68+
prefix = "input"
69+
70+
[[s3Uploads.sources]]
71+
source = "classpath:data/a.txt"
72+
key = "input/a.txt"
73+
contentType = "text/plain"
74+
75+
[[s3Uploads.sources]]
76+
source = "file:build/seed"
77+
prefix = "nested"
78+
""".trimIndent(),
79+
)
80+
81+
assertEquals(1, extensions.size)
82+
assertTrue(extensions.single() is S3UploadExtension)
83+
assertEquals("seed-s3", extensions.single().id)
84+
}
85+
6186
@Test
6287
fun `uploads files and directories to s3`(@TempDir tempDir: Path) {
6388
Files.writeString(tempDir.resolve("single.txt"), "single")

0 commit comments

Comments
 (0)