Skip to content

Commit 872b457

Browse files
authored
feat: smoke tests trait (#1141)
1 parent b202b54 commit 872b457

File tree

17 files changed

+577
-4
lines changed

17 files changed

+577
-4
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"id": "756754c3-f6e1-4ff2-ae31-08b3b67b6750",
3+
"type": "feature",
4+
"description": "Add support for [smoke tests](https://smithy.io/2.0/additional-specs/smoke-tests.html)"
5+
}

codegen/smithy-kotlin-codegen/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ dependencies {
3131
implementation(libs.smithy.aws.traits)
3232
implementation(libs.smithy.protocol.traits)
3333
implementation(libs.smithy.protocol.test.traits)
34+
implementation(libs.smithy.smoke.test.traits)
3435
implementation(libs.jsoup)
3536

3637
// Test dependencies

codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/KotlinDelegator.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import software.amazon.smithy.model.shapes.Shape
1515
import java.nio.file.Paths
1616

1717
const val DEFAULT_SOURCE_SET_ROOT = "./src/main/kotlin/"
18-
private const val DEFAULT_TEST_SOURCE_SET_ROOT = "./src/test/kotlin/"
18+
const val DEFAULT_TEST_SOURCE_SET_ROOT = "./src/test/kotlin/"
1919

2020
/**
2121
* Manages writers for Kotlin files.
@@ -121,9 +121,10 @@ class KotlinDelegator(
121121
*
122122
* @param filename Name of the file to create.
123123
* @param block Lambda that accepts and works with the file.
124+
* @param sourceSetRoot Root directory for source set
124125
*/
125-
fun useFileWriter(filename: String, namespace: String, block: (KotlinWriter) -> Unit) {
126-
val writer: KotlinWriter = checkoutWriter(filename, namespace)
126+
fun useFileWriter(filename: String, namespace: String, sourceSetRoot: String = DEFAULT_SOURCE_SET_ROOT, block: (KotlinWriter) -> Unit) {
127+
val writer: KotlinWriter = checkoutWriter(filename, namespace, sourceSetRoot)
127128
block(writer)
128129
}
129130

@@ -205,6 +206,6 @@ internal data class GeneratedDependency(
205206
}
206207

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

210211
fun KotlinDelegator.applyFileWriter(symbol: Symbol, block: KotlinWriter.() -> Unit) = useFileWriter(symbol, block)

codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/KotlinDependency.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ data class KotlinDependency(
104104
val CORE = KotlinDependency(GradleConfiguration.Api, RUNTIME_ROOT_NS, RUNTIME_GROUP, "runtime-core", RUNTIME_VERSION)
105105
val HTTP = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS.http", RUNTIME_GROUP, "http", RUNTIME_VERSION)
106106
val HTTP_CLIENT = KotlinDependency(GradleConfiguration.Api, "$RUNTIME_ROOT_NS.http", RUNTIME_GROUP, "http-client", RUNTIME_VERSION)
107+
val HTTP_TEST = KotlinDependency(GradleConfiguration.Api, "$RUNTIME_ROOT_NS.httptest", RUNTIME_GROUP, "http-test", RUNTIME_VERSION)
107108
val SERDE = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS.serde", RUNTIME_GROUP, "serde", RUNTIME_VERSION)
108109
val SERDE_JSON = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS.serde.json", RUNTIME_GROUP, "serde-json", RUNTIME_VERSION)
109110
val SERDE_XML = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS.serde.xml", RUNTIME_GROUP, "serde-xml", RUNTIME_VERSION)

codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/RuntimeTypes.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,16 @@ object RuntimeTypes {
8686
val FlexibleChecksumsResponseInterceptor = symbol("FlexibleChecksumsResponseInterceptor")
8787
val ResponseLengthValidationInterceptor = symbol("ResponseLengthValidationInterceptor")
8888
val RequestCompressionInterceptor = symbol("RequestCompressionInterceptor")
89+
val SmokeTestsInterceptor = symbol("SmokeTestsInterceptor")
90+
val SmokeTestsFailureException = symbol("SmokeTestsFailureException")
91+
val SmokeTestsSuccessException = symbol("SmokeTestsSuccessException")
8992
}
9093
}
9194

95+
object HttpTest : RuntimeTypePackage(KotlinDependency.HTTP_TEST) {
96+
val TestEngine = symbol("TestEngine")
97+
}
98+
9299
object Core : RuntimeTypePackage(KotlinDependency.CORE) {
93100
val Clock = symbol("Clock", "time")
94101
val ExecutionContext = symbol("ExecutionContext", "operation")
@@ -107,6 +114,10 @@ object RuntimeTypes {
107114
val SmithyBusinessMetric = symbol("SmithyBusinessMetric")
108115
}
109116

117+
object SmokeTests : RuntimeTypePackage(KotlinDependency.CORE, "smoketests") {
118+
val exitProcess = symbol("exitProcess")
119+
}
120+
110121
object Collections : RuntimeTypePackage(KotlinDependency.CORE, "collections") {
111122
val Attributes = symbol("Attributes")
112123
val attributesOf = symbol("attributesOf")
@@ -163,6 +174,7 @@ object RuntimeTypes {
163174
val Closeable = symbol("Closeable")
164175
val SdkManagedGroup = symbol("SdkManagedGroup")
165176
val addIfManaged = symbol("addIfManaged", isExtension = true)
177+
val use = symbol("use")
166178
}
167179

168180
object Text : RuntimeTypePackage(KotlinDependency.CORE, "text") {
@@ -184,6 +196,7 @@ object RuntimeTypes {
184196
val truthiness = symbol("truthiness")
185197
val toNumber = symbol("toNumber")
186198
val type = symbol("type")
199+
val PlatformProvider = symbol("PlatformProvider")
187200
}
188201

189202
object Net : RuntimeTypePackage(KotlinDependency.CORE, "net") {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package software.amazon.smithy.kotlin.codegen.rendering.smoketests
2+
3+
import software.amazon.smithy.kotlin.codegen.KotlinSettings
4+
import software.amazon.smithy.kotlin.codegen.core.CodegenContext
5+
import software.amazon.smithy.kotlin.codegen.core.DEFAULT_TEST_SOURCE_SET_ROOT
6+
import software.amazon.smithy.kotlin.codegen.core.KotlinDelegator
7+
import software.amazon.smithy.kotlin.codegen.integration.KotlinIntegration
8+
import software.amazon.smithy.kotlin.codegen.model.hasTrait
9+
import software.amazon.smithy.kotlin.codegen.utils.topDownOperations
10+
import software.amazon.smithy.model.Model
11+
import software.amazon.smithy.smoketests.traits.SmokeTestsTrait
12+
13+
/**
14+
* Renders smoke test runner for a service if any of the operations have the [SmokeTestsTrait].
15+
*/
16+
class SmokeTestsIntegration : KotlinIntegration {
17+
override fun enabledForService(model: Model, settings: KotlinSettings): Boolean =
18+
model.topDownOperations(settings.service).any { it.hasTrait<SmokeTestsTrait>() }
19+
20+
override fun writeAdditionalFiles(ctx: CodegenContext, delegator: KotlinDelegator) =
21+
delegator.useFileWriter(
22+
"SmokeTests.kt",
23+
"${ctx.settings.pkg.name}.smoketests",
24+
DEFAULT_TEST_SOURCE_SET_ROOT,
25+
) { writer ->
26+
SmokeTestsRunnerGenerator(
27+
writer,
28+
ctx,
29+
).render()
30+
}
31+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package software.amazon.smithy.kotlin.codegen.rendering.smoketests
2+
3+
import software.amazon.smithy.codegen.core.Symbol
4+
import software.amazon.smithy.kotlin.codegen.core.*
5+
import software.amazon.smithy.kotlin.codegen.integration.SectionId
6+
import software.amazon.smithy.kotlin.codegen.model.getTrait
7+
import software.amazon.smithy.kotlin.codegen.model.hasTrait
8+
import software.amazon.smithy.kotlin.codegen.rendering.util.format
9+
import software.amazon.smithy.kotlin.codegen.utils.dq
10+
import software.amazon.smithy.kotlin.codegen.utils.toCamelCase
11+
import software.amazon.smithy.kotlin.codegen.utils.topDownOperations
12+
import software.amazon.smithy.model.shapes.OperationShape
13+
import software.amazon.smithy.smoketests.traits.SmokeTestCase
14+
import software.amazon.smithy.smoketests.traits.SmokeTestsTrait
15+
import kotlin.jvm.optionals.getOrNull
16+
17+
object SmokeTestsRunner : SectionId
18+
object SmokeTestAdditionalEnvVars : SectionId
19+
object SmokeTestDefaultConfig : SectionId
20+
object SmokeTestRegionDefault : SectionId
21+
object SmokeTestHttpEngineOverride : SectionId
22+
23+
const val SKIP_TAGS = "AWS_SMOKE_TEST_SKIP_TAGS"
24+
const val SERVICE_FILTER = "AWS_SMOKE_TEST_SERVICE_IDS"
25+
26+
/**
27+
* Renders smoke tests runner for a service
28+
*/
29+
class SmokeTestsRunnerGenerator(
30+
private val writer: KotlinWriter,
31+
ctx: CodegenContext,
32+
) {
33+
private val model = ctx.model
34+
private val sdkId = ctx.settings.sdkId
35+
private val symbolProvider = ctx.symbolProvider
36+
private val service = symbolProvider.toSymbol(model.expectShape(ctx.settings.service))
37+
private val operations = ctx.model.topDownOperations(ctx.settings.service).filter { it.hasTrait<SmokeTestsTrait>() }
38+
39+
internal fun render() {
40+
writer.declareSection(SmokeTestsRunner) {
41+
write("private var exitCode = 0")
42+
write(
43+
"private val skipTags = #T.System.getenv(#S)?.let { it.split(#S).map { it.trim() }.toSet() } ?: emptySet()",
44+
RuntimeTypes.Core.Utils.PlatformProvider,
45+
SKIP_TAGS,
46+
",",
47+
)
48+
write(
49+
"private val serviceFilter = #T.System.getenv(#S)?.let { it.split(#S).map { it.trim() }.toSet() } ?: emptySet()",
50+
RuntimeTypes.Core.Utils.PlatformProvider,
51+
SERVICE_FILTER,
52+
",",
53+
)
54+
declareSection(SmokeTestAdditionalEnvVars)
55+
write("")
56+
withBlock("public suspend fun main() {", "}") {
57+
renderFunctionCalls()
58+
write("#T(exitCode)", RuntimeTypes.Core.SmokeTests.exitProcess)
59+
}
60+
write("")
61+
renderFunctions()
62+
}
63+
}
64+
65+
private fun renderFunctionCalls() {
66+
operations.forEach { operation ->
67+
operation.getTrait<SmokeTestsTrait>()?.testCases?.forEach { testCase ->
68+
writer.write("${testCase.functionName}()")
69+
}
70+
}
71+
}
72+
73+
private fun renderFunctions() {
74+
operations.forEach { operation ->
75+
operation.getTrait<SmokeTestsTrait>()?.testCases?.forEach { testCase ->
76+
renderFunction(operation, testCase)
77+
writer.write("")
78+
}
79+
}
80+
}
81+
82+
private fun renderFunction(operation: OperationShape, testCase: SmokeTestCase) {
83+
writer.withBlock("private suspend fun ${testCase.functionName}() {", "}") {
84+
write("val tags = setOf<String>(${testCase.tags.joinToString(",") { it.dq()} })")
85+
writer.withBlock("if ((serviceFilter.isNotEmpty() && #S !in serviceFilter) || tags.any { it in skipTags }) {", "}", sdkId) {
86+
printTestResult(
87+
sdkId.filter { !it.isWhitespace() },
88+
testCase.id,
89+
testCase.expectation.isFailure,
90+
writer,
91+
"ok",
92+
"# skip",
93+
)
94+
writer.write("return")
95+
}
96+
write("")
97+
withInlineBlock("try {", "} ") {
98+
renderClient(testCase)
99+
renderOperation(operation, testCase)
100+
}
101+
withBlock("catch (e: Exception) {", "}") {
102+
renderCatchBlock(testCase)
103+
}
104+
}
105+
}
106+
107+
private fun renderClient(testCase: SmokeTestCase) {
108+
writer.withInlineBlock("#L {", "}", service) {
109+
if (testCase.vendorParams.isPresent) {
110+
testCase.vendorParams.get().members.forEach { vendorParam ->
111+
if (vendorParam.key.value == "region") {
112+
writeInline("#L = ", vendorParam.key.value.toCamelCase())
113+
declareSection(SmokeTestRegionDefault)
114+
write("#L", vendorParam.value.format())
115+
} else {
116+
write("#L = #L", vendorParam.key.value.toCamelCase(), vendorParam.value.format())
117+
}
118+
}
119+
} else {
120+
declareSection(SmokeTestDefaultConfig)
121+
}
122+
val expectingSpecificError = testCase.expectation.failure.getOrNull()?.errorId?.getOrNull() != null
123+
if (!expectingSpecificError) {
124+
write("interceptors.add(#T())", RuntimeTypes.HttpClient.Interceptors.SmokeTestsInterceptor)
125+
}
126+
declareSection(SmokeTestHttpEngineOverride)
127+
}
128+
}
129+
130+
private fun renderOperation(operation: OperationShape, testCase: SmokeTestCase) {
131+
val operationSymbol = symbolProvider.toSymbol(model.getShape(operation.input.get()).get())
132+
133+
writer.withBlock(".#T { client ->", "}", RuntimeTypes.Core.IO.use) {
134+
withBlock("client.#L(", ")", operation.defaultName()) {
135+
withBlock("#L {", "}", operationSymbol) {
136+
testCase.params.get().members.forEach { member ->
137+
write("#L = #L", member.key.value.toCamelCase(), member.value.format())
138+
}
139+
}
140+
}
141+
}
142+
}
143+
144+
private fun renderCatchBlock(testCase: SmokeTestCase) {
145+
val expected = if (testCase.expectation.isFailure) {
146+
getFailureCriterion(testCase)
147+
} else {
148+
RuntimeTypes.HttpClient.Interceptors.SmokeTestsSuccessException
149+
}
150+
151+
writer.write("val success = e is #T", expected)
152+
writer.write("val status = if (success) #S else #S", "ok", "not ok")
153+
printTestResult(
154+
sdkId.filter { !it.isWhitespace() },
155+
testCase.id,
156+
testCase.expectation.isFailure,
157+
writer,
158+
)
159+
writer.write("if (!success) exitCode = 1")
160+
}
161+
162+
/**
163+
* Tries to get the specific exception required in the failure criterion of a test.
164+
* If no specific exception is required we default to the generic smoke tests failure exception.
165+
*/
166+
private fun getFailureCriterion(testCase: SmokeTestCase): Symbol =
167+
testCase.expectation.failure.getOrNull()?.errorId?.getOrNull()?.let {
168+
symbolProvider.toSymbol(model.getShape(it).get())
169+
} ?: RuntimeTypes.HttpClient.Interceptors.SmokeTestsFailureException
170+
171+
/**
172+
* Renders print statement for smoke test result in accordance to design doc & test anything protocol (TAP)
173+
*/
174+
private fun printTestResult(
175+
service: String,
176+
testCase: String,
177+
errorExpected: Boolean,
178+
writer: KotlinWriter,
179+
statusOverride: String? = null,
180+
directive: String? = "",
181+
) {
182+
val expectation = if (errorExpected) "error expected from service" else "no error expected from service"
183+
val status = statusOverride ?: "\$status"
184+
val testResult = "$status $service $testCase - $expectation $directive"
185+
writer.write("println(#S)", testResult)
186+
}
187+
}
188+
189+
/**
190+
* Derives a function name for a [SmokeTestCase]
191+
*/
192+
private val SmokeTestCase.functionName: String
193+
get() = this.id.toCamelCase()
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package software.amazon.smithy.kotlin.codegen.rendering.util
2+
3+
import software.amazon.smithy.kotlin.codegen.utils.dq
4+
import software.amazon.smithy.model.node.ArrayNode
5+
import software.amazon.smithy.model.node.BooleanNode
6+
import software.amazon.smithy.model.node.Node
7+
import software.amazon.smithy.model.node.NullNode
8+
import software.amazon.smithy.model.node.NumberNode
9+
import software.amazon.smithy.model.node.ObjectNode
10+
import software.amazon.smithy.model.node.StringNode
11+
12+
/**
13+
* Formats a [Node] into a String for codegen.
14+
*/
15+
fun Node.format(): String = when (this) {
16+
is NullNode -> "null"
17+
is StringNode -> value.dq()
18+
is BooleanNode -> value.toString()
19+
is NumberNode -> value.toString()
20+
is ArrayNode -> elements.joinToString(",", "listOf(", ")") { element ->
21+
element.format()
22+
}
23+
is ObjectNode -> stringMap.entries.joinToString(", ", "mapOf(", ")") { (key, value) ->
24+
"${key.dq()} to ${value.format()}"
25+
}
26+
else -> throw Exception("Unexpected node type: $this")
27+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package software.amazon.smithy.kotlin.codegen.utils
2+
3+
import software.amazon.smithy.model.Model
4+
import software.amazon.smithy.model.knowledge.TopDownIndex
5+
import software.amazon.smithy.model.shapes.OperationShape
6+
import software.amazon.smithy.model.shapes.ShapeId
7+
8+
/**
9+
* Syntactic sugar for getting a services operations
10+
*/
11+
fun Model.topDownOperations(service: ShapeId): Set<OperationShape> {
12+
val topDownIndex = TopDownIndex.of(this)
13+
return topDownIndex.getContainedOperations(service)
14+
}

codegen/smithy-kotlin-codegen/src/main/resources/META-INF/services/software.amazon.smithy.kotlin.codegen.integration.KotlinIntegration

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ software.amazon.smithy.kotlin.codegen.rendering.endpoints.discovery.EndpointDisc
1212
software.amazon.smithy.kotlin.codegen.rendering.endpoints.SdkEndpointBuiltinIntegration
1313
software.amazon.smithy.kotlin.codegen.rendering.compression.RequestCompressionIntegration
1414
software.amazon.smithy.kotlin.codegen.rendering.auth.SigV4AsymmetricAuthSchemeIntegration
15+
software.amazon.smithy.kotlin.codegen.rendering.smoketests.SmokeTestsIntegration

0 commit comments

Comments
 (0)