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

feat(codegen): improve nullability of generated types #968

Merged
merged 25 commits into from
Oct 25, 2023
Merged

Conversation

aajtodd
Copy link
Contributor

@aajtodd aajtodd commented Oct 2, 2023

Issue #

n/a

Description of changes

Previously we set nullability information on symbols coming out of KotlinSymbolProvider based on the underlying type
and whether a default value was provided. This meant things like List, String, Enum, etc were always considered nullable. This was based on IDL v1 semantics w.r.t `Primitive*" shapes being the only shapes that were non-nullable (since they had an implicit default).

This PR removes all notion of how we considered nullability previously to instead rely entirely on the NullableIndex
as the source of truth. The new mental model is that converting any old shape (in particular explicit member.target)
is not going to give you nullability information. Only member shapes will. This is because nullability is local to how
a target shape is used in the context of some container (e.g. structure) shape.

For operation inputs customers will now see a runtime exception for any @required member without a @default set.
This shouldn't affect anyone with a working application assuming services actually validated these fields as required.
For operation outputs any missing field will be corrected using client error correction introduced in #958 such that all non-nullable members of the output struct end up with a value instead of throwing an exception.

  • feat! (BREAKING): Add plugin setting to control nullability check mode and change the default to be CLIENT_CAREFUL.
    We now generate structures with non-null members anywhere NullableIndex indicates a members is non-nullable. Previously this would have been only Primitive* shapes. We also update when we respect @default trait and only set a default in the builder (on the symbol) if NullableIndex indicates that it's non-nullable T
  • feat: Add plugin setting to control when default values are serialized. Previously we only serialized numbers and booleans when they were set to something other than the default or marked @required. The default settings is the same but can be updated to "always" which would serialize defaults even if they aren't different.
    • I've added this as a setting because the current smithy spec recommends to always serialize defaults. I had implemented this change but it causes some issues with S3 not accepting default values. Smithy team maintains this may be fixed with model updates but who knows when they will come. Instead I've opted to keep the current default value serialization semantics but allowed for easy toggling back and forth.
  • refactor: Improve struct serialization codegen to better handle nullability and reduce the number of generated warnings.
  • refactor: Remove unnecessary safe-call on non-nullable prefix headers. See disallow sparse prefixHeaders
  • refactor: Move httpLabel required checks introduced in fix: require values for HTTP query- and queryParams-bound parameters #697 to the operation serializer.

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

Comment on lines 235 to 251
/**
* Always serialize default values even if they are set to the default
*/
ALWAYS("always"),

/**
* Only serialize default values when they differ from the default given in the model.
*/
WHEN_DIFFERENT("whenDifferent"),
;
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggestion: These KDocs are confusing to me because they use the term "default values" to (I believe) indicate "values set on fields which have the @default trait". Perhaps they would be clearer as:

  • Always serialize values even when they are set to the default given in the model
  • Only serialize values when they differ from the default given in the model

Copy link
Contributor

Choose a reason for hiding this comment

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

or just add one word: "modeled default"

*/
WHEN_DIFFERENT("whenDifferent"),
;
override fun toString(): String = value
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 override this? Does some code rely on the output of toString?

Comment on lines +223 to +233
private fun checkModefromValue(value: String): CheckMode {
val camelCaseToMode = CheckMode.values().associateBy { it.toString().toCamelCase() }
return requireNotNull(camelCaseToMode[value]) { "$value is not a valid CheckMode, expected one of ${camelCaseToMode.keys}" }
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Correctness: This is a case-sensitive comparison. Do we want that?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do we not?

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 do not. Build files will be written mostly by humans and so I think we should be forgiving about capitalization.

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 disagree. It results in an error so it's not like a human isn't going to find it and I'd rather be explicit.

Comment on lines +247 to +257
fun fromValue(value: String): DefaultValueSerializationMode =
values().find {
it.value == value
} ?: throw IllegalArgumentException("$value is not a valid DefaultValueSerializationMode, expected one of ${values().map { it.value }}")
Copy link
Contributor

Choose a reason for hiding this comment

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

Correctness: This is a case-sensitive comparison. Do we want that?


fun fromNode(node: Optional<ObjectNode>): ApiSettings = node.map {
val visibility = Visibility.fromValue(node.get().getStringMemberOrDefault(VISIBILITY, "public"))
ApiSettings(visibility)
val checkMode = checkModefromValue(node.get().getStringMemberOrDefault(NULLABILITY_CHECK_MODE, "clientCareful"))
Copy link
Contributor

Choose a reason for hiding this comment

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

Style: This seems odd because it specifies as the default a string constant which isn't verified at compile time. This might be safer as getting the nullable string member value, ?.let passing that to the deserializer function, and then null-coalescing to CheckMode.CLIENT_CAREFUL.

Comment on lines +63 to +64
// FIXME - we only generate an endpoint provider type if we have a protocol generator defined
if (context.protocolGenerator != null) {
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 did this change?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So that we don't have to add a hand written endpoint provider in things like waiter-tests, nullability-tests, and paginator-tests. We shouldn't be generating code that doesn't compile by default and we should likely be generating those definitions without the protocol generator.


writer.write("input.#L?.let { field(#L, it, #L) }", memberName, memberShape.descriptorName(), tsFormat)
val fn = serializerFn.format(memberShape, "input.$memberName")
writer.write("${defaultCheck}${fn}$postfix")
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Braces unnecessary.

@@ -510,4 +510,74 @@ internal class SmokeTestOperationDeserializer: HttpDeserialize<SmokeTestResponse
"""
contents.shouldContainOnlyOnceWithDiff(expected)
}

@Test
fun itValidatesRequiredAndNonBlankURIBindings() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: URIUri

Comment on lines 51 to 52
doLast {
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Question: What does this do?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

🤔 , nothing not sure where it came from.

private const val SmithyVersion = "1.0"
private const val SmithyVersion = "2.0"
Copy link
Contributor

Choose a reason for hiding this comment

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

Question: Where is this used?

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 question, I believe it's used in the test utils somewhere as the model version.

Comment on lines 235 to 251
/**
* Always serialize default values even if they are set to the default
*/
ALWAYS("always"),

/**
* Only serialize default values when they differ from the default given in the model.
*/
WHEN_DIFFERENT("whenDifferent"),
;
Copy link
Contributor

Choose a reason for hiding this comment

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

or just add one word: "modeled default"

val prefix = if (shape.isError && memberName == "message") {
val targetShape = model.expectShape(memberShape.target)
if (!targetShape.isStringShape) {
throw CodegenException("Message is a reserved name for exception types and cannot be used for any other property")
Copy link
Contributor

Choose a reason for hiding this comment

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

Should the exception refer to lowercase "message"?


plugins {
kotlin("jvm")
@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
Copy link
Contributor

Choose a reason for hiding this comment

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

It's fixed in Gradle 8.1, maybe we should add that detail?

@aajtodd aajtodd force-pushed the fix-nullability branch 2 times, most recently from 61d90fe to 1b7920f Compare October 19, 2023 14:24
@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 23 Code Smells

No Coverage information No Coverage information
0.0% 0.0% Duplication

@aajtodd aajtodd merged commit 4f06399 into main Oct 25, 2023
13 of 14 checks passed
@aajtodd aajtodd deleted the fix-nullability branch October 25, 2023 16:50
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.

3 participants