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 16 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 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,11 @@ object RuntimeTypes {
val SmithyBusinessMetric = symbol("SmithyBusinessMetric")
}

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

object Collections : RuntimeTypePackage(KotlinDependency.CORE, "collections") {
val Attributes = symbol("Attributes")
val attributesOf = symbol("attributesOf")
Expand Down Expand Up @@ -163,6 +175,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 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(#S)?.let { it.split(#S).map { it.trim() }.toSet() } ?: emptySet()",
RuntimeTypes.Core.SmokeTests.getEnv,
SKIP_TAGS,
",",
)
write(
"private val serviceFilter = #T(#S)?.let { it.split(#S).map { it.trim() }.toSet() } ?: emptySet()",
RuntimeTypes.Core.SmokeTests.getEnv,
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
Loading