Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: smoke tests trait #1141

Merged
merged 17 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/756754c3-f6e1-4ff2-ae31-08b3b67b6750.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"id": "756754c3-f6e1-4ff2-ae31-08b3b67b6750",
"type": "feature",
"description": "Add support for [smoke tests](https://smithy.io/2.0/additional-specs/smoke-tests.html)"
}
1 change: 1 addition & 0 deletions .github/workflows/continuous-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,5 +131,6 @@ jobs:
sed -i "s/smithy-kotlin-runtime-version = .*$/smithy-kotlin-runtime-version = \"$SMITHY_KOTLIN_RUNTIME_VERSION\"/" gradle/libs.versions.toml
sed -i "s/smithy-kotlin-codegen-version = .*$/smithy-kotlin-codegen-version = \"$SMITHY_KOTLIN_CODEGEN_VERSION\"/" gradle/libs.versions.toml
./gradlew --parallel publishToMavenLocal
./gradlew build
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the reason for adding ./gradlew build here? Don't the test tasks depend on build already?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so, the build task depends on the stageServices task but it doesn't seem to be running when the build task is being called by the test task.

I added a dependency on stageServices to the test tasks and that should allow us to get rid of this ^

./gradlew test jvmTest
./gradlew testAllProtocols
1 change: 1 addition & 0 deletions codegen/smithy-kotlin-codegen/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ dependencies {
implementation(libs.smithy.aws.traits)
implementation(libs.smithy.protocol.traits)
implementation(libs.smithy.protocol.test.traits)
implementation(libs.smithy.smoke.test.traits)
implementation(libs.jsoup)

// Test dependencies
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,10 @@ class KotlinDelegator(
*
* @param filename Name of the file to create.
* @param block Lambda that accepts and works with the file.
* @param sourceSetRoot Root directory for source set
*/
fun useFileWriter(filename: String, namespace: String, block: (KotlinWriter) -> Unit) {
val writer: KotlinWriter = checkoutWriter(filename, namespace)
fun useFileWriter(filename: String, namespace: String, sourceSetRoot: String = DEFAULT_SOURCE_SET_ROOT, block: (KotlinWriter) -> Unit) {
val writer: KotlinWriter = checkoutWriter(filename, namespace, sourceSetRoot)
block(writer)
}

Expand Down Expand Up @@ -205,6 +206,6 @@ internal data class GeneratedDependency(
}

fun KotlinDelegator.useFileWriter(symbol: Symbol, block: (KotlinWriter) -> Unit) =
useFileWriter("${symbol.name}.kt", symbol.namespace, block)
useFileWriter("${symbol.name}.kt", symbol.namespace, DEFAULT_SOURCE_SET_ROOT, block)

fun KotlinDelegator.applyFileWriter(symbol: Symbol, block: KotlinWriter.() -> Unit) = useFileWriter(symbol, block)
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ data class KotlinDependency(
val CORE = KotlinDependency(GradleConfiguration.Api, RUNTIME_ROOT_NS, RUNTIME_GROUP, "runtime-core", RUNTIME_VERSION)
val HTTP = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS.http", RUNTIME_GROUP, "http", RUNTIME_VERSION)
val HTTP_CLIENT = KotlinDependency(GradleConfiguration.Api, "$RUNTIME_ROOT_NS.http", RUNTIME_GROUP, "http-client", RUNTIME_VERSION)
val HTTP_TEST = KotlinDependency(GradleConfiguration.Api, "$RUNTIME_ROOT_NS.httptest", RUNTIME_GROUP, "http-test", RUNTIME_VERSION)
val SERDE = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS.serde", RUNTIME_GROUP, "serde", RUNTIME_VERSION)
val SERDE_JSON = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS.serde.json", RUNTIME_GROUP, "serde-json", RUNTIME_VERSION)
val SERDE_XML = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS.serde.xml", RUNTIME_GROUP, "serde-xml", RUNTIME_VERSION)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ class KotlinWriter(
)
}

fun emptyLine(): KotlinWriter = this.write("")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This method doesn't create an empty line necessarily—it merely guarantees a new line. For instance, the following code would result in no empty lines:

writer.writeInline("foo")
writer.emptyLine()

Applies in GradleGenerator too.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Also write("") is two characters shorter than emptyLine()—do we even need this method? We seem to have used the former quite a lot with no issues until now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll get rid of emptyLine()


/**
* Clean/escape any content from the doc that would invalidate the Kotlin output.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,16 @@ object RuntimeTypes {
val FlexibleChecksumsResponseInterceptor = symbol("FlexibleChecksumsResponseInterceptor")
val ResponseLengthValidationInterceptor = symbol("ResponseLengthValidationInterceptor")
val RequestCompressionInterceptor = symbol("RequestCompressionInterceptor")
val SmokeTestsInterceptor = symbol("SmokeTestsInterceptor")
val SmokeTestsFailureException = symbol("SmokeTestsFailureException")
val SmokeTestsSuccessException = symbol("SmokeTestsSuccessException")
}
}

object HttpTest : RuntimeTypePackage(KotlinDependency.HTTP_TEST) {
val TestEngine = symbol("TestEngine")
}

object Core : RuntimeTypePackage(KotlinDependency.CORE) {
val Clock = symbol("Clock", "time")
val ExecutionContext = symbol("ExecutionContext", "operation")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package software.amazon.smithy.kotlin.codegen.model.traits

import software.amazon.smithy.model.node.ObjectNode
import software.amazon.smithy.model.shapes.ShapeId
import software.amazon.smithy.model.traits.AnnotationTrait

/**
* Indicates the annotated service should always return a failed response.
*/
class FailedResponseTrait(node: ObjectNode) : AnnotationTrait(ID, node) {
companion object {
val ID: ShapeId = ShapeId.from("smithy.kotlin.traits#failedResponseTrait")
}
}

/**
* Indicates the annotated service should always return a success response.
*/
class SuccessResponseTrait(node: ObjectNode) : AnnotationTrait(ID, node) {
companion object {
val ID: ShapeId = ShapeId.from("smithy.kotlin.traits#successResponseTrait")
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: What attaches these traits to shapes? I can see where we check for them but not where we create/apply them...

More broadly, why do we even need these traits? I was under the impression that individual smoke test cases identified whether they should fail/succeed based on an Expectation union.

Copy link
Contributor Author

@0marperez 0marperez Sep 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The traits are applied here to either of those models for testing purposes

Original file line number Diff line number Diff line change
Expand Up @@ -250,4 +250,6 @@ class GradleWriter(parent: GradleWriter? = null) : AbstractCodeWriter<GradleWrit
expressionStart = parent?.expressionStart ?: '#'
putFormatter('W', InlineCodeWriterFormatter(::GradleWriter))
}

fun emptyLine(): GradleWriter = this.write("")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: If we need this functionality in multiple code writers and its implementation is identical then it seems like we could instead write a single extension method for AbstractCodeWriter.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package software.amazon.smithy.kotlin.codegen.rendering.smoketests

import software.amazon.smithy.kotlin.codegen.KotlinSettings
import software.amazon.smithy.kotlin.codegen.core.CodegenContext
import software.amazon.smithy.kotlin.codegen.core.KotlinDelegator
import software.amazon.smithy.kotlin.codegen.integration.KotlinIntegration
import software.amazon.smithy.kotlin.codegen.model.hasTrait
import software.amazon.smithy.kotlin.codegen.utils.operations
import software.amazon.smithy.model.Model
import software.amazon.smithy.smoketests.traits.SmokeTestsTrait

/**
* Renders smoke test runner for a service if any of the operations have the [SmokeTestsTrait].
*/
class SmokeTestsIntegration : KotlinIntegration {
override fun enabledForService(model: Model, settings: KotlinSettings): Boolean =
model.operations(settings.service).any { it.hasTrait<SmokeTestsTrait>() }

override fun writeAdditionalFiles(ctx: CodegenContext, delegator: KotlinDelegator) =
delegator.useFileWriter(
"SmokeTests.kt",
"${ctx.settings.pkg.name}.smoketests",
"./jvm-src/test/java/",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is jvm-src a standard sourceset location?

Copy link
Contributor Author

@0marperez 0marperez Sep 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I was trying to follow the convention we have currently without affecting the SDK. I believe we currently code-generate services into a src dir and then the SDK moves that code into the <sdk root dir>/services/<relevant service>/generated-src dir in the SDK. Here's where we move the generated code.

I don't know if we have anywhere else that does codegen like this that is intended as JVM only. If we follow source-set convention I think src should be commonMain and jvm-src should be jvmMain but it won't affect functionality.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is generating JVM-only code necessary? Can't we generate common code to run the tests?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Main reason is that there's no common code for exitProcess
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.system/exit-process.html

I think I can wrap this in our own KMP function, thoughts?

) { writer ->
SmokeTestsRunnerGenerator(
writer,
ctx,
).render()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
package software.amazon.smithy.kotlin.codegen.rendering.smoketests

import software.amazon.smithy.codegen.core.Symbol
import software.amazon.smithy.kotlin.codegen.core.*
import software.amazon.smithy.kotlin.codegen.integration.SectionId
import software.amazon.smithy.kotlin.codegen.model.expectShape
import software.amazon.smithy.kotlin.codegen.model.getTrait
import software.amazon.smithy.kotlin.codegen.model.hasTrait
import software.amazon.smithy.kotlin.codegen.model.traits.FailedResponseTrait
import software.amazon.smithy.kotlin.codegen.model.traits.SuccessResponseTrait
import software.amazon.smithy.kotlin.codegen.rendering.util.format
import software.amazon.smithy.kotlin.codegen.utils.dq
import software.amazon.smithy.kotlin.codegen.utils.operations
import software.amazon.smithy.kotlin.codegen.utils.toCamelCase
import software.amazon.smithy.model.shapes.OperationShape
import software.amazon.smithy.model.shapes.ServiceShape
import software.amazon.smithy.smoketests.traits.SmokeTestCase
import software.amazon.smithy.smoketests.traits.SmokeTestsTrait
import kotlin.jvm.optionals.getOrNull

object SmokeTestsRunner : SectionId

// Env var constants
const val SKIP_TAGS = "AWS_SMOKE_TEST_SKIP_TAGS"
const val SERVICE_FILTER = "AWS_SMOKE_TEST_SERVICE_IDS"

/**
* Renders smoke tests runner for a service
*/
class SmokeTestsRunnerGenerator(
private val writer: KotlinWriter,
ctx: CodegenContext,
) {
// Generator config
private val model = ctx.model
private val sdkId = ctx.settings.sdkId
private val symbolProvider = ctx.symbolProvider
private val service = symbolProvider.toSymbol(model.expectShape(ctx.settings.service))
private val operations = ctx.model.operations(ctx.settings.service).filter { it.hasTrait<SmokeTestsTrait>() }

// Test config
private val hasSuccessResponseTrait = model.expectShape<ServiceShape>(ctx.settings.service).hasTrait(SuccessResponseTrait.ID)
private val hasFailedResponseTrait = model.expectShape<ServiceShape>(ctx.settings.service).hasTrait(FailedResponseTrait.ID)
init {
check(!(hasSuccessResponseTrait && hasFailedResponseTrait)) {
"A service can't have both the success response trait and the failed response trait."
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we also need to check that hasSuccessResponseTrait || hasFailedResponseTrait (a service has exactly one of the traits)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No because the trait is only used for the E2E tests. During code generation for actual real life services neither of the traits is necessary

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am concerned about the tight coupling between this smoke tests generator and the tests for it. The generator here should not know / have to care about test-specific traits.

If we're just using these traits to configure the httpClient, can it be configured through the smoke test vendor params instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I don't like it either. I can't think of any other reasonable way to replace the httpClient without high coupling.

If we're just using these traits to configure the httpClient, can it be configured through the smoke test vendor params instead?

We would have to add some custom logic for that to work, so the coupling would still be there.


internal fun render() {
writer.declareSection(SmokeTestsRunner) {
writer.write("import kotlin.system.exitProcess")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Generally do not codegen imports directly. Favor #T placeholders and type references, which automatically add imports as appropriate.

writer.emptyLine()
writer.write("private var exitCode = 0")
writer.write("private val regionOverride = System.getenv(#S)", "AWS_SMOKE_TEST_REGION")
writer.write("private val skipTags = System.getenv(#S)?.let { it.split(\",\").map { it.trim() }.toSet() } ?: emptySet()", SKIP_TAGS)
writer.write("private val serviceFilter = System.getenv(#S)?.let { it.split(\",\").map { it.trim() }.toSet() }", SERVICE_FILTER)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Replace \",\" with #S and ","

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Why not default serviceFilter to an empty set the same as above with skipTags? Every subsequent access of serviceFilter now has to be null-checked when that could be avoided.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's important to know if the user didn't set this because this is an include filter. It the default is an empty set then no smoke tests will run. We could change the default to an empty set and then check if it's that instead of null

writer.emptyLine()
writer.withBlock("public suspend fun main() {", "}") {
renderFunctionCalls()
write("exitProcess(exitCode)")
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: What's the reason to use exitProcess(errorCode) instead of return errorCode?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is part of the design document, we're supposed to return a non-zero exit code when any of the tests don't pass. Just returning the code doesn't set the exit code AFAIK.

writer.emptyLine()
renderFunctions()
}
}

private fun renderFunctionCalls() {
operations.forEach { operation ->
operation.getTrait<SmokeTestsTrait>()?.testCases?.forEach { testCase ->
writer.write("${testCase.functionName}()")
}
}
}

private fun renderFunctions() {
operations.forEach { operation ->
operation.getTrait<SmokeTestsTrait>()?.testCases?.forEach { testCase ->
renderFunction(operation, testCase)
writer.emptyLine()
}
}
}

private fun renderFunction(operation: OperationShape, testCase: SmokeTestCase) {
writer.withBlock("private suspend fun ${testCase.functionName}() {", "}") {
write("val tags = setOf<String>(${testCase.tags.joinToString(",") { it.dq()} })")
writer.withBlock("if ((serviceFilter != null && #S !in serviceFilter) || tags.any { it in skipTags }) {", "}", sdkId) {
printTestResult(
sdkId.filter { !it.isWhitespace() },
testCase.id,
testCase.expectation.isFailure,
writer,
"ok",
"# skip",
)
writer.write("return")
}
emptyLine()
withInlineBlock("try {", "} ") {
renderClient(testCase)
renderOperation(operation, testCase)
}
withBlock("catch (e: Exception) {", "}") {
renderCatchBlock(testCase)
}
}
}

private fun renderClient(testCase: SmokeTestCase) {
writer.withInlineBlock("#L {", "}", service) {
// Client config
if (testCase.vendorParams.isPresent) {
testCase.vendorParams.get().members.forEach { vendorParam ->
if (vendorParam.key.value == "region") {
write("region = regionOverride ?: #L", vendorParam.value.format())
} else {
write("#L = #L", vendorParam.key.value.toCamelCase(), vendorParam.value.format())
}
}
} else {
write("region = regionOverride")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens when the AWS_SMOKE_TEST_REGION is not set?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case regionOverride will be set to null

}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: region is an AWS service concept. Why are we codegenning region config in smithy-kotlin?

val expectingSpecificError = testCase.expectation.failure.getOrNull()?.errorId?.getOrNull() != null
if (!expectingSpecificError) {
write("interceptors.add(#T())", RuntimeTypes.HttpClient.Interceptors.SmokeTestsInterceptor)
}

// Test config
if (hasSuccessResponseTrait) {
write("httpClient = #T()", RuntimeTypes.HttpTest.TestEngine)
}
if (hasFailedResponseTrait) {
withBlock("httpClient = #T(", ")", RuntimeTypes.HttpTest.TestEngine) {
withBlock("roundTripImpl = { _, request ->", "}") {
write(
"val resp = #T(#T.BadRequest, #T.Empty, #T.Empty)",
RuntimeTypes.Http.Response.HttpResponse,
RuntimeTypes.Http.StatusCode,
RuntimeTypes.Http.Headers,
RuntimeTypes.Http.HttpBody,
)
write("val now = #T.now()", RuntimeTypes.Core.Instant)
write("#T(request, resp, now, now)", RuntimeTypes.Http.HttpCall)
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: Why are we using a test client here? If we don't call the actual service with a real client, what are we testing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic is supposed to be used during testing only. The test client is only used if the service has those custom traits that were created (FailedResponseTrait & SuccessResponseTrait)

}
}

private fun renderOperation(operation: OperationShape, testCase: SmokeTestCase) {
val operationSymbol = symbolProvider.toSymbol(model.getShape(operation.input.get()).get())

writer.withBlock(".use { client ->", "}") {
withBlock("client.#L(", ")", operation.defaultName()) {
withBlock("#L {", "}", operationSymbol) {
testCase.params.get().members.forEach { member ->
write("#L = #L", member.key.value.toCamelCase(), member.value.format())
}
}
}
}
}

private fun renderCatchBlock(testCase: SmokeTestCase) {
val successCriterion = RuntimeTypes.HttpClient.Interceptors.SmokeTestsSuccessException
val failureCriterion = getFailureCriterion(testCase)
val expected = if (testCase.expectation.isFailure) {
failureCriterion
} else {
successCriterion
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: successCriterion and failureCriterion are only used as short names for the if-branch logic. They're unnecessary.

val expected = if (testCase.expectation.isFailure) {
    getFailureCriterion(testCase)
} else {
    RuntimeTypes.HttpClient.Interceptors.SmokeTestsSuccessException
}


writer.write("val success = e is #L", expected)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Use #T for types

writer.write("val status = if (success) \"ok\" else \"not ok\"")
printTestResult(
sdkId.filter { !it.isWhitespace() },
testCase.id,
testCase.expectation.isFailure,
writer,
)
writer.write("if (!success) exitCode = 1")
}

/**
* Tries to get the specific exception required in the failure criterion of a test.
* If no specific exception is required we default to the generic smoke tests failure exception.
*/
private fun getFailureCriterion(testCase: SmokeTestCase): Symbol =
testCase.expectation.failure.getOrNull()?.errorId?.getOrNull()?.let {
symbolProvider.toSymbol(model.getShape(it).get())
} ?: RuntimeTypes.HttpClient.Interceptors.SmokeTestsFailureException

/**
* Renders print statement for smoke test result in accordance to design doc & test anything protocol (TAP)
*/
private fun printTestResult(
service: String,
testCase: String,
errorExpected: Boolean,
writer: KotlinWriter,
statusOverride: String? = null,
directive: String? = "",
) {
val expectation = if (errorExpected) "error expected from service" else "no error expected from service"
val status = statusOverride ?: "\$status"
val testResult = "$status $service $testCase - $expectation $directive"
writer.write("println(#S)", testResult)
}
}

/**
* Derives a function name for a [SmokeTestCase]
*/
private val SmokeTestCase.functionName: String
get() = this.id.toCamelCase()
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package software.amazon.smithy.kotlin.codegen.rendering.util

import software.amazon.smithy.kotlin.codegen.utils.dq
import software.amazon.smithy.model.node.ArrayNode
import software.amazon.smithy.model.node.BooleanNode
import software.amazon.smithy.model.node.Node
import software.amazon.smithy.model.node.NullNode
import software.amazon.smithy.model.node.NumberNode
import software.amazon.smithy.model.node.ObjectNode
import software.amazon.smithy.model.node.StringNode

/**
* Formats a [Node] into a String for codegen.
*/
fun Node.format(): String = when (this) {
is NullNode -> "null"
is StringNode -> value.dq()
is BooleanNode -> value.toString()
is NumberNode -> value.toString()
is ArrayNode -> elements.joinToString(",", "listOf(", ")") { element ->
element.format()
}
is ObjectNode -> buildString {
append("mapOf(")
stringMap.forEach { (key, value) ->
append("\t${key.dq()} to ${value.format()}")
}
append(")")
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: This doesn't seem to add commas between subsequent key-value pairs. I'm not sure why the tabs (\t) are there either but I believe this could be simplified down to a joinToString call similar to the one above:

is ObjectNode -> stringMap.entries().joinToString(", ", "mapOf(", ")") { (key, value) ->
    "${key.dq()} to ${value.format()}"
}

else -> throw Exception("Unexpected node type: $this")
}
Loading
Loading