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

Paginator codegen using Kotlin Flow #557

Merged
merged 18 commits into from
Jan 10, 2022
Merged

Paginator codegen using Kotlin Flow #557

merged 18 commits into from
Jan 10, 2022

Conversation

kggilmer
Copy link
Contributor

@kggilmer kggilmer commented Dec 21, 2021

Issue #

#124

Description of changes

  • Generate paginator extension functions for paginated operations
  • Use the kotlinx-coroutines flow abstraction
  • Reorganize compile-tests and benchmarks into common /tests/ folder
  • Use benchmark codegen strategy to codegen paginators and then test them

Testing done

  • Built against all service SDKs, verify all services compile
  • Write and written tests against dynamodb, ensure that functions return expected data

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

@kggilmer kggilmer requested review from aajtodd and ianbotsf December 21, 2021 01:24
@kggilmer kggilmer changed the title Paginator codegen using Koltin Flow Paginator codegen using Kotlin Flow Dec 21, 2021
@kggilmer kggilmer force-pushed the feat-flow-paginator branch from 37a25e8 to d7535a3 Compare January 6, 2022 15:36
@kggilmer kggilmer marked this pull request as ready for review January 6, 2022 15:37
@kggilmer kggilmer requested a review from a team as a code owner January 6, 2022 15:37
Copy link
Contributor

@aajtodd aajtodd left a 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()
Copy link
Contributor

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.

Copy link
Contributor Author

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 ->
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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()
Copy link
Contributor

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 { } ?

Copy link
Contributor Author

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.

Copy link
Contributor

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}]")
Copy link
Contributor

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<..> { ... }

Copy link
Contributor Author

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 {", "}") {
Copy link
Contributor

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
Copy link
Contributor

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

Copy link
Contributor Author

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") {
Copy link
Contributor

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

Copy link
Contributor Author

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 {
Copy link
Contributor

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.

Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor Author

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

Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor Author

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")
Copy link
Contributor

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) {
Copy link
Contributor

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?

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 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 ->
Copy link
Contributor

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 {", "}") {
Copy link
Contributor

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 {

Copy link
Contributor Author

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)) {", "}") {
Copy link
Contributor

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?

Copy link
Contributor Author

@kggilmer kggilmer Jan 7, 2022

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?

Copy link
Contributor

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.

Copy link

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

Copy link
Contributor Author

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)}]
Copy link
Contributor

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.

Comment on lines 171 to 177
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}]")
Copy link
Contributor

Choose a reason for hiding this comment

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

Question: Why two writer.dokkas?

Copy link
Contributor Author

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(),
Copy link
Contributor

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.

Copy link
Contributor Author

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()
}

Copy link
Contributor

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)

@kggilmer
Copy link
Contributor Author

kggilmer commented Jan 7, 2022

Plural better matches conventions in the ecosystem (e.g., kotlin.sequences, kotlin.streams, kotlin.coroutines, etc.) so paginators gets my vote.

I agree platform consistency wins, will change.

@sonarqubecloud
Copy link

Kudos, SonarCloud Quality Gate passed!    Quality Gate passed

Bug A 0 Bugs
Vulnerability A 0 Vulnerabilities
Security Hotspot A 0 Security Hotspots
Code Smell A 17 Code Smells

No Coverage information No Coverage information
0.3% 0.3% Duplication

@kggilmer kggilmer merged commit 1197943 into main Jan 10, 2022
@kggilmer kggilmer deleted the feat-flow-paginator branch January 10, 2022 18:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants