Skip to content

Commit

Permalink
feat: smoke tests trait (#1141)
Browse files Browse the repository at this point in the history
  • Loading branch information
0marperez authored Sep 26, 2024
1 parent b202b54 commit 872b457
Show file tree
Hide file tree
Showing 17 changed files with 577 additions and 4 deletions.
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 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 @@ -15,7 +15,7 @@ import software.amazon.smithy.model.shapes.Shape
import java.nio.file.Paths

const val DEFAULT_SOURCE_SET_ROOT = "./src/main/kotlin/"
private const val DEFAULT_TEST_SOURCE_SET_ROOT = "./src/test/kotlin/"
const val DEFAULT_TEST_SOURCE_SET_ROOT = "./src/test/kotlin/"

/**
* Manages writers for Kotlin files.
Expand Down 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 @@ -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 All @@ -107,6 +114,10 @@ object RuntimeTypes {
val SmithyBusinessMetric = symbol("SmithyBusinessMetric")
}

object SmokeTests : RuntimeTypePackage(KotlinDependency.CORE, "smoketests") {
val exitProcess = symbol("exitProcess")
}

object Collections : RuntimeTypePackage(KotlinDependency.CORE, "collections") {
val Attributes = symbol("Attributes")
val attributesOf = symbol("attributesOf")
Expand Down Expand Up @@ -163,6 +174,7 @@ object RuntimeTypes {
val Closeable = symbol("Closeable")
val SdkManagedGroup = symbol("SdkManagedGroup")
val addIfManaged = symbol("addIfManaged", isExtension = true)
val use = symbol("use")
}

object Text : RuntimeTypePackage(KotlinDependency.CORE, "text") {
Expand All @@ -184,6 +196,7 @@ object RuntimeTypes {
val truthiness = symbol("truthiness")
val toNumber = symbol("toNumber")
val type = symbol("type")
val PlatformProvider = symbol("PlatformProvider")
}

object Net : RuntimeTypePackage(KotlinDependency.CORE, "net") {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
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.DEFAULT_TEST_SOURCE_SET_ROOT
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.topDownOperations
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.topDownOperations(settings.service).any { it.hasTrait<SmokeTestsTrait>() }

override fun writeAdditionalFiles(ctx: CodegenContext, delegator: KotlinDelegator) =
delegator.useFileWriter(
"SmokeTests.kt",
"${ctx.settings.pkg.name}.smoketests",
DEFAULT_TEST_SOURCE_SET_ROOT,
) { writer ->
SmokeTestsRunnerGenerator(
writer,
ctx,
).render()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
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.getTrait
import software.amazon.smithy.kotlin.codegen.model.hasTrait
import software.amazon.smithy.kotlin.codegen.rendering.util.format
import software.amazon.smithy.kotlin.codegen.utils.dq
import software.amazon.smithy.kotlin.codegen.utils.toCamelCase
import software.amazon.smithy.kotlin.codegen.utils.topDownOperations
import software.amazon.smithy.model.shapes.OperationShape
import software.amazon.smithy.smoketests.traits.SmokeTestCase
import software.amazon.smithy.smoketests.traits.SmokeTestsTrait
import kotlin.jvm.optionals.getOrNull

object SmokeTestsRunner : SectionId
object SmokeTestAdditionalEnvVars : SectionId
object SmokeTestDefaultConfig : SectionId
object SmokeTestRegionDefault : SectionId
object SmokeTestHttpEngineOverride : SectionId

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,
) {
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.topDownOperations(ctx.settings.service).filter { it.hasTrait<SmokeTestsTrait>() }

internal fun render() {
writer.declareSection(SmokeTestsRunner) {
write("private var exitCode = 0")
write(
"private val skipTags = #T.System.getenv(#S)?.let { it.split(#S).map { it.trim() }.toSet() } ?: emptySet()",
RuntimeTypes.Core.Utils.PlatformProvider,
SKIP_TAGS,
",",
)
write(
"private val serviceFilter = #T.System.getenv(#S)?.let { it.split(#S).map { it.trim() }.toSet() } ?: emptySet()",
RuntimeTypes.Core.Utils.PlatformProvider,
SERVICE_FILTER,
",",
)
declareSection(SmokeTestAdditionalEnvVars)
write("")
withBlock("public suspend fun main() {", "}") {
renderFunctionCalls()
write("#T(exitCode)", RuntimeTypes.Core.SmokeTests.exitProcess)
}
write("")
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.write("")
}
}
}

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.isNotEmpty() && #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")
}
write("")
withInlineBlock("try {", "} ") {
renderClient(testCase)
renderOperation(operation, testCase)
}
withBlock("catch (e: Exception) {", "}") {
renderCatchBlock(testCase)
}
}
}

private fun renderClient(testCase: SmokeTestCase) {
writer.withInlineBlock("#L {", "}", service) {
if (testCase.vendorParams.isPresent) {
testCase.vendorParams.get().members.forEach { vendorParam ->
if (vendorParam.key.value == "region") {
writeInline("#L = ", vendorParam.key.value.toCamelCase())
declareSection(SmokeTestRegionDefault)
write("#L", vendorParam.value.format())
} else {
write("#L = #L", vendorParam.key.value.toCamelCase(), vendorParam.value.format())
}
}
} else {
declareSection(SmokeTestDefaultConfig)
}
val expectingSpecificError = testCase.expectation.failure.getOrNull()?.errorId?.getOrNull() != null
if (!expectingSpecificError) {
write("interceptors.add(#T())", RuntimeTypes.HttpClient.Interceptors.SmokeTestsInterceptor)
}
declareSection(SmokeTestHttpEngineOverride)
}
}

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

writer.withBlock(".#T { client ->", "}", RuntimeTypes.Core.IO.use) {
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 expected = if (testCase.expectation.isFailure) {
getFailureCriterion(testCase)
} else {
RuntimeTypes.HttpClient.Interceptors.SmokeTestsSuccessException
}

writer.write("val success = e is #T", expected)
writer.write("val status = if (success) #S else #S", "ok", "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,27 @@
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 -> stringMap.entries.joinToString(", ", "mapOf(", ")") { (key, value) ->
"${key.dq()} to ${value.format()}"
}
else -> throw Exception("Unexpected node type: $this")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package software.amazon.smithy.kotlin.codegen.utils

import software.amazon.smithy.model.Model
import software.amazon.smithy.model.knowledge.TopDownIndex
import software.amazon.smithy.model.shapes.OperationShape
import software.amazon.smithy.model.shapes.ShapeId

/**
* Syntactic sugar for getting a services operations
*/
fun Model.topDownOperations(service: ShapeId): Set<OperationShape> {
val topDownIndex = TopDownIndex.of(this)
return topDownIndex.getContainedOperations(service)
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ software.amazon.smithy.kotlin.codegen.rendering.endpoints.discovery.EndpointDisc
software.amazon.smithy.kotlin.codegen.rendering.endpoints.SdkEndpointBuiltinIntegration
software.amazon.smithy.kotlin.codegen.rendering.compression.RequestCompressionIntegration
software.amazon.smithy.kotlin.codegen.rendering.auth.SigV4AsymmetricAuthSchemeIntegration
software.amazon.smithy.kotlin.codegen.rendering.smoketests.SmokeTestsIntegration
Loading

0 comments on commit 872b457

Please sign in to comment.