Skip to content

Commit

Permalink
feat: smoke tests
Browse files Browse the repository at this point in the history
  • Loading branch information
0marperez committed Aug 23, 2024
1 parent 1ae66a0 commit 0ffb5a5
Show file tree
Hide file tree
Showing 13 changed files with 551 additions and 3 deletions.
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 @@ -198,6 +198,8 @@ class KotlinWriter(
)
}

fun emptyLine(): KotlinWriter = this.write("")

/**
* 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,6 +86,11 @@ 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")
val SmokeTestsUnexpectedException = symbol("SmokeTestsUnexpectedException")
val exitProcess = symbol("exitProcess")
}
}

Expand Down
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("")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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 has the smoke test trait.
*/
class SmokeTestsIntegration : KotlinIntegration {
override fun enabledForService(model: Model, settings: KotlinSettings): Boolean =
model.operations(settings.service).any { it.hasTrait<SmokeTestsTrait>() } && !smokeTestDenyList.contains(settings.sdkId)

override fun writeAdditionalFiles(ctx: CodegenContext, delegator: KotlinDelegator) =
delegator.useFileWriter("SmokeTests.kt", "smoketests", "./jvm-src/main/java/") { writer ->
SmokeTestsRunnerGenerator(
writer,
ctx.symbolProvider.toSymbol(ctx.model.expectShape(ctx.settings.service)),
ctx.model.operations(ctx.settings.service).filter { it.hasTrait<SmokeTestsTrait>() },
ctx.model,
ctx.symbolProvider,
ctx.settings.sdkId,
).render()
}
}

/**
* SDK ID's of services that model smoke tests incorrectly
*/
val smokeTestDenyList = setOf(
"Application Auto Scaling",
"SWF",
"WAFV2",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package software.amazon.smithy.kotlin.codegen.rendering.smoketests

import software.amazon.smithy.codegen.core.Symbol
import software.amazon.smithy.codegen.core.SymbolProvider
import software.amazon.smithy.kotlin.codegen.core.*
import software.amazon.smithy.kotlin.codegen.model.getTrait
import software.amazon.smithy.kotlin.codegen.rendering.util.render
import software.amazon.smithy.kotlin.codegen.utils.dq
import software.amazon.smithy.kotlin.codegen.utils.getOrNull
import software.amazon.smithy.kotlin.codegen.utils.toCamelCase
import software.amazon.smithy.model.Model
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

/**
* Renders smoke tests runner for a service
*/
class SmokeTestsRunnerGenerator(
private val writer: KotlinWriter,
private val service: Symbol,
private val operations: List<OperationShape>,
private val model: Model,
private val symbolProvider: SymbolProvider,
private val sdkId: String,
) {
internal fun render() {
writer.write("private var exitCode = 0")
writer.write("private val regionOverride = System.getenv(\"AWS_SMOKE_TEST_REGION\")")
writer.write("private val skipTags = System.getenv(\"AWS_SMOKE_TEST_SKIP_TAGS\")?.let { it.split(\",\").map { it.trim() }.toSet() }")
writer.emptyLine()
writer.withBlock("public suspend fun main() {", "}") {
renderFunctionCalls()
write("#L(exitCode)", RuntimeTypes.HttpClient.Interceptors.exitProcess)
}
writer.emptyLine()
renderFunctions()
}

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

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.id.toCamelCase()}() {", "}") {
write("val tags = setOf<String>(${testCase.tags.joinToString(",") { it.dq()} })")
write("if (skipTags != null && tags.any { it in skipTags }) return")
emptyLine()
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") {
write("region = regionOverride ?: #L", vendorParam.value.render())
} else {
write("#L = #L", vendorParam.key.value.toCamelCase(), vendorParam.value.render())
}
}
} else {
write("region = regionOverride")
}
val expectingSpecificError = testCase.expectation.failure.getOrNull()?.errorId?.getOrNull() != null
write("interceptors.add(#T($expectingSpecificError))", RuntimeTypes.HttpClient.Interceptors.SmokeTestsInterceptor)
}
checkVendorParamsAreCorrect(testCase)
}

/**
* Smithy IDL doesn't check that vendor params are found in vendor params shape so we have to check here.
*/
private fun checkVendorParamsAreCorrect(testCase: SmokeTestCase) {
if (testCase.vendorParamsShape.isPresent && testCase.vendorParams.isPresent) {
val vendorParamsShape = model.getShape(testCase.vendorParamsShape.get()).get()
val validVendorParams = vendorParamsShape.members().map { it.memberName }
val vendorParams = testCase.vendorParams.get().members.map { it.key.value }

vendorParams.forEach { vendorParam ->
check(validVendorParams.contains(vendorParam)) {
"Smithy smoke test \"${testCase.id}\" contains invalid vendor param \"$vendorParam\", it was not found in vendor params shape \"${testCase.vendorParamsShape}\""
}
}
}
}

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

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

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

writer.write("val success = e is #L", expected)
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.
*
* Some smoke tests model exceptions not found in the model, in that case we default to the generic smoke tests
* failure exception.
*/
private fun getFailureCriterion(testCase: SmokeTestCase): Symbol = testCase.expectation.failure.getOrNull()?.errorId?.let {
try {
symbolProvider.toSymbol(model.getShape(it.get()).get())
} catch (e: Exception) {
RuntimeTypes.HttpClient.Interceptors.SmokeTestsFailureException
}
} ?: 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,
) {
val expectation = if (errorExpected) "error expected from service" else "no error expected from service"
val testResult = "\$status $service $testCase - $expectation"
writer.write("println(#S)", testResult)
}
}
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

/**
* Renders a [Node] into String format for codegen.
*/
fun Node.render(): 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.render()
}
is ObjectNode -> buildString {
append("mapOf(")
stringMap.forEach { (key, value) ->
append("\t${key.dq()} to ${value.render()}")
}
append(")")
}
else -> throw Exception("Unexpected node type: $this")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
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
import software.amazon.smithy.utils.SmithyInternalApi

/**
* Syntactic sugar for getting a services operations
*/
@SmithyInternalApi
fun Model.operations(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 0ffb5a5

Please sign in to comment.