Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ data class KotlinDependency(
val IDENTITY_API = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS", RUNTIME_GROUP, "identity-api", RUNTIME_VERSION)
val SMITHY_RPCV2_PROTOCOLS = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS.awsprotocol.rpcv2", RUNTIME_GROUP, "smithy-rpcv2-protocols", RUNTIME_VERSION)
val SMITHY_RPCV2_PROTOCOLS_CBOR = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS.awsprotocol.rpcv2.cbor", RUNTIME_GROUP, "smithy-rpcv2-protocols", RUNTIME_VERSION)
val AWS_SIGNING_CRT = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS.auth.awssigning.crt", RUNTIME_GROUP, "aws-signing-crt", RUNTIME_VERSION)

// External third-party dependencies
val KOTLIN_STDLIB = KotlinDependency(GradleConfiguration.Implementation, "kotlin", "org.jetbrains.kotlin", "kotlin-stdlib", KOTLIN_COMPILER_VERSION)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ object RuntimeTypes {

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

object Collections : RuntimeTypePackage(KotlinDependency.CORE, "collections") {
Expand Down Expand Up @@ -378,6 +379,10 @@ object RuntimeTypes {
val sigV4 = symbol("sigV4")
val sigV4A = symbol("sigV4A")
}

object AwsSigningCrt : RuntimeTypePackage(KotlinDependency.AWS_SIGNING_CRT) {
val CrtAwsSigner = symbol("CrtAwsSigner")
}
}

object Observability {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,63 +3,75 @@ 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.integration.SectionKey
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.isStringEnumShape
import software.amazon.smithy.kotlin.codegen.rendering.endpoints.EndpointParametersGenerator
import software.amazon.smithy.kotlin.codegen.rendering.endpoints.EndpointProviderGenerator
import software.amazon.smithy.kotlin.codegen.rendering.protocol.stringToNumber
import software.amazon.smithy.kotlin.codegen.rendering.smoketests.SmokeTestsRunnerGenerator.ClientConfig.EndpointParams
import software.amazon.smithy.kotlin.codegen.rendering.smoketests.SmokeTestsRunnerGenerator.ClientConfig.EndpointProvider
import software.amazon.smithy.kotlin.codegen.rendering.smoketests.SmokeTestsRunnerGenerator.ClientConfig.Name
import software.amazon.smithy.kotlin.codegen.rendering.smoketests.SmokeTestsRunnerGenerator.ClientConfig.Value
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.toPascalCase
import software.amazon.smithy.kotlin.codegen.utils.topDownOperations
import software.amazon.smithy.model.shapes.OperationShape
import software.amazon.smithy.model.node.*
import software.amazon.smithy.model.shapes.*
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()
writer.write("private var exitCode = 0")
renderEnvironmentVariables()
writer.declareSection(AdditionalEnvironmentVariables)
writer.write("")
writer.withBlock("public suspend fun main() {", "}") {
renderFunctionCalls()
write("#T(exitCode)", RuntimeTypes.Core.SmokeTests.exitProcess)
}
writer.write("")
renderFunctions()
}

private fun renderEnvironmentVariables() {
// Skip tags
writer.writeInline(
"private val skipTags = #T.System.getenv(",
RuntimeTypes.Core.Utils.PlatformProvider,
)
writer.declareSection(SkipTags) {
writer.writeInline("#S", SKIP_TAGS)
}
writer.write(
")?.let { it.split(#S).map { it.trim() }.toSet() } ?: emptySet()",
",",
)

// Service filter
writer.writeInline(
"private val serviceFilter = #T.System.getenv(",
RuntimeTypes.Core.Utils.PlatformProvider,
)
writer.declareSection(ServiceFilter) {
writer.writeInline("#S", SERVICE_FILTER)
}
writer.write(
")?.let { it.split(#S).map { it.trim() }.toSet() } ?: emptySet()",
",",
)
}

private fun renderFunctionCalls() {
Expand Down Expand Up @@ -98,32 +110,45 @@ class SmokeTestsRunnerGenerator(
renderClient(testCase)
renderOperation(operation, testCase)
}
withBlock("catch (e: Exception) {", "}") {
withBlock("catch (exception: 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)
renderClientConfig(testCase)
}
}

private fun renderClientConfig(testCase: SmokeTestCase) {
if (!testCase.expectingSpecificError) {
writer.write("interceptors.add(#T())", RuntimeTypes.HttpClient.Interceptors.SmokeTestsInterceptor)
}

writer.declareSection(HttpEngineOverride)

if (!testCase.hasClientConfig) {
writer.declareSection(DefaultClientConfig)
return
}

testCase.clientConfig!!.forEach { config ->
val name = config.key.value.toCamelCase()
val value = config.value.format()

writer.declareSection(
ClientConfig,
mapOf(
Name to name,
Value to value,
EndpointProvider to EndpointProviderGenerator.getSymbol(settings),
EndpointParams to EndpointParametersGenerator.getSymbol(settings),
),
) {
writer.writeInline("#L = #L", name, value)
}
declareSection(SmokeTestHttpEngineOverride)
}
}

Expand All @@ -133,30 +158,98 @@ class SmokeTestsRunnerGenerator(
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())
}
renderOperationParameters(operation, testCase)
}
}
}
}

private fun renderOperationParameters(operation: OperationShape, testCase: SmokeTestCase) {
if (!testCase.hasOperationParameters) return

val paramsToShapes = mapOperationParametersToModeledShapes(operation)

testCase.operationParameters.forEach { param ->
val paramName = param.key.value.toCamelCase()
writer.writeInline("#L = ", paramName)
renderOperationParameter(paramName, param.value, paramsToShapes, testCase)
Copy link
Contributor

Choose a reason for hiding this comment

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

simplify: You shouldn't need to pass the entire paramsToShapes map to this function, just passing paramsToShapes[paramName] should work

}
}

private fun renderCatchBlock(testCase: SmokeTestCase) {
val expected = if (testCase.expectation.isFailure) {
val expectedException = 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")
writer.write("val success: Boolean = exception is #T", expectedException)
writer.write("val status: String = 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")

writer.withBlock("if (!success) {", "}") {
write("#T(exception)", RuntimeTypes.Core.SmokeTests.printExceptionStackTrace)
write("exitCode = 1")
}
}

// Helpers
/**
* Renders a [SmokeTestCase] operation parameter
*/
private fun renderOperationParameter(
paramName: String,
node: Node,
shapes: Map<String, Shape>,
testCase: SmokeTestCase,
shapeOverride: Shape? = null,
) {
val shape = shapeOverride ?: shapes[paramName] ?: throw Exception("Unable to find shape for operation parameter '$paramName' in smoke test '${testCase.functionName}'.")
Copy link
Contributor

Choose a reason for hiding this comment

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

There should be a better exception type available like IllegalArgumentException or IllegalStateException. Same applies for other Exception below

when {
// String enum
node is StringNode && shape.isStringEnumShape -> {
val enumSymbol = symbolProvider.toSymbol(shape)
val enumValue = node.value.toPascalCase()
writer.write("#T.#L", enumSymbol, enumValue)
}
// Int enum
node is NumberNode && shape is IntEnumShape -> {
val enumSymbol = symbolProvider.toSymbol(shape)
val enumValue = node.format()
writer.write("#T.fromValue(#L.toInt())", enumSymbol, enumValue)
}
// Number
node is NumberNode && shape is NumberShape -> writer.write("#L.#L", node.format(), stringToNumber(shape))
// Object
node is ObjectNode -> {
val shapeSymbol = symbolProvider.toSymbol(shape)
writer.withBlock("#T {", "}", shapeSymbol) {
node.members.forEach { member ->
val memberName = member.key.value.toCamelCase()
val memberShape = shape.allMembers[member.key.value] ?: throw Exception("Unable to find shape for operation parameter '$paramName' in smoke test '${testCase.functionName}'.")
writer.writeInline("#L = ", memberName)
renderOperationParameter(memberName, member.value, mapOf(memberName to memberShape), testCase)
}
}
}
// List
node is ArrayNode && shape is CollectionShape -> {
writer.withBlock("listOf(", ")") {
node.elements.forEach { element ->
renderOperationParameter(paramName, element, mapOf(), testCase, model.expectShape(shape.member.target))
writer.write(",")
}
}
}
// Everything else
else -> writer.write("#L", node.format())
Comment on lines +276 to +277
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 support "anything else" or should we be throwing an exception if we reach this else?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, we support "simple" types i.e. Node. If we encounter an unknown Node type we throw an exception.

}
}

/**
Expand Down Expand Up @@ -184,10 +277,82 @@ class SmokeTestsRunnerGenerator(
val testResult = "$status $service $testCase - $expectation $directive"
writer.write("println(#S)", testResult)
}

/**
* Maps an operations parameters to their shapes
*/
private fun mapOperationParametersToModeledShapes(operation: OperationShape): Map<String, Shape> =
model.getShape(operation.inputShape).get().allMembers.map { (key, value) ->
key.toCamelCase() to model.getShape(value.target).get()
}.toMap()

/**
* Derives a function name for a [SmokeTestCase]
*/
private val SmokeTestCase.functionName: String
get() = this.id.toCamelCase()

/**
* Get the operation parameters for a [SmokeTestCase]
*/
private val SmokeTestCase.operationParameters: Map<StringNode, Node>
get() = this.params.get().members

/**
* Checks if there are operation parameters for a [SmokeTestCase]
*/
private val SmokeTestCase.hasOperationParameters: Boolean
get() = this.params.isPresent

/**
* Check if a [SmokeTestCase] is expecting a specific error
*/
private val SmokeTestCase.expectingSpecificError: Boolean
get() = this.expectation.failure.getOrNull()?.errorId?.getOrNull() != null

/**
* Checks if a [SmokeTestCase] requires client configuration
*/
private val SmokeTestCase.hasClientConfig: Boolean
get() = this.vendorParams.isPresent

/**
* Get the client configuration required for a [SmokeTestCase]
*/
private val SmokeTestCase.clientConfig: MutableMap<StringNode, Node>?
get() = this.vendorParams.get().members

// Section IDs
object AdditionalEnvironmentVariables : SectionId
object DefaultClientConfig : SectionId
object HttpEngineOverride : SectionId
object ServiceFilter : SectionId
object SkipTags : SectionId
object ClientConfig : SectionId {
val Name: SectionKey<String> = SectionKey("aws.smithy.kotlin#SmokeTestClientConfigName")
val Value: SectionKey<String> = SectionKey("aws.smithy.kotlin#SmokeTestClientConfigValue")
val EndpointProvider: SectionKey<Symbol> = SectionKey("aws.smithy.kotlin#SmokeTestEndpointProvider")
val EndpointParams: SectionKey<Symbol> = SectionKey("aws.smithy.kotlin#SmokeTestClientEndpointParams")
}
Copy link
Contributor

Choose a reason for hiding this comment

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

These should be top-level values, not class members


// Constants
private val model = ctx.model
private val settings = ctx.settings
private val sdkId = settings.sdkId
private val symbolProvider = ctx.symbolProvider
private val service = symbolProvider.toSymbol(model.expectShape(settings.service))
private val operations = model.topDownOperations(settings.service).filter { it.hasTrait<SmokeTestsTrait>() }
}

/**
* Derives a function name for a [SmokeTestCase]
* Env var for smoke test runners.
* Should be a comma-delimited list of strings that correspond to tags on the test cases.
* If a test case is tagged with one of the tags indicated by SMOKE_TEST_SKIP_TAGS, it MUST be skipped by the smoke test runner.
*/
const val SKIP_TAGS = "SMOKE_TEST_SKIP_TAGS"

/**
* Env var for smoke test runners.
* Should be a comma-separated list of service identifiers to test.
*/
private val SmokeTestCase.functionName: String
get() = this.id.toCamelCase()
const val SERVICE_FILTER = "SMOKE_TEST_SERVICE_IDS"
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: These should all go at the top of the class / file

Loading
Loading