- Type: Design
- Author(s): Aaron Todd
This document presents the high-level design of how Smithy shapes and traits will map to code in Kotlin. It dictates the fundamental outline of the generated code and discusses exceptions or edge cases as necessary. The HTTP, Event Stream, XML, and MQTT Smithy specifications will all receive their own separate design as an addendum to this one. The reason for this approach is (1) this document is getting large on its own, (2) once we agree on the majority of the core spec we can start making progress on the code generator in parallel, and (3) since we are exploring the possibility of using Kotlin Multiplatform for the client-runtime we feel the need to do some more exploration and a POC before committing to a direction.
Reference the Smithy Core Spec.
Kotlin keywords can be found here. Kotlin has both hard
keywords and soft keywords (context sensitive). Codegen will escape generated identifiers with names from the set of
hard keywords (e.g., "null" will be escaped as `null`
).
Smithy Type | Kotlin Type |
---|---|
blob |
ByteArray |
boolean |
Boolean |
string |
String |
byte |
Byte |
short |
Short |
integer |
Int |
long |
Long |
float |
Float |
double |
Double |
bigInteger |
*custom type provided by client runtime (type aliased to java.math.BigInteger on JVM) |
bigDecimal |
*custom type provided by client runtime (type aliased to java.math.BigDecimal on JVM) |
timestamp |
*custom type provided by client runtime |
document |
*custom type provided by client runtime |
See Document type for more detail.
Smithy type | Kotlin type |
---|---|
list |
List |
set |
Set |
map |
Map |
structure |
*class |
union |
*sealed class |
A structure type represents a fixed set of named heterogeneous members. In Kotlin this can be represented as either a normal class or a data class.
Traits of generated classes in Kotlin:
- We generate standard classes for types rather than Kotlin's data classes. See Domain class types in Kotlin SDK for the reasoning.
- We generate request and response classes with nullable properties, regardless of any modeling notions of required-ness. See here for discussion.
- For the construction of requests, DSL-style builders are provided instead of constructors. This approach provides a more robust form of creation as we have more flexibility in how values are evaluated during instantiation of a type.
Non-boxed member values will be defaulted according to the spec:
The default value of a byte, short, integer, long, float, and double shape that is not boxed is zero
All other types (aggegates list, set, structure, String, etc.) will be nullable and defaulted to null.
@enum(YES:{}, NO: {})
string SimpleYesNo
structure Baz {
quux: String,
}
structure MyStruct {
foo: String,
bar: PrimitiveInteger,
baz: Baz,
yesno: SimpleYesNo,
}
Given the above Smithy structure shapes above we would generate the following:
class Baz private constructor(builder: Builder) {
val quux: String? = builder.quux
override fun toString(): String = "Baz(quux=$quux)"
companion object {
inline operator fun invoke(block: Builder.() -> Unit): Baz = Builder().apply(block).build()
}
fun copy(block: Builder.() -> kotlin.Unit = {}): Baz = Builder(this).apply(block).build()
class Builder {
var quux: String? = null
internal constructor()
constructor(baz: Baz): this() {
this.quux = baz.quux
}
internal fun build(): Baz = Baz(this)
}
}
class MyStruct private constructor(builder: Builder) {
val foo: String? = builder.foo
val bar: Int = builder.bar
val baz: Baz? = builder.baz
val yesno: SimpleYesNo? = builder.yesno
override fun toString(): String {
return "MyStruct(foo=$foo, bar=$bar, baz=$baz, yesno=$yesno)"
}
fun copy(block: Builder.() -> Unit = {}): MyStruct = Builder(this).apply(block).build()
companion object {
inline operator fun invoke(block: Builder.() -> Unit): MyStruct {
val builder = Builder()
builder.block()
return builder.build()
}
}
class Builder {
var foo: String? = null
var bar: Int = 0
var baz: Baz? = null
var yesno: SimpleYesNo? = null
constructor(mystruct: MyStruct): this() {
this.foo = mystruct.foo
this.bar = mystruct.bar
this.baz = mystruct.baz
this.yesno = mystruct.yesno
}
internal fun build(): MyStruct = MyStruct(this)
// generated for any member shapes that target a StructureShape
fun baz(block: Baz.Builder.() -> Unit) {
this.baz = Baz.invoke(block)
}
}
}
Example usage of the builder(s):
// DSL builder for Kotlin
val mystruct = MyStruct {
foo = "fooey"
bar = 12
baz {
quux = "foo"
}
yesno = SimpleYesNo.YES
}
println(mystruct)
// MyStruct(foo=fooey, bar=12, baz=Baz(quux=foo), yesno=raw)
val mystruct2 = mystruct.copy {
foo="copied"
}
println(mystruct2)
// MyStruct(foo=copied, bar=12, baz=Baz(quux=foo), yesno=raw)
println(mystruct3)
// MyStruct(foo=fooey, bar=12, baz=null, yesno=null)
Notes:
- This approach favors immutable objects (
val
vsvar
). The Java v2 SDK took the same approach and Kotlin as a language strongly favors immutability:- Java v2 SDK notes
- Kotlin coding conventions
- Smart casting with immutable objects
- Mutable vs immutable collection interfaces (e.g.
List
vsMutableList
)
- For each structure shape a builder is provided for constructing immutable objects using a Kotlin DSL approach
- For each structure shape a
copy
function is generated, providing similar functionality available in data classes - Why not data classes?
- A data class is a normal class where the compiler generates
hashCode
,equals
, andcopy
/componentN
functions for you. For this to work though the properties must show up in the default constructor. Even though Kotlin can make use of named arguments it doesn't enforce their usage so we can't generate constructors that are backwards compatible if new properties are added and a customer is making use of positional arguments.
- A data class is a normal class where the compiler generates
- The
toString()
methods are examples, the real implementation will differ and take into account the@sensitive
trait - Methods for
hashCode()
andequals
will also be generated
A union is a fixed set of types where only one type is used at any one time. In Kotlin this maps well to a sealed class.
Example
# smithy
union MyUnion {
bar: Integer,
foo: String
}
sealed class MyUnion
data class Bar(val bar: Int) : MyUnion()
data class Foo(val foo: String): MyUnion()
Services will generate both an interface and a concrete client implementation provided by the protocol implementation.
Each operation will generate a method with the given operation name and the input
and output
shapes of that
operation.
The following example from the Smithy quickstart has been abbreviated. All input/output operation structure bodies have been omitted as they aren't important to how a service is defined.
service Weather {
version: "2006-03-01",
resources: [City],
operations: [GetCurrentTime]
}
@readonly
operation GetCurrentTime {
output: GetCurrentTimeOutput
}
structure GetCurrentTimeOutput {
@required
time: Timestamp
}
resource City {
identifiers: { cityId: CityId },
read: GetCity,
list: ListCities,
resources: [Forecast],
}
resource Forecast {
identifiers: { cityId: CityId },
read: GetForecast,
}
// "pattern" is a trait.
@pattern("^[A-Za-z0-9 ]+$")
string CityId
@readonly
operation GetCity {
input: GetCityInput,
output: GetCityOutput,
errors: [NoSuchResource]
}
structure GetCityInput { ... }
structure GetCityOutput { ... }
@error("client")
structure NoSuchResource { ... }
@readonly
@paginated(items: "items")
operation ListCities {
input: ListCitiesInput,
output: ListCitiesOutput
}
structure ListCitiesInput { ... }
structure ListCitiesOutput { ... }
@readonly
operation GetForecast {
input: GetForecastInput,
output: GetForecastOutput
}
structure GetForecastInput { ... }
structure GetForecastOutput { ... }
interface Weather {
suspend fun getCurrentTime(): GetCurrentTimeOutput
/**
* ...
*
* @throws NoSuchResource
*/
suspend fun getCity(input: GetCityInput): GetCityOutput
suspend fun listCities(input: ListCitiesInput): ListCitiesOutput
suspend fun getForecast(input: GetForecastInput): GetForecastOutput
}
class WeatherClient : Weather {
suspend fun getCurrentTime(): GetCurrentTimeOutput { ... }
suspend fun getCity(input: GetCityInput): GetCityOutput { ... }
suspend fun listCities(input: ListCitiesInput): ListCitiesOutput { ... }
suspend fun getForecast(input: GetForecastInput): GetForecastOutput { ... }
}
All service operations are expected to be async operations under the hood since they imply a network call. Making this explicit in the interface sets expectations up front.
As of Kotlin 1.3 Coroutines are marked stable, they are shipped by default with the stdlib, and it is our belief that Coroutines are the future of async programming in Kotlin.
As Coroutines become the default choice for async in Kotlin our customers will expect a coroutine compatible API. Deviating from this with normal threading models is generally incompatible and will create friction.
Coroutines take a different mindset because they are not heavyweight like threads. They have much easier ways to compose them and share results. One of the design philosophies to follow (w.r.t coroutines) is "let the caller decide". What this means is when you design an API that is inherently async you let the caller decide how to handle concurrency. Perhaps they want to process results in the background, or maybe they want to launch several requests and wait for all of them to complete before continuing, and of course possibly they want to block on each call. You can't account for each scenario and by the nature of coroutines in Kotlin we don't have to decide up front.
As an example to turn our async client call to a synchronous one in Kotlin is very easy:
val service = WeatherService()
runBlocking {
val forecast = service.getForecast(input)
}
Here is another example where the getForecast()
and getCity()
operations happen concurrently and we wait for both
results to complete.
val service = WeatherService()
runBlocking {
val forecastDef = async { service.getForecast(forecastInput) }
val cityDef = async { service.getCity(cityInput) }
val forecast = forecastDef.await()
val cityDef = forecastDef.await()
}
By providing Coroutine compatible API we can let the caller decide what kind of concurrency makes sense for their use case/application and compose results as needed.
See Composing Suspend Functions for more details.
This design choice would be a breaking change with the existing aws-sdk-android
service definitions that are
generated. The current SDK generates an interface and concrete client implementation of that interface as proposed here.
The difference is it generates both synchronous and an asynchronous version. The asynchronous version is based on Java's
Future and uses a thread pool executor to
run the request to completion in the background.
We could provide a generated synchronous API that matches the existing by doing the runBlocking
call for the consumer.
The asynchronous version cannot be made to match if we are to support Coroutines though. See the Java interop discussion
below for more details.
Suspend functions are not supported directly in Java. Take the following Kotlin code for example:
class FooService {
suspend fun foo(): Int {
println("kt: starting foo")
delay(5000)
println("kt: foo finished")
return 12
}
}
Given the above function foo
in Kotlin, it would appear as a foo(Continuation<? super Integer> completion)
from
Java. What you get is the Kotlin compiler's de-sugared version of the function which is based on continuations. This is
not easily consumable or understandable. If we are going to support service definitions that are easily consumable from
Java we will need to provide either a blocking interface, an equivalent async interface based on futures, or both.
// Java compatible async service
class FooServiceAsync {
private val service: FooService = FooService()
fun fooAsync(): CompletableFuture<Int> = GlobalScope.future {
service.foo()
}
}
// Synchronous FooService client
class FooServiceBlocking {
private val service: FooService = FooService()
fun foo(): Int = runBlocking { service.foo() }
}
The synchronous interface is easy to generate. It just proxies the Kotlin coroutine service and uses the runBlocking
coroutine builder to block the current thread until completion. From Java this looks exactly how you would expect:
fooService.foo()
.
The asynchronous interface makes use of an
extra compatibility library
that transforms a suspend function call into a CompletableFuture
. It launches the coroutine into the GlobalScope and
returns a (completable) future that works as you would expect. This approach requires
org.jetbrains.kotlinx:kotlinx-coroutines-jdk8
as a dependency which is Android API level 24.
- Coroutines work fine on Android. Suspend functions were tested with Ktor HTTP client on an API level 16 (Jelly Bean) virtual device without issue.
- Blocking calls based on coroutines work fine (due to 1)
- An async interface based on
CompletableFuture
is API 24+ compatible.
Suggestion: We should take a "Kotlin first" approach and provide the suspend based coroutine API as the "primary" client interface.
Response: Why? SDKs should strive to be idiomatic for the target language. Also Google has on numerous occasions (example) that Kotlin is the preferred language going forward and that "Android will become increasingly Kotlin-first".
Each resources will be processed for each of the corresponding lifecycle operations as well as the non-lifecycle operations.
Every operation, both lifecycle and non-lifecycle, will generate a method on the service class to which the resource belongs.
This will happen recursively since resources can have child resources.
See the Service section which has a detailed example of how resources show up on a service.
Indicates that a shape is boxed which means the member may or may not contain a value and that the member has no default value.
NOTE: all shapes other than primitives are always considered boxed in the Smithy spec
structure Foo {
@box
bar: integer
}
class Foo {
var bar: Int? = null
}
Will generate the equivalent code for the shape annotated with Kotlin's @Deprecated
annotation.
@deprecated
structure Foo
@deprecated(message: "no longer used", since: "1.3")
structure Bar
@Deprecated
class Foo
@Deprecated(message = "no longer used; since 1.3")
class Bar
The error trait will be processed as an exception type in Kotlin. This requires support from the client-runtime lib.
See Modeled errors
Enums will be modeled as sealed classes. The advantage of a sealed class is in retention of unknown values with little
to no loss of usability on the use of the type. The compiler warns on non-exhaustive matching and the syntax of when
matches uses the form is XYZ
.
When no name
is provided the sealed class variant name will be the same as the value, otherwise the Kotlin SDK will
use the provided enum name.
The value will be stored as an abstract property on the sealed class and each variant will have to override it. This allows enums to be applied to other types in the future if Smithy evolves to allow it.
@enum("YES": {}, "NO": {})
string SimpleYesNo
@enum("Yes": {name: "YES"}, "No": {name: "NO"})
string TypedYesNo
Simple example:
sealed class SimpleYesNo {
abstract val value: String
object Yes: SimpleYesNo() {
override val value: String = "YES"
override fun toString(): String = value
}
object No: SimpleYesNo() {
override val value: String = "NO"
override fun toString(): String = value
}
data class SdkUnknown(override val value: String): SimpleYesNo() {
override fun toString(): String = "SdkUnknown($value)"
}
companion object {
/**
* Convert a raw string to an enum constant using either the constant name or it's value
*/
fun fromValue(str: String): SimpleYesNo = when(str) {
"YES" -> Yes
"NO" -> No
else -> SdkUnknown(str)
}
/**
* Get a list of all possible variants
*/
fun values(): List<SimpleYesNo> = listOf(Yes, No)
}
}
sealed class TypedYesNo {
abstract val value: String
object Yes: TypedYesNo() {
override val value: String = "Yes"
override fun toString(): String = value
}
object No: TypedYesNo() {
override val value: String = "No"
override fun toString(): String = value
}
data class SdkUnknown(override val value: String): TypedYesNo() {
override fun toString(): String = value
}
companion object {
fun fromValue(str: String): TypedYesNo = when(str) {
"Yes" -> Yes
"No" -> No
else -> SdkUnknown(str)
}
fun values(): List<TypedYesNo> = listOf(Yes, No)
}
}
More complex example:
@enum(
t2.nano: {
name: "T2_NANO",
documentation: """
T2 instances are Burstable Performance
Instances that provide a baseline level of CPU
performance with the ability to burst above the
baseline.""",
tags: ["ebsOnly"]
},
t2.micro: {
name: "T2_MICRO",
documentation: """
T2 instances are Burstable Performance
Instances that provide a baseline level of CPU
performance with the ability to burst above the
baseline.""",
tags: ["ebsOnly"]
},
m256.mega: {
name: "M256_MEGA",
deprecated: true
}
)
string MyString
sealed class MyString {
abstract val value: String
/**
* T2 instances are Burstable Performance
* Instances that provide a baseline level of CPU
* performance with the ability to burst above the
* baseline.
*/
object T2Micro : MyString() {
override val value: String = "t2.micro"
override fun toString(): String = value
}
object T2Nano : MyString() {
override val value: String = "t2.nano"
override fun toString(): String = value
}
@Deprecated("deprecated")
object M256Mega : MyString() {
override val value: String = "m256.mega"
override fun toString(): String = value
}
data class SdkUnknown(override val value: String) : MyString() {
override fun toString(): String = value
}
companion object {
/**
* Convert a raw value to one of the sealed variants or [SdkUnknown]
*/
fun fromValue(str: String): MyString = when(str) {
"t2.micro" -> T2Micro
"t2.nano" -> T2Nano
"m256.mega" -> M256Mega
else -> SdkUnknown(str)
}
/**
* Get a list of all possible variants
*/
fun values(): List<MyString> = listOf(
T2Micro,
T2Nano,
M256Mega
)
}
}
Serialization will need to make use of the given value
of the enum rather than the name of the enum constant.
The Smithy core spec indicates that unknown enum values need to be handled as well.
Consumers that choose to represent enums as constants SHOULD ensure that unknown enum names returned from a service do not cause runtime failures.
Each sealed class is generated with an
SdkUnknown
variant which is used to deal with forwards compatibility.
Not processed
Not processed
Not processed
Not processed
Not processed
Not processed
Not processed
Not processed
Not processed
Not processed
This trait influences errors, see the error
trait for how it will be handled.
See Pagination.
Not processed
Not processed
Inspected to see if the protocol is supported by the code generator/client-runtime. If no protocol is supported codegen will fail.
The auth
peroperty of this trait will be inspected just to confirm at least one of the authentication schemes is
supported.
All of the built-in HTTP authentication schemes will be supported by being able to customize the request headers.
Processed the same as the auth
property of the protocols
trait.
Influences serialization/deserialization
The media type trait SHOULD influence the HTTP Content-Type header if not already set.
Influences serialization/deserialization
All top level classes, enums, and their members will be generated with the given documentation.
Not processed
Processed the same as the documentation
trait. The link will be processed appropriately for the target documentation
engine (e.g. dokka).
Influences the generated toString()
method to ignore sensitive values
Not processed
Not processed
Combined with the generated documentation as the first text to show up for a service or resource.
Influences endpoint resolution
Binary streams: Kotlin (binary) streaming request/response bodies
Event streams: Event streams
- 11/15/2021 - Update code snippets from builder refactoring
- 5/27/2021 - Initial upload