-
Notifications
You must be signed in to change notification settings - Fork 28
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
Paginator codegen using Kotlin Flow #557
Conversation
…ame. Minor cleanup and additional tests.
37a25e8
to
d7535a3
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice!
Minor fixes/suggestions. Overall looks great
* https://awslabs.github.io/smithy/1.0/spec/core/behavior-traits.html#paginated-trait for details. | ||
*/ | ||
class PaginatorGenerator : KotlinIntegration { | ||
private val kotlinxFlowSymbol = "kotlinx.coroutines.flow.Flow".toSymbol() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fix
Move to RuntimeTypes
(or similar).
Also need to add a dependency on kotlinx.coroutines
. We can either do that by defining it in KotlinDependency
or alternatively we can add an api("kotlinx.coroutines:$coroutinesVersion")
to runtime-core/build.gradle.kts
for managing the dependency (since runtime-core
is basically guaranteed to always be pulled in). This may be favorable to ensure the version stays in sync with the runtime.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I created a new object
to house external types, I think it makes sense to keep them separate from our own runtime types. Regarding the dependency aspect, yeah we had discussed this but then I found that everything works as is so was reluctant to add it without knowing why it's needed. I notice that the build file you mention already expresses a dependency on coroutines-core
, which is where Flow
and friends live. Is there something I'm missing other than switching from implementation
to api
?
val service = ctx.model.expectShape<ServiceShape>(ctx.settings.service) | ||
val paginatedIndex = PaginatedIndex.of(ctx.model) | ||
|
||
delegator.useFileWriter("Paginators.kt", "${ctx.settings.pkg.name}.paginator") { writer -> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question
Should we use paginator/waiter
or paginators/waiters
as the package name?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
personally I don't see the value of the nested package. But as we have opted to do this, singular requires one less byte so that would be my vote.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Plural better matches conventions in the ecosystem (e.g., kotlin.sequences, kotlin.streams, kotlin.coroutines, etc.) so paginators
gets my vote. We could also name it pagination
in the theory that there may be additional helper classes in the future.
.filter { operationShape -> operationShape.hasTrait(PaginatedTrait.ID) } | ||
|
||
paginatedOperations.forEach { paginatedOperation -> | ||
val paginationInfo = paginatedIndex.getPaginationInfo(service, paginatedOperation).getOrNull() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style
Doesn't optional have orElseThrow { }
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It does but it doesn't throw a CodegenException
nor does it provide a way of setting a message. I think this is better as is.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right no I want to keep CodegenException
, it looked to me like you can supply your own
val paginationInfo = paginatedIndex.getPaginationInfo(...)
.orElseThrow {
CodegenException(...)
}
What you have is fine though.
val markerLiteral = paginationInfo.inputTokenMember.defaultName() | ||
|
||
writer.write("") | ||
writer.dokka("Paginate over [${outputSymbol.name}]") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could probably come up with more complete documentation.
just spit balling
/**
* Paginate over [Client.Operation] results.
* When this operation is called, a [kotlinx.coroutines.Flow] is created. Flows are lazy (cold) so no service calls are
* made until the flow is collected. This also means there is no guarantee that the request is valid until then. Once
* you start collecting the flow, the SDK will lazily load response pages by making service calls until there are no
* pages left or the flow is cancelled. If there are errors in your request, you will see the failures only after you start
* collection.
*
* @return a flow of [outputSymbol]
*/
fun Client.fooPaginated(...): Flow<..> { ... }
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fantastic!
inputSymbol, | ||
outputSymbol | ||
) { | ||
withBlock("return flow {", "}") { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this could just be = flow {
from the outer block with the function definition (L125)
|
||
tasks["smithyBuildJar"].enabled = false | ||
|
||
val codegen by configurations.creating |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you don't need this or any of the stuff in generateSdk
that uses it. There is no custom codegen component for this project
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems to fail at gradle configuration if I remove it.
val codegenSourceInfo = listOf("paginator-tests").map{ CodegenSourceInfo(it) } | ||
|
||
|
||
val stageGeneratedSources = tasks.register("stageGeneratedSources") { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if you're leaving it in the buildDir we could just avoid any copying at all and include the projection dir directly on L52
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll look into this in a subsequent refactor of project from kmp to jvm.
* each dot represents a service round trip. Within each service round trip a list of [FunctionConfiguration] | ||
* is generated including the round trip index. | ||
*/ | ||
class DefaultLambdaClient(config: LambdaClient.Config) : LambdaClient { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question
Won't this conflict with the generated DefaultLambdaClient
? Or is it not generating one because no protocol generator is found?
Either way we should probably rename this to TestLambdaClient
or something to remove ambiguity.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also LambdaClient.Config
looks unused, no need to keep it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Won't this conflict with the generated DefaultLambdaClient? Or is it not generating one because no protocol generator is found?
Yes. In fact the test client must be called this due to reference to the type name in LambdaClient
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LambdaClient.Config
is also required for compilation to succeed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see, can we document what is happening here because it was confusing to me at first glance. Something to the effect of The default client is not generated because no protocol generator is found/available, we supply a hand written version instead suitable for these tests
Not something in this PR but it seems like we should maybe make the generated clients not depend on DefaultLambdaClient
. IDK I mean I guess in practice it should normally always be there.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added a note to describe the situation as requested.
seems like we should maybe make the generated clients not depend on DefaultLambdaClient.
Yeah I think this is the rough edge of where a proper Smithy SDK would be designed and built.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Verified that I can address these concerns after #564 is merged.
*/ | ||
class DefaultLambdaClient(config: LambdaClient.Config) : LambdaClient { | ||
override val config: LambdaClient.Config | ||
get() = TODO("Unneeded for test") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we wanted to leave TODO
for real todos. Move to error()
itemDesc.collectionLiteral | ||
) { | ||
withBlock("transform() { response -> ", "}") { | ||
withBlock("response.#L?.forEach {", "}", itemDesc.itemPathLiteral) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question
Is this correct for maps? We end up with an Entry
in that case?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, we get a Map.Entry
. Here is a sample from apigateway
:
fun Flow<GetUsageResponse>.items(): Flow<Map.Entry<String, List<List<Long>>>> =
transform() { response ->
response.items?.forEach {
emit(it)
}
}
val service = ctx.model.expectShape<ServiceShape>(ctx.settings.service) | ||
val paginatedIndex = PaginatedIndex.of(ctx.model) | ||
|
||
delegator.useFileWriter("Paginators.kt", "${ctx.settings.pkg.name}.paginator") { writer -> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Plural better matches conventions in the ecosystem (e.g., kotlin.sequences, kotlin.streams, kotlin.coroutines, etc.) so paginators
gets my vote. We could also name it pagination
in the theory that there may be additional helper classes in the future.
inputSymbol, | ||
outputSymbol | ||
) { | ||
withBlock("flow {", "}") { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Seems like this could be on the previous line: fun #T.#LPaginated(initialRequest: #T): Flow<#T> = flow {
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the following line will be more readable as the function names may end up quite long for some services (where the #L
expands). Knowing that the body is a Flow
is critical to understanding the function and if it's off screen that's harder to see.
write("var cursor: #F = null", cursorSymbol) | ||
write("var isFirstPage: Boolean = true") | ||
write("") | ||
withBlock("while (isFirstPage || (cursor?.isNotEmpty() == true)) {", "}") { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: The cursor symbol is detected above but isNotEmpty()
seems like it can only apply to specific types. Will this always be a string? If so, do we need to go through the trouble of keeping the cursor type dynamic?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will this always be a string?
Yes, according to the Smithy spec on the paginated
trait.
If so, do we need to go through the trouble of keeping the cursor type dynamic?
This is simply using the model to generate it's specified type. What do you find troublesome, the action to retrieve the type of the member, using a variable to store that, or the notation in CodeWriter
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All of the above. In cases where we know the one-and-only type of a variable, we typically hardcode that (e.g., var isFirstPage: Boolean
). The resulting codegen code is much more readable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is cursor the next token? This is not always a string. See:
"com.amazonaws.dynamodb#Key": {
"type": "map",
"key": {
"target": "com.amazonaws.dynamodb#AttributeName"
},
"value": {
"target": "com.amazonaws.dynamodb#AttributeValue"
}
},
which is used as a token for DynamoDb::scan
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good callout, tracking issue: #565
writer.write("") | ||
writer.dokka( | ||
""" | ||
This paginator transforms the flow returned by [${operationShape.defaultName()}Paginated] to access the nested member [${itemDesc.targetMember.defaultName(serviceShape)}] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Style: Line length > 120 chars.
writer.dokka( | ||
""" | ||
This paginator transforms the flow returned by [${operationShape.defaultName()}Paginated] to access the nested member [${itemDesc.targetMember.defaultName(serviceShape)}] | ||
@return a [kotlinx.coroutines.flow.Flow] that can collect [${itemDesc.targetMember.defaultName(serviceShape)}] | ||
""".trimIndent() | ||
) | ||
writer.dokka("Paginate over [${outputSymbol.name}.${itemDesc.itemLiteral}]") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: Why two writer.dokka
s?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
whoops forgot to delete the original.. ナイスキャッチ
// NOTE: This does not mean these functions are callable from Java. | ||
.write( | ||
"""@JvmName("#L#L")""", | ||
outputSymbol.name.toggleFirstCharacterCase(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: Explicitly casting the first character to lowercase would clarify the intent here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This?
outputSymbol.name.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm confused...do we want this to be uppercase? That's not normally how Java/Kotlin methods are named. Assuming not, I think this captures the intent more clearly:
outputSymbol.name.replaceFirstChar(Char::lowercaseChar)
I agree platform consistency wins, will change. |
Kudos, SonarCloud Quality Gate passed! 0 Bugs No Coverage information |
Issue #
#124
Description of changes
/tests
/ folderTesting done
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.