diff --git a/.brazil.json b/.brazil.json index dbb2135d1b..0908ffedb4 100644 --- a/.brazil.json +++ b/.brazil.json @@ -7,22 +7,35 @@ "com.squareup.okhttp3:okhttp:5.*": "OkHttp3-5.x", "com.squareup.okio:okio-jvm:3.*": "OkioJvm-3.x", "io.opentelemetry:opentelemetry-api:1.*": "Maven-io-opentelemetry_opentelemetry-api-1.x", + "io.opentelemetry:opentelemetry-extension-kotlin:1.*": "Maven-io-opentelemetry_opentelemetry-extension-kotlin-1.x", "org.slf4j:slf4j-api:2.*": "Maven-org-slf4j_slf4j-api-2.x", "aws.sdk.kotlin.crt:aws-crt-kotlin:0.9.*": "AwsCrtKotlin-0.9.x", "aws.sdk.kotlin.crt:aws-crt-kotlin:0.8.*": "AwsCrtKotlin-0.8.x", - "com.squareup.okhttp3:okhttp:4.*": "OkHttp3-4.x" + "com.squareup.okhttp3:okhttp:4.*": "OkHttp3-4.x", + + "software.amazon.smithy:smithy-aws-traits:1.*": "Maven-software-amazon-smithy_smithy-aws-traits-1.x", + "software.amazon.smithy:smithy-aws-iam-traits:1.*": "Maven-software-amazon-smithy_smithy-aws-iam-traits-1.x", + "software.amazon.smithy:smithy-aws-cloudformation-traits:1.*": "Maven-software-amazon-smithy_smithy-aws-cloudformation-traits-1.x", + "software.amazon.smithy:smithy-protocol-test-traits:1.*": "Maven-software-amazon-smithy_smithy-protocol-test-traits-1.x", + "software.amazon.smithy:smithy-protocol-traits:1.*": "Maven-software-amazon-smithy_smithy-protocol-traits-1.x", + "software.amazon.smithy:smithy-aws-endpoints:1.*": "Maven-software-amazon-smithy_smithy-aws-endpoints-1.x", + "software.amazon.smithy:smithy-codegen-core:1.*": "Maven-software-amazon-smithy_smithy-codegen-core-1.x", + "software.amazon.smithy:smithy-waiters:1.*": "Maven-software-amazon-smithy_smithy-waiters-1.x", + "software.amazon.smithy:smithy-rules-engine:1.*": "Maven-software-amazon-smithy_smithy-rules-engine-1.x", + "software.amazon.smithy:smithy-smoke-test-traits:1.*": "Maven-software-amazon-smithy_smithy-smoke-test-traits-1.x", + "org.jsoup:jsoup:1.19.*": "Maven-jsoup-1.19.x" }, "packageHandlingRules": { "versioning": { "defaultVersionLayout": "{MAJOR}.0.x", "overrides": { - "software.amazon.smithy.kotlin:smithy-kotlin-codegen": "{MAJOR}.{MINOR}.x", - "software.amazon.smithy.kotlin:smithy-kotlin-codegen-testutils": "{MAJOR}.{MINOR}.x" + "software.amazon.smithy.kotlin:smithy-aws-kotlin-codegen": "{MAJOR}.x", + "software.amazon.smithy.kotlin:smithy-kotlin-codegen": "{MAJOR}.x" } }, "rename": { - "software.amazon.smithy.kotlin:smithy-kotlin-codegen": "SmithyKotlinCodegen", - "software.amazon.smithy.kotlin:smithy-kotlin-codegen-testutils": "SmithyKotlinCodegenTestUtils" + "software.amazon.smithy.kotlin:smithy-aws-kotlin-codegen": "AwsSmithyAwsKotlinCodegen", + "software.amazon.smithy.kotlin:smithy-kotlin-codegen": "AwsSmithyKotlinCodegen" }, "ignore": [ "aws.smithy.kotlin:http-test", @@ -30,7 +43,8 @@ "aws.smithy.kotlin:telemetry-provider-micrometer", "aws.smithy.kotlin:testing", "aws.smithy.kotlin:bom", - "aws.smithy.kotlin:version-catalog" + "aws.smithy.kotlin:version-catalog", + "software.amazon.smithy.kotlin:smithy-kotlin-codegen-testutils" ], "resolvesConflictDependencies": { "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.*": [ diff --git a/.github/workflows/artifact-size-metrics.yml b/.github/workflows/artifact-size-metrics.yml index 0591b2b6aa..d690d87916 100644 --- a/.github/workflows/artifact-size-metrics.yml +++ b/.github/workflows/artifact-size-metrics.yml @@ -31,6 +31,8 @@ jobs: with: role-to-assume: ${{ secrets.CI_AWS_ROLE_ARN }} aws-region: us-west-2 + - name: Configure Gradle + uses: awslabs/aws-kotlin-repo-tools/.github/actions/configure-gradle@main - name: Generate Artifact Size Metrics run: ./gradlew artifactSizeMetrics - name: Save Artifact Size Metrics @@ -54,60 +56,15 @@ jobs: with: role-to-assume: ${{ secrets.CI_AWS_ROLE_ARN }} aws-region: us-west-2 + - name: Configure Gradle + uses: awslabs/aws-kotlin-repo-tools/.github/actions/configure-gradle@main - name: Generate Artifact Size Metrics run: ./gradlew artifactSizeMetrics - name: Analyze Artifact Size Metrics run: ./gradlew analyzeArtifactSizeMetrics - - name: Show Results - uses: actions/github-script@v7 - with: - script: | - const getComments = - `query { - repository(owner:"${context.repo.owner}", name:"${context.repo.repo}"){ - pullRequest(number: ${context.issue.number}) { - id - comments(last:100) { - nodes { - id - body - author { - login - } - isMinimized - } - } - } - } - }` - - const response = await github.graphql(getComments) - const comments = response.repository.pullRequest.comments.nodes - - const mutations = comments - .filter(comment => comment.author.login == 'github-actions' && !comment.isMinimized && comment.body.startsWith('Affected Artifacts')) - .map(comment => - github.graphql( - `mutation { - minimizeComment(input:{subjectId:"${comment.id}", classifier:OUTDATED}){ - clientMutationId - } - }` - ) - ) - await Promise.all(mutations) - const fs = require('node:fs') - const comment = fs.readFileSync('build/reports/metrics/artifact-analysis.md', 'utf8') - - const writeComment = - `mutation { - addComment(input:{body:"""${comment}""", subjectId:"${response.repository.pullRequest.id}"}){ - clientMutationId - } - }` - - await github.graphql(writeComment) + - name: Show Results + uses: awslabs/aws-kotlin-repo-tools/.github/actions/artifact-size-metrics/show-results@main - name: Evaluate if: ${{ !contains(github.event.pull_request.labels.*.name, 'acknowledge-artifact-size-increase') }} diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index d47020ea5d..5536a141bc 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -39,6 +39,8 @@ jobs: distribution: 'corretto' java-version: 17 cache: 'gradle' + - name: Configure Gradle + uses: awslabs/aws-kotlin-repo-tools/.github/actions/configure-gradle@main - name: Test shell: bash run: | @@ -59,6 +61,8 @@ jobs: distribution: 'corretto' java-version: 17 cache: 'gradle' + - name: Configure Gradle + uses: awslabs/aws-kotlin-repo-tools/.github/actions/configure-gradle@main - name: Test shell: bash run: | @@ -83,6 +87,8 @@ jobs: distribution: 'corretto' java-version: 17 cache: 'gradle' + - name: Configure Gradle + uses: awslabs/aws-kotlin-repo-tools/.github/actions/configure-gradle@main - name: Test shell: bash run: | @@ -110,6 +116,14 @@ jobs: # smithy-kotlin is checked out as a sibling dir which will automatically make it an included build path: 'aws-sdk-kotlin' repository: 'awslabs/aws-sdk-kotlin' + - name: Configure Gradle - smithy-kotlin + uses: awslabs/aws-kotlin-repo-tools/.github/actions/configure-gradle@main + with: + working-directory: ./smithy-kotlin + - name: Configure Gradle - aws-sdk-kotlin + uses: awslabs/aws-kotlin-repo-tools/.github/actions/configure-gradle@main + with: + working-directory: ./aws-sdk-kotlin - name: Configure JDK uses: actions/setup-java@v3 with: diff --git a/.github/workflows/kat-transform.yml b/.github/workflows/kat-transform.yml index 938e936ef9..bcf6c933c1 100644 --- a/.github/workflows/kat-transform.yml +++ b/.github/workflows/kat-transform.yml @@ -35,6 +35,11 @@ jobs: role-to-assume: ${{ secrets.CI_AWS_ROLE_ARN }} aws-region: us-west-2 + - name: Configure Gradle + uses: awslabs/aws-kotlin-repo-tools/.github/actions/configure-gradle@main + with: + working-directory: ./smithy-kotlin + - name: Setup kat uses: awslabs/aws-kotlin-repo-tools/.github/actions/setup-kat@main diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e95b98aded..809b2bc451 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -21,6 +21,8 @@ jobs: steps: - name: Checkout sources uses: actions/checkout@v2 + - name: Configure Gradle + uses: awslabs/aws-kotlin-repo-tools/.github/actions/configure-gradle@main - name: Lint ${{ env.PACKAGE_NAME }} run: | ./gradlew ktlint diff --git a/.github/workflows/merge-main.yml b/.github/workflows/merge-main.yml new file mode 100644 index 0000000000..1b354b2ad7 --- /dev/null +++ b/.github/workflows/merge-main.yml @@ -0,0 +1,14 @@ +name: Merge main +on: + push: + branches: [ main ] + workflow_dispatch: + +jobs: + merge: + runs-on: ubuntu-latest + steps: + - name: Merge main + uses: awslabs/aws-kotlin-repo-tools/.github/actions/merge-main@main + with: + exempt-branches: # Add any if required \ No newline at end of file diff --git a/.github/workflows/sync-mirror.yml b/.github/workflows/sync-mirror.yml new file mode 100644 index 0000000000..edc80ce30c --- /dev/null +++ b/.github/workflows/sync-mirror.yml @@ -0,0 +1,20 @@ +name: Sync Mirror + +on: + push: + branches: [ main ] + workflow_dispatch: + +jobs: + git-sync: + # Only sync when pushing to source repo + if: github.repository == 'smithy-lang/smithy-kotlin' + runs-on: ubuntu-latest + steps: + - name: git-sync + uses: wei/git-sync@v3 + with: + source_repo: "https://aws-sdk-kotlin-ci:${{ secrets.CI_USER_PAT }}@github.com/smithy-lang/smithy-kotlin.git" + source_branch: "main" + destination_repo: "https://aws-sdk-kotlin-ci:${{ secrets.CI_USER_PAT }}@github.com/smithy-lang/private-smithy-kotlin-staging.git" + destination_branch: "main" \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e925d0943..f44b2726cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,53 @@ # Changelog +## [1.4.13] - 04/10/2025 + +## [1.4.12] - 04/04/2025 + +## [1.4.11] - 03/14/2025 + +## [1.4.10] - 03/06/2025 + +### Fixes +* Correctly handle sequential calls to `SingleFlightGroup` + +## [1.4.9] - 02/27/2025 + +### Fixes +* Correctly generate paginators for item type names which collide with other used types (e.g., an item type `com.foo.Flow` which conflicts with `kotlinx.coroutines.flow.Flow`) + +## [1.4.8] - 02/27/2025 + +### Fixes +* Idempotency tokens are no longer code-generated for nested structures. See: https://smithy.io/2.0/spec/behavior-traits.html#smithy-api-idempotencytoken-trait + +## [1.4.7] - 02/25/2025 + +### Fixes +* [#1211](https://github.com/smithy-lang/smithy-kotlin/issues/1211) Fix OpenTelemetry span concurrency by using Span.asContextElement() instead of Span.makeCurrent() + +## [1.4.6] - 02/25/2025 + +## [1.4.5] - 02/24/2025 + +### Features +* Add SigV4a support to the default AWS signer + +## [1.4.4] - 02/18/2025 + +### Miscellaneous +* Increase maximum event stream message length to 24MB + +## [1.4.3] - 02/13/2025 + +### Fixes +* Fix errors in equality checks for `CaseInsensitiveMap` which affect `Headers` and `ValuesMap` implementations +* fix: correct hash code calculation for case-insensitive map entries +* [#1413](https://github.com/awslabs/aws-sdk-kotlin/issues/1413) Favor `endpointUrl` over endpoint discovery when provided + +### Miscellaneous +* Add telemetry provider configuration to `DefaultAwsSigner` + ## [1.4.2] - 01/28/2025 ### Fixes diff --git a/codegen/smithy-aws-kotlin-codegen/build.gradle.kts b/codegen/smithy-aws-kotlin-codegen/build.gradle.kts index 96a7442a5c..d2fdef82f4 100644 --- a/codegen/smithy-aws-kotlin-codegen/build.gradle.kts +++ b/codegen/smithy-aws-kotlin-codegen/build.gradle.kts @@ -13,15 +13,11 @@ plugins { } val codegenVersion: String by project -description = "Codegen support for AWS protocols" +description = "Smithy codegen support for AWS protocols" group = "software.amazon.smithy.kotlin" version = codegenVersion -val sdkVersion: String by project - dependencies { - - implementation(libs.kotlin.stdlib.jdk8) api(project(":codegen:smithy-kotlin-codegen")) api(libs.smithy.aws.traits) diff --git a/codegen/smithy-aws-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/aws/protocols/AwsQueryTest.kt b/codegen/smithy-aws-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/aws/protocols/AwsQueryTest.kt new file mode 100644 index 0000000000..8a575c7cd4 --- /dev/null +++ b/codegen/smithy-aws-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/aws/protocols/AwsQueryTest.kt @@ -0,0 +1,105 @@ +package software.amazon.smithy.kotlin.codegen.aws.protocols + +import software.amazon.smithy.kotlin.codegen.test.* +import kotlin.test.Test + +class AwsQueryTest { + @Test + fun testNonNestedIdempotencyToken() { + val ctx = model.newTestContext("Example") + + val generator = AwsQuery() + generator.generateProtocolClient(ctx.generationCtx) + + ctx.generationCtx.delegator.finalize() + ctx.generationCtx.delegator.flushWriters() + + val expected = """ + serializer.serializeStruct(OBJ_DESCRIPTOR) { + input.bar?.let { field(BAR_DESCRIPTOR, it) } ?: field(BAR_DESCRIPTOR, context.idempotencyTokenProvider.generateToken()) + } + """.trimIndent() + + val actual = ctx + .manifest + .expectFileString("/src/main/kotlin/com/test/serde/GetBarUnNestedOperationSerializer.kt") + .lines(" serializer.serializeStruct(OBJ_DESCRIPTOR) {", " }") + .trimIndent() + + actual.shouldContainOnlyOnceWithDiff(expected) + } + + @Test + fun testNestedIdempotencyToken() { + val ctx = model.newTestContext("Example") + + val generator = AwsQuery() + generator.generateProtocolClient(ctx.generationCtx) + + ctx.generationCtx.delegator.finalize() + ctx.generationCtx.delegator.flushWriters() + + val expected = """ + serializer.serializeStruct(OBJ_DESCRIPTOR) { + input.baz?.let { field(BAZ_DESCRIPTOR, it) } + } + """.trimIndent() + + val actual = ctx + .manifest + .expectFileString("/src/main/kotlin/com/test/serde/NestDocumentSerializer.kt") + .lines(" serializer.serializeStruct(OBJ_DESCRIPTOR) {", " }") + .trimIndent() + + actual.shouldContainOnlyOnceWithDiff(expected) + + val unexpected = """ + serializer.serializeStruct(OBJ_DESCRIPTOR) { + input.baz?.let { field(BAZ_DESCRIPTOR, it) } ?: field(BAR_DESCRIPTOR, context.idempotencyTokenProvider.generateToken()) + } + """.trimIndent() + + actual.shouldNotContainOnlyOnceWithDiff(unexpected) + } + + private val model = """ + ${"$"}version: "2" + + namespace com.test + + use aws.protocols#awsQuery + use aws.api#service + + @awsQuery + @service(sdkId: "Example") + @xmlNamespace(uri: "http://foo.com") + service Example { + version: "1.0.0", + operations: [GetBarUnNested, GetBarNested] + } + + @http(method: "POST", uri: "/get-bar-un-nested") + operation GetBarUnNested { + input: BarUnNested + } + + structure BarUnNested { + @idempotencyToken + bar: String + } + + @http(method: "POST", uri: "/get-bar-nested") + operation GetBarNested { + input: BarNested + } + + structure BarNested { + bar: Nest + } + + structure Nest { + @idempotencyToken + baz: String + } + """.toSmithyModel() +} diff --git a/codegen/smithy-aws-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/aws/protocols/RestJson1Test.kt b/codegen/smithy-aws-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/aws/protocols/RestJson1Test.kt new file mode 100644 index 0000000000..e1a4b22c01 --- /dev/null +++ b/codegen/smithy-aws-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/aws/protocols/RestJson1Test.kt @@ -0,0 +1,104 @@ +package software.amazon.smithy.kotlin.codegen.aws.protocols + +import software.amazon.smithy.kotlin.codegen.test.* +import kotlin.test.Test + +class RestJson1Test { + @Test + fun testNonNestedIdempotencyToken() { + val ctx = model.newTestContext("Example") + + val generator = RestJson1() + generator.generateProtocolClient(ctx.generationCtx) + + ctx.generationCtx.delegator.finalize() + ctx.generationCtx.delegator.flushWriters() + + val expected = """ + serializer.serializeStruct(OBJ_DESCRIPTOR) { + input.bar?.let { field(BAR_DESCRIPTOR, it) } ?: field(BAR_DESCRIPTOR, context.idempotencyTokenProvider.generateToken()) + } + """.trimIndent() + + val actual = ctx + .manifest + .expectFileString("/src/main/kotlin/com/test/serde/GetBarUnNestedOperationSerializer.kt") + .lines(" serializer.serializeStruct(OBJ_DESCRIPTOR) {", " }") + .trimIndent() + + actual.shouldContainOnlyOnceWithDiff(expected) + } + + @Test + fun testNestedIdempotencyToken() { + val ctx = model.newTestContext("Example") + + val generator = RestJson1() + generator.generateProtocolClient(ctx.generationCtx) + + ctx.generationCtx.delegator.finalize() + ctx.generationCtx.delegator.flushWriters() + + val expected = """ + serializer.serializeStruct(OBJ_DESCRIPTOR) { + input.baz?.let { field(BAZ_DESCRIPTOR, it) } + } + """.trimIndent() + + val actual = ctx + .manifest + .expectFileString("/src/main/kotlin/com/test/serde/NestDocumentSerializer.kt") + .lines(" serializer.serializeStruct(OBJ_DESCRIPTOR) {", " }") + .trimIndent() + + actual.shouldContainOnlyOnceWithDiff(expected) + + val unexpected = """ + serializer.serializeStruct(OBJ_DESCRIPTOR) { + input.baz?.let { field(BAZ_DESCRIPTOR, it) } ?: field(BAR_DESCRIPTOR, context.idempotencyTokenProvider.generateToken()) + } + """.trimIndent() + + actual.shouldNotContainOnlyOnceWithDiff(unexpected) + } + + private val model = """ + ${"$"}version: "2" + + namespace com.test + + use aws.protocols#restJson1 + use aws.api#service + + @restJson1 + @service(sdkId: "Example") + service Example { + version: "1.0.0", + operations: [GetBarUnNested, GetBarNested] + } + + @http(method: "POST", uri: "/get-bar-un-nested") + operation GetBarUnNested { + input: BarUnNested + } + + structure BarUnNested { + @idempotencyToken + bar: String + } + + @http(method: "POST", uri: "/get-bar-nested") + operation GetBarNested { + input: BarNested + } + + structure BarNested { + bar: Nest + } + + structure Nest { + @idempotencyToken + baz: String + } + """.toSmithyModel() +} diff --git a/codegen/smithy-aws-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/aws/protocols/RestXmlTest.kt b/codegen/smithy-aws-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/aws/protocols/RestXmlTest.kt new file mode 100644 index 0000000000..4f59359bee --- /dev/null +++ b/codegen/smithy-aws-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/aws/protocols/RestXmlTest.kt @@ -0,0 +1,104 @@ +package software.amazon.smithy.kotlin.codegen.aws.protocols + +import software.amazon.smithy.kotlin.codegen.test.* +import kotlin.test.Test + +class RestXmlTest { + @Test + fun testNonNestedIdempotencyToken() { + val ctx = model.newTestContext("Example") + + val generator = RestXml() + generator.generateProtocolClient(ctx.generationCtx) + + ctx.generationCtx.delegator.finalize() + ctx.generationCtx.delegator.flushWriters() + + val expected = """ + serializer.serializeStruct(OBJ_DESCRIPTOR) { + input.bar?.let { field(BAR_DESCRIPTOR, it) } ?: field(BAR_DESCRIPTOR, context.idempotencyTokenProvider.generateToken()) + } + """.trimIndent() + + val actual = ctx + .manifest + .expectFileString("/src/main/kotlin/com/test/serde/GetBarUnNestedOperationSerializer.kt") + .lines(" serializer.serializeStruct(OBJ_DESCRIPTOR) {", " }") + .trimIndent() + + actual.shouldContainOnlyOnceWithDiff(expected) + } + + @Test + fun testNestedIdempotencyToken() { + val ctx = model.newTestContext("Example") + + val generator = RestXml() + generator.generateProtocolClient(ctx.generationCtx) + + ctx.generationCtx.delegator.finalize() + ctx.generationCtx.delegator.flushWriters() + + val expected = """ + serializer.serializeStruct(OBJ_DESCRIPTOR) { + input.baz?.let { field(BAZ_DESCRIPTOR, it) } + } + """.trimIndent() + + val actual = ctx + .manifest + .expectFileString("/src/main/kotlin/com/test/serde/NestDocumentSerializer.kt") + .lines(" serializer.serializeStruct(OBJ_DESCRIPTOR) {", " }") + .trimIndent() + + actual.shouldContainOnlyOnceWithDiff(expected) + + val unexpected = """ + serializer.serializeStruct(OBJ_DESCRIPTOR) { + input.baz?.let { field(BAZ_DESCRIPTOR, it) } ?: field(BAR_DESCRIPTOR, context.idempotencyTokenProvider.generateToken()) + } + """.trimIndent() + + actual.shouldNotContainOnlyOnceWithDiff(unexpected) + } + + private val model = """ + ${"$"}version: "2" + + namespace com.test + + use aws.protocols#restXml + use aws.api#service + + @restXml + @service(sdkId: "Example") + service Example { + version: "1.0.0", + operations: [GetBarUnNested, GetBarNested] + } + + @http(method: "POST", uri: "/get-bar-un-nested") + operation GetBarUnNested { + input: BarUnNested + } + + structure BarUnNested { + @idempotencyToken + bar: String + } + + @http(method: "POST", uri: "/get-bar-nested") + operation GetBarNested { + input: BarNested + } + + structure BarNested { + bar: Nest + } + + structure Nest { + @idempotencyToken + baz: String + } + """.toSmithyModel() +} diff --git a/codegen/smithy-aws-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/aws/protocols/RpcV2CborTest.kt b/codegen/smithy-aws-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/aws/protocols/RpcV2CborTest.kt index 0114185004..a52456d61a 100644 --- a/codegen/smithy-aws-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/aws/protocols/RpcV2CborTest.kt +++ b/codegen/smithy-aws-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/aws/protocols/RpcV2CborTest.kt @@ -21,7 +21,7 @@ class RpcV2CborTest { @service(sdkId: "CborExample") service CborExample { version: "1.0.0", - operations: [GetFoo, GetFooStreaming, PutFooStreaming] + operations: [GetFoo, GetFooStreaming, PutFooStreaming, GetBarUnNested, GetBarNested] } @http(method: "POST", uri: "/foo") @@ -74,6 +74,30 @@ class RpcV2CborTest { @error("client") @retryable(throttling: true) structure ThrottlingError {} + + @http(method: "POST", uri: "/get-bar-un-nested") + operation GetBarUnNested { + input: BarUnNested + } + + structure BarUnNested { + @idempotencyToken + bar: String + } + + @http(method: "POST", uri: "/get-bar-nested") + operation GetBarNested { + input: BarNested + } + + structure BarNested { + bar: Nest + } + + structure Nest { + @idempotencyToken + baz: String + } """.toSmithyModel() @Test @@ -162,4 +186,62 @@ class RpcV2CborTest { val serializeBody = documentSerializer.lines(" serializer.serializeStruct(OBJ_DESCRIPTOR) {", "}") serializeBody.shouldNotContain("input.messages") // `messages` is the stream member and should not be serialized in the initial request } + + @Test + fun testNonNestedIdempotencyToken() { + val ctx = model.newTestContext("CborExample") + + val generator = RpcV2Cbor() + generator.generateProtocolClient(ctx.generationCtx) + + ctx.generationCtx.delegator.finalize() + ctx.generationCtx.delegator.flushWriters() + + val expected = """ + serializer.serializeStruct(OBJ_DESCRIPTOR) { + input.bar?.let { field(BAR_DESCRIPTOR, it) } ?: field(BAR_DESCRIPTOR, context.idempotencyTokenProvider.generateToken()) + } + """.trimIndent() + + val actual = ctx + .manifest + .expectFileString("/src/main/kotlin/com/test/serde/GetBarUnNestedOperationSerializer.kt") + .lines(" serializer.serializeStruct(OBJ_DESCRIPTOR) {", " }") + .trimIndent() + + actual.shouldContainOnlyOnceWithDiff(expected) + } + + @Test + fun testNestedIdempotencyToken() { + val ctx = model.newTestContext("CborExample") + + val generator = RpcV2Cbor() + generator.generateProtocolClient(ctx.generationCtx) + + ctx.generationCtx.delegator.finalize() + ctx.generationCtx.delegator.flushWriters() + + val expected = """ + serializer.serializeStruct(OBJ_DESCRIPTOR) { + input.baz?.let { field(BAZ_DESCRIPTOR, it) } + } + """.trimIndent() + + val actual = ctx + .manifest + .expectFileString("/src/main/kotlin/com/test/serde/NestDocumentSerializer.kt") + .lines(" serializer.serializeStruct(OBJ_DESCRIPTOR) {", " }") + .trimIndent() + + actual.shouldContainOnlyOnceWithDiff(expected) + + val unexpected = """ + serializer.serializeStruct(OBJ_DESCRIPTOR) { + input.baz?.let { field(BAZ_DESCRIPTOR, it) } ?: field(BAR_DESCRIPTOR, context.idempotencyTokenProvider.generateToken()) + } + """.trimIndent() + + actual.shouldNotContainOnlyOnceWithDiff(unexpected) + } } diff --git a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/KotlinSymbolProvider.kt b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/KotlinSymbolProvider.kt index f35570dfeb..cca76f0f42 100644 --- a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/KotlinSymbolProvider.kt +++ b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/KotlinSymbolProvider.kt @@ -154,7 +154,7 @@ class KotlinSymbolProvider(private val model: Model, private val settings: Kotli val fullyQualifiedValueType = "${reference.fullName}$valueSuffix" return createSymbolBuilder(shape, "List<$valueType>") .addReferences(reference) - .putProperty(SymbolProperty.FULLY_QUALIFIED_NAME_HINT, "List<$fullyQualifiedValueType>") + .putProperty(SymbolProperty.FULLY_QUALIFIED_NAME_HINT, "kotlin.collections.List<$fullyQualifiedValueType>") .putProperty(SymbolProperty.MUTABLE_COLLECTION_FUNCTION, "mutableListOf<$valueType>") .putProperty(SymbolProperty.IMMUTABLE_COLLECTION_FUNCTION, "listOf<$valueType>") .build() @@ -173,7 +173,7 @@ class KotlinSymbolProvider(private val model: Model, private val settings: Kotli return createSymbolBuilder(shape, "Map<$keyType, $valueType>") .addReferences(keyReference) .addReferences(valueReference) - .putProperty(SymbolProperty.FULLY_QUALIFIED_NAME_HINT, "Map<$fullyQualifiedKeyType, $fullyQualifiedValueType>") + .putProperty(SymbolProperty.FULLY_QUALIFIED_NAME_HINT, "kotlin.collections.Map<$fullyQualifiedKeyType, $fullyQualifiedValueType>") .putProperty(SymbolProperty.MUTABLE_COLLECTION_FUNCTION, "mutableMapOf<$keyType, $valueType>") .putProperty(SymbolProperty.IMMUTABLE_COLLECTION_FUNCTION, "mapOf<$keyType, $valueType>") .putProperty(SymbolProperty.ENTRY_EXPRESSION, "Map.Entry<$keyType, $valueType>") diff --git a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/RuntimeTypes.kt b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/RuntimeTypes.kt index d5913ac797..c247b97b88 100644 --- a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/RuntimeTypes.kt +++ b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/RuntimeTypes.kt @@ -115,6 +115,7 @@ object RuntimeTypes { } object SmokeTests : RuntimeTypePackage(KotlinDependency.CORE, "smoketests") { + val DefaultPrinter = symbol("DefaultPrinter") val exitProcess = symbol("exitProcess") val printExceptionStackTrace = symbol("printExceptionStackTrace") val SmokeTestsException = symbol("SmokeTestsException") diff --git a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/lang/KotlinTypes.kt b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/lang/KotlinTypes.kt index 242ba4d0da..6d2384584e 100644 --- a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/lang/KotlinTypes.kt +++ b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/lang/KotlinTypes.kt @@ -109,6 +109,7 @@ object KotlinTypes { } object Text : RuntimeTypePackage(KotlinDependency.KOTLIN_STDLIB, "text") { + val Appendable = stdlibSymbol("Appendable") val encodeToByteArray = stdlibSymbol("encodeToByteArray") } diff --git a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/model/knowledge/TopLevelIndex.kt b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/model/knowledge/TopLevelIndex.kt new file mode 100644 index 0000000000..df89e2d782 --- /dev/null +++ b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/model/knowledge/TopLevelIndex.kt @@ -0,0 +1,20 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.kotlin.codegen.model.knowledge + +import software.amazon.smithy.kotlin.codegen.utils.getOrNull +import software.amazon.smithy.model.Model +import software.amazon.smithy.model.knowledge.TopDownIndex +import software.amazon.smithy.model.shapes.MemberShape +import software.amazon.smithy.model.shapes.ServiceShape + +class TopLevelIndex(model: Model, service: ServiceShape) { + private val operations = TopDownIndex(model).getContainedOperations(service) + private val inputStructs = operations.mapNotNull { it.input.getOrNull() }.map { model.expectShape(it) } + private val inputMembers = inputStructs.flatMap { it.members() }.toSet() + + fun isTopLevelInputMember(member: MemberShape): Boolean = member in inputMembers +} diff --git a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/PaginatorGenerator.kt b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/PaginatorGenerator.kt index 7ac76fee7d..1a829dbb5a 100644 --- a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/PaginatorGenerator.kt +++ b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/PaginatorGenerator.kt @@ -8,12 +8,7 @@ import software.amazon.smithy.codegen.core.CodegenException import software.amazon.smithy.codegen.core.Symbol import software.amazon.smithy.codegen.core.SymbolReference import software.amazon.smithy.kotlin.codegen.KotlinSettings -import software.amazon.smithy.kotlin.codegen.core.CodegenContext -import software.amazon.smithy.kotlin.codegen.core.ExternalTypes -import software.amazon.smithy.kotlin.codegen.core.KotlinDelegator -import software.amazon.smithy.kotlin.codegen.core.KotlinWriter -import software.amazon.smithy.kotlin.codegen.core.defaultName -import software.amazon.smithy.kotlin.codegen.core.withBlock +import software.amazon.smithy.kotlin.codegen.core.* import software.amazon.smithy.kotlin.codegen.integration.KotlinIntegration import software.amazon.smithy.kotlin.codegen.lang.KotlinTypes import software.amazon.smithy.kotlin.codegen.model.* @@ -54,7 +49,7 @@ class PaginatorGenerator : KotlinIntegration { paginatedOperations.forEach { paginatedOperation -> val paginationInfo = paginatedIndex.getPaginationInfo(service, paginatedOperation).getOrNull() ?: throw CodegenException("Unexpectedly unable to get PaginationInfo from $service $paginatedOperation") - val paginationItemInfo = getItemDescriptorOrNull(paginationInfo, ctx) + val paginationItemInfo = getItemDescriptorOrNull(paginationInfo, ctx, writer) renderPaginatorForOperation(ctx, writer, paginatedOperation, paginationInfo, paginationItemInfo) } @@ -264,7 +259,11 @@ private data class ItemDescriptor( /** * Return an [ItemDescriptor] if model supplies, otherwise null */ -private fun getItemDescriptorOrNull(paginationInfo: PaginationInfo, ctx: CodegenContext): ItemDescriptor? { +private fun getItemDescriptorOrNull( + paginationInfo: PaginationInfo, + ctx: CodegenContext, + writer: KotlinWriter, +): ItemDescriptor? { val itemMemberId = paginationInfo.itemsMemberPath?.lastOrNull()?.target ?: return null val itemLiteral = paginationInfo.itemsMemberPath!!.last()!!.defaultName() @@ -273,15 +272,18 @@ private fun getItemDescriptorOrNull(paginationInfo: PaginationInfo, ctx: Codegen val isSparse = itemMember.isSparse val (collectionLiteral, targetMember) = when (itemMember) { is MapShape -> { - val symbol = ctx.symbolProvider.toSymbol(itemMember) - val entryExpression = symbol.expectProperty(SymbolProperty.ENTRY_EXPRESSION) as String - entryExpression to itemMember + val keySymbol = ctx.symbolProvider.toSymbol(itemMember.key) + val valueSymbol = ctx.symbolProvider.toSymbol(itemMember.value) + val valueSuffix = if (isSparse || valueSymbol.isNullable) "?" else "" + val elementExpression = writer.format("Map.Entry<#T, #T#L>", keySymbol, valueSymbol, valueSuffix) + elementExpression to itemMember } is CollectionShape -> { val target = ctx.model.expectShape(itemMember.member.target) val symbol = ctx.symbolProvider.toSymbol(target) - val literal = symbol.name + if (symbol.isNullable || isSparse) "?" else "" - literal to target + val suffix = if (isSparse || symbol.isNullable) "?" else "" + val elementExpression = writer.format("#T#L", symbol, suffix) + elementExpression to target } else -> error("Unexpected shape type ${itemMember.type}") } diff --git a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/serde/SerializeStructGenerator.kt b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/serde/SerializeStructGenerator.kt index d8810044a5..3cfbc0dcfe 100644 --- a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/serde/SerializeStructGenerator.kt +++ b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/serde/SerializeStructGenerator.kt @@ -8,10 +8,12 @@ import software.amazon.smithy.codegen.core.CodegenException import software.amazon.smithy.kotlin.codegen.DefaultValueSerializationMode import software.amazon.smithy.kotlin.codegen.core.* import software.amazon.smithy.kotlin.codegen.model.* +import software.amazon.smithy.kotlin.codegen.model.knowledge.TopLevelIndex import software.amazon.smithy.kotlin.codegen.model.targetOrSelf import software.amazon.smithy.kotlin.codegen.rendering.protocol.ProtocolGenerator import software.amazon.smithy.model.shapes.* import software.amazon.smithy.model.traits.* +import java.util.logging.Logger /** * Generate serialization for members bound to the payload. @@ -43,6 +45,9 @@ open class SerializeStructGenerator( protected val writer: KotlinWriter, protected val defaultTimestampFormat: TimestampFormatTrait.Format, ) { + private val logger = Logger.getLogger(javaClass.name) + private val topLevelIndex = TopLevelIndex(ctx.model, ctx.service) + /** * Container for serialization information for a particular shape being serialized to */ @@ -587,7 +592,7 @@ open class SerializeStructGenerator( * @param serializerFn [SerializeFunction] the serializer responsible for returning the function to invoke */ open fun renderShapeSerializer(memberShape: MemberShape, serializerFn: SerializeFunction) { - val postfix = idempotencyTokenPostfix(memberShape) + val postfix = if (memberShape.hasTrait()) idempotencyTokenPostfix(memberShape) else "" val memberSymbol = ctx.symbolProvider.toSymbol(memberShape) val memberName = ctx.symbolProvider.toMemberName(memberShape) if (memberSymbol.isNullable) { @@ -614,10 +619,15 @@ open class SerializeStructGenerator( * @return string intended for codegen output */ private fun idempotencyTokenPostfix(memberShape: MemberShape): String = - if (memberShape.hasTrait()) { + // https://github.com/smithy-lang/smithy-kotlin/issues/1128 + if (topLevelIndex.isTopLevelInputMember(memberShape)) { writer.addImport(RuntimeTypes.SmithyClient.IdempotencyTokenProviderExt) " ?: field(${memberShape.descriptorName()}, context.idempotencyTokenProvider.generateToken())" } else { + logger.warning( + "$memberShape has the idempotency token trait but is not eligible to be used as an idempotency token. " + + "It must be a top-level structure member within the input of an operation. ", + ) "" } diff --git a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/smoketests/SmokeTestsRunnerGenerator.kt b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/smoketests/SmokeTestsRunnerGenerator.kt index df8eeda53e..a3124d7bff 100644 --- a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/smoketests/SmokeTestsRunnerGenerator.kt +++ b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/smoketests/SmokeTestsRunnerGenerator.kt @@ -4,6 +4,7 @@ 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.lang.KotlinTypes import software.amazon.smithy.kotlin.codegen.model.getTrait import software.amazon.smithy.kotlin.codegen.model.hasTrait import software.amazon.smithy.kotlin.codegen.rendering.ShapeValueGenerator @@ -17,8 +18,8 @@ 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.topDownOperations -import software.amazon.smithy.model.node.* -import software.amazon.smithy.model.shapes.* +import software.amazon.smithy.model.node.Node +import software.amazon.smithy.model.shapes.OperationShape import software.amazon.smithy.smoketests.traits.SmokeTestCase import software.amazon.smithy.smoketests.traits.SmokeTestsTrait import kotlin.jvm.optionals.getOrNull @@ -61,25 +62,47 @@ class SmokeTestsRunnerGenerator( ) { internal fun render() { writer.declareSection(SmokeTestSectionIds.SmokeTestsFile) { - writer.write("private var exitCode = 0") + write("") + + withBlock("public suspend fun main() {", "}") { + write("val success = SmokeTestRunner().runAllTests()") + withBlock("if (!success) {", "}") { + write("#T(1)", RuntimeTypes.Core.SmokeTests.exitProcess) + } + } + write("") + renderRunnerClass() + } + } + + private fun renderRunnerClass() { + writer.withBlock( + "public class SmokeTestRunner(private val platform: #1T = #1T.System, private val printer: #2T = #3T) {", + "}", + RuntimeTypes.Core.Utils.PlatformProvider, + KotlinTypes.Text.Appendable, + RuntimeTypes.Core.SmokeTests.DefaultPrinter, + ) { renderEnvironmentVariables() - writer.declareSection(SmokeTestSectionIds.AdditionalEnvironmentVariables) - writer.write("") - writer.withBlock("public suspend fun main() {", "}") { - renderFunctionCalls() - write("#T(exitCode)", RuntimeTypes.Core.SmokeTests.exitProcess) + declareSection(SmokeTestSectionIds.AdditionalEnvironmentVariables) + write("") + + withBlock("public suspend fun runAllTests(): Boolean =", "") { + withBlock("listOf Boolean>(", ")") { + renderFunctionReferences() + } + indent() + write(".map { it() }") + write(".all { it }") + dedent() } - writer.write("") renderFunctions() } } private fun renderEnvironmentVariables() { // Skip tags - writer.writeInline( - "private val skipTags = #T.System.getenv(", - RuntimeTypes.Core.Utils.PlatformProvider, - ) + writer.writeInline("private val skipTags = platform.getenv(") writer.declareSection(SmokeTestSectionIds.SkipTags) { writer.writeInline("#S", SKIP_TAGS) } @@ -89,10 +112,7 @@ class SmokeTestsRunnerGenerator( ) // Service filter - writer.writeInline( - "private val serviceFilter = #T.System.getenv(", - RuntimeTypes.Core.Utils.PlatformProvider, - ) + writer.writeInline("private val serviceFilter = platform.getenv(") writer.declareSection(SmokeTestSectionIds.ServiceFilter) { writer.writeInline("#S", SERVICE_FILTER) } @@ -102,10 +122,10 @@ class SmokeTestsRunnerGenerator( ) } - private fun renderFunctionCalls() { + private fun renderFunctionReferences() { operations.forEach { operation -> operation.getTrait()?.testCases?.forEach { testCase -> - writer.write("${testCase.functionName}()") + writer.write("::${testCase.functionName},") } } } @@ -120,7 +140,7 @@ class SmokeTestsRunnerGenerator( } private fun renderFunction(operation: OperationShape, testCase: SmokeTestCase) { - writer.withBlock("private suspend fun ${testCase.functionName}() {", "}") { + writer.withBlock("private suspend fun ${testCase.functionName}(): Boolean {", "}") { write("val tags = setOf(${testCase.tags.joinToString(",") { it.dq()} })") writer.withBlock("if ((serviceFilter.isNotEmpty() && #S !in serviceFilter) || tags.any { it in skipTags }) {", "}", sdkId) { printTestResult( @@ -131,10 +151,10 @@ class SmokeTestsRunnerGenerator( "ok", "# skip", ) - writer.write("return") + writer.write("return true") } write("") - withInlineBlock("try {", "} ") { + withInlineBlock("return try {", "} ") { renderTestCase(operation, testCase) } withBlock("catch (exception: Exception) {", "}") { @@ -149,6 +169,8 @@ class SmokeTestsRunnerGenerator( closeAndOpenBlock("}.#T { client ->", RuntimeTypes.Core.IO.use) renderOperation(operation, testCase) } + writer.write("") + writer.write("error(#S)", "Unexpectedly completed smoke test operation without throwing exception") } private fun renderClientConfig(testCase: SmokeTestCase) { @@ -212,9 +234,11 @@ class SmokeTestsRunnerGenerator( ) writer.withBlock("if (!success) {", "}") { - write("#T(exception)", RuntimeTypes.Core.SmokeTests.printExceptionStackTrace) - write("exitCode = 1") + write("printer.appendLine(exception.stackTraceToString().prependIndent(#S))", "# ") } + + writer.write("") + writer.write("success") } // Helpers @@ -241,7 +265,7 @@ class SmokeTestsRunnerGenerator( val expectation = if (errorExpected) "error expected from service" else "no error expected from service" val status = statusOverride ?: "\$status" val testResult = "$status $service $testCase - $expectation $directive" - writer.write("println(#S)", testResult) + writer.write("printer.appendLine(#S)", testResult) } /** @@ -250,18 +274,6 @@ class SmokeTestsRunnerGenerator( private val SmokeTestCase.functionName: String get() = this.id.toCamelCase() - /** - * Get the operation parameters for a [SmokeTestCase] - */ - private val SmokeTestCase.operationParameters: Map - 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 */ diff --git a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/waiters/KotlinJmespathExpressionVisitor.kt b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/waiters/KotlinJmespathExpressionVisitor.kt index 20b07454e4..a7b28180f2 100644 --- a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/waiters/KotlinJmespathExpressionVisitor.kt +++ b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/waiters/KotlinJmespathExpressionVisitor.kt @@ -19,16 +19,26 @@ import software.amazon.smithy.kotlin.codegen.model.isEnum import software.amazon.smithy.kotlin.codegen.model.targetOrSelf import software.amazon.smithy.kotlin.codegen.model.traits.OperationInput import software.amazon.smithy.kotlin.codegen.model.traits.OperationOutput +import software.amazon.smithy.kotlin.codegen.utils.doubleQuote import software.amazon.smithy.kotlin.codegen.utils.dq import software.amazon.smithy.kotlin.codegen.utils.getOrNull import software.amazon.smithy.kotlin.codegen.utils.toCamelCase -import software.amazon.smithy.model.shapes.ListShape -import software.amazon.smithy.model.shapes.MapShape -import software.amazon.smithy.model.shapes.MemberShape -import software.amazon.smithy.model.shapes.Shape +import software.amazon.smithy.model.shapes.* private val suffixSequence = sequenceOf("") + generateSequence(2) { it + 1 }.map(Int::toString) // "", "2", "3", etc. +/* +JMESPath has the concept of objects. +This visitor assumes JMESPath objects will be Kotlin objects/classes. +The smithy spec contains an instance where it's assumed a JMESPath object will be a Kotlin map. + +Specifically it's the keys function +Smithy spec: https://smithy.io/2.0/additional-specs/rules-engine/parameters.html#smithy-rules-operationcontextparams-trait +JMESPath spec: https://jmespath.org/specification.html#keys + +TODO: Test relevant uses of JMESPath to determine if we should support JMESPath objects as Kotlin maps throughout the entire visitor + */ + /** * An [ExpressionVisitor] used for traversing a JMESPath expression to generate code for traversing an equivalent * modeled object. This visitor is passed to [JmespathExpression.accept], at which point specific expression methods @@ -526,11 +536,22 @@ class KotlinJmespathExpressionVisitor( ".$expr" } - private fun VisitedExpression.getKeys(): String { - val keys = this.shape?.targetOrSelf(ctx.model)?.allMembers - ?.keys?.joinToString(", ", "listOf(", ")") { "\"$it\"" } - return keys ?: "listOf()" - } + /* + Smithy spec expects a map, JMESPath spec expects an object + Smithy spec: https://smithy.io/2.0/additional-specs/rules-engine/parameters.html#smithy-rules-operationcontextparams-trait + JMESPath spec: https://jmespath.org/specification.html#keys + */ + private fun VisitedExpression.getKeys(): String = + if (shape?.targetOrSelf(ctx.model)?.type == ShapeType.MAP) { + "$identifier?.keys?.map { it.toString() }?.toList()" + } else { + shape + ?.targetOrSelf(ctx.model) + ?.allMembers + ?.keys + ?.joinToString(", ", "listOf(", ")") { it.doubleQuote() } + ?: "listOf()" + } private fun VisitedExpression.getValues(): String { val values = this.shape?.targetOrSelf(ctx.model)?.allMembers?.keys diff --git a/codegen/smithy-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/core/SymbolProviderTest.kt b/codegen/smithy-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/core/SymbolProviderTest.kt index bb90fbc0fb..df803ef25e 100644 --- a/codegen/smithy-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/core/SymbolProviderTest.kt +++ b/codegen/smithy-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/core/SymbolProviderTest.kt @@ -481,8 +481,8 @@ class SymbolProviderTest { assertEquals("Record", sparseListSymbol.references[0].symbol.name) // check the fully qualified name hint is set - assertEquals("List", listSymbol.fullNameHint) - assertEquals("List", sparseListSymbol.fullNameHint) + assertEquals("kotlin.collections.List", listSymbol.fullNameHint) + assertEquals("kotlin.collections.List", sparseListSymbol.fullNameHint) } @Test @@ -544,8 +544,8 @@ class SymbolProviderTest { assertTrue("com.test.model.Record" in sparseRefNames) // check the fully qualified name hint is set - assertEquals("Map", mapSymbol.fullNameHint) - assertEquals("Map", sparseMapSymbol.fullNameHint) + assertEquals("kotlin.collections.Map", mapSymbol.fullNameHint) + assertEquals("kotlin.collections.Map", sparseMapSymbol.fullNameHint) } @Test diff --git a/codegen/smithy-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/rendering/PaginatorGeneratorTest.kt b/codegen/smithy-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/rendering/PaginatorGeneratorTest.kt index 7b08a6d869..a3c33532d5 100644 --- a/codegen/smithy-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/rendering/PaginatorGeneratorTest.kt +++ b/codegen/smithy-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/rendering/PaginatorGeneratorTest.kt @@ -263,6 +263,130 @@ class PaginatorGeneratorTest { actual.shouldContainOnlyOnceWithDiff(expectedImports) } + @Test + fun testRenderPaginatorWithItemRequiringFullName() { + val testModelWithItems = """ + namespace com.test + + use aws.protocols#restJson1 + + service FlowService { + operations: [ListFlows] + } + + @paginated( + inputToken: "Marker", + outputToken: "NextMarker", + pageSize: "MaxItems", + items: "Flows" + ) + @readonly + @http(method: "GET", uri: "/flows", code: 200) + operation ListFlows { + input: ListFlowsRequest, + output: ListFlowsResponse + } + + structure ListFlowsRequest { + @httpQuery("FlowVersion") + FlowVersion: String, + @httpQuery("Marker") + Marker: String, + @httpQuery("MasterRegion") + MasterRegion: String, + @httpQuery("MaxItems") + MaxItems: Integer + } + + structure ListFlowsResponse { + Flows: FlowList, + NextMarker: String + } + + list FlowList { + member: Flow + } + + structure Flow { + Name: String + } + """.toSmithyModel() + val testContextWithItems = testModelWithItems.newTestContext("FlowService", "com.test") + + val codegenContextWithItems = object : CodegenContext { + override val model: Model = testContextWithItems.generationCtx.model + override val symbolProvider: SymbolProvider = testContextWithItems.generationCtx.symbolProvider + override val settings: KotlinSettings = testContextWithItems.generationCtx.settings + override val protocolGenerator: ProtocolGenerator = testContextWithItems.generator + override val integrations: List = testContextWithItems.generationCtx.integrations + } + + val unit = PaginatorGenerator() + unit.writeAdditionalFiles(codegenContextWithItems, testContextWithItems.generationCtx.delegator) + + testContextWithItems.generationCtx.delegator.flushWriters() + val testManifest = testContextWithItems.generationCtx.delegator.fileManifest as MockManifest + val actual = testManifest.expectFileString("src/main/kotlin/com/test/paginators/Paginators.kt") + + val expectedCode = """ + /** + * Paginate over [ListFlowsResponse] 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. + * @param initialRequest A [ListFlowsRequest] to start pagination + * @return A [kotlinx.coroutines.flow.Flow] that can collect [ListFlowsResponse] + */ + public fun TestClient.listFlowsPaginated(initialRequest: ListFlowsRequest = ListFlowsRequest { }): kotlinx.coroutines.flow.Flow = + flow { + var cursor: kotlin.String? = initialRequest.marker + var hasNextPage: Boolean = true + + while (hasNextPage) { + val req = initialRequest.copy { + this.marker = cursor + } + val result = this@listFlowsPaginated.listFlows(req) + cursor = result.nextMarker + hasNextPage = cursor?.isNotEmpty() == true + emit(result) + } + } + + /** + * Paginate over [ListFlowsResponse] 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. + * @param block A builder block used for DSL-style invocation of the operation + * @return A [kotlinx.coroutines.flow.Flow] that can collect [ListFlowsResponse] + */ + public fun TestClient.listFlowsPaginated(block: ListFlowsRequest.Builder.() -> Unit): kotlinx.coroutines.flow.Flow = + listFlowsPaginated(ListFlowsRequest.Builder().apply(block).build()) + + /** + * This paginator transforms the flow returned by [listFlowsPaginated] + * to access the nested member [Flow] + * @return A [kotlinx.coroutines.flow.Flow] that can collect [Flow] + */ + @JvmName("listFlowsResponseFlow") + public fun kotlinx.coroutines.flow.Flow.flows(): kotlinx.coroutines.flow.Flow = + transform() { response -> + response.flows?.forEach { + emit(it) + } + } + """.trimIndent() + + actual.shouldContainOnlyOnceWithDiff(expectedCode) + } + @Test fun testRenderPaginatorWithSparseItem() { val testModelWithItems = """ diff --git a/codegen/smithy-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/rendering/endpoints/OperationContextParamsTest.kt b/codegen/smithy-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/rendering/endpoints/OperationContextParamsTest.kt index 1e7feeb172..6d5d713a49 100644 --- a/codegen/smithy-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/rendering/endpoints/OperationContextParamsTest.kt +++ b/codegen/smithy-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/rendering/endpoints/OperationContextParamsTest.kt @@ -88,7 +88,7 @@ class OperationContextParamsTest { } @Test - fun testKeysFunctionPath() { + fun testKeysFunctionPathWithStructure() { val input = """ structure TestOperationRequest { Object: Object @@ -114,4 +114,31 @@ class OperationContextParamsTest { codegen(pathResultType, path, input).shouldContainOnlyOnceWithDiff(expected) } + + @Test + fun testKeysFunctionPathWithMap() { + val input = """ + structure TestOperationRequest { + Object: StringMap + } + + map StringMap { + key: String + value: String + } + """.trimIndent() + + val path = "keys(Object)" + val pathResultType = "stringArray" + + val expected = """ + @Suppress("UNCHECKED_CAST") + val input = request.context[HttpOperationContext.OperationInput] as TestOperationRequest + val object = input.object + val keys = object?.keys?.map { it.toString() }?.toList() + builder.foo = keys + """.formatForTest(" ") + + codegen(pathResultType, path, input).shouldContainOnlyOnceWithDiff(expected) + } } diff --git a/codegen/smithy-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/rendering/smoketests/SmokeTestsRunnerGeneratorTest.kt b/codegen/smithy-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/rendering/smoketests/SmokeTestsRunnerGeneratorTest.kt index 50291a482f..c9d4b535c1 100644 --- a/codegen/smithy-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/rendering/smoketests/SmokeTestsRunnerGeneratorTest.kt +++ b/codegen/smithy-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/rendering/smoketests/SmokeTestsRunnerGeneratorTest.kt @@ -74,10 +74,9 @@ class SmokeTestsRunnerGeneratorTest { fun variablesTest() { generatedCode.shouldContainOnlyOnceWithDiff( """ - private var exitCode = 0 - private val skipTags = PlatformProvider.System.getenv("SMOKE_TEST_SKIP_TAGS")?.let { it.split(",").map { it.trim() }.toSet() } ?: emptySet() - private val serviceFilter = PlatformProvider.System.getenv("SMOKE_TEST_SERVICE_IDS")?.let { it.split(",").map { it.trim() }.toSet() } - """.trimIndent(), + private val skipTags = platform.getenv("SMOKE_TEST_SKIP_TAGS")?.let { it.split(",").map { it.trim() }.toSet() } ?: emptySet() + private val serviceFilter = platform.getenv("SMOKE_TEST_SERVICE_IDS")?.let { it.split(",").map { it.trim() }.toSet() } + """.formatForTest(), ) } @@ -86,27 +85,50 @@ class SmokeTestsRunnerGeneratorTest { generatedCode.shouldContainOnlyOnceWithDiff( """ public suspend fun main() { - successTest() - invalidMessageErrorTest() - failureTest() - exitProcess(exitCode) + val success = SmokeTestRunner().runAllTests() + if (!success) { + exitProcess(1) + } } """.trimIndent(), ) } + @Test + fun runnerClassTest() { + generatedCode.shouldContainOnlyOnceWithDiff( + "public class SmokeTestRunner(private val platform: PlatformProvider = PlatformProvider.System, private val printer: Appendable = DefaultPrinter) {", + ) + } + + @Test + fun runAllTestsTest() { + generatedCode.shouldContainOnlyOnceWithDiff( + """ + public suspend fun runAllTests(): Boolean = + listOf Boolean>( + ::successTest, + ::invalidMessageErrorTest, + ::failureTest, + ) + .map { it() } + .all { it } + """.formatForTest(), + ) + } + @Test fun successTest() { generatedCode.shouldContainOnlyOnceWithDiff( """ - private suspend fun successTest() { + private suspend fun successTest(): Boolean { val tags = setOf("success") if ((serviceFilter.isNotEmpty() && "Test" !in serviceFilter) || tags.any { it in skipTags }) { - println("ok Test SuccessTest - no error expected from service # skip") - return + printer.appendLine("ok Test SuccessTest - no error expected from service # skip") + return true } - - try { + + return try { TestClient { interceptors.add(SmokeTestsInterceptor()) region = "eu-central-1" @@ -118,18 +140,21 @@ class SmokeTestsRunnerGeneratorTest { } ) } - + + error("Unexpectedly completed smoke test operation without throwing exception") + } catch (exception: Exception) { val success: Boolean = exception is SmokeTestsSuccessException val status: String = if (success) "ok" else "not ok" - println("${'$'}status Test SuccessTest - no error expected from service ") + printer.appendLine("${'$'}status Test SuccessTest - no error expected from service ") if (!success) { - printExceptionStackTrace(exception) - exitCode = 1 + printer.appendLine(exception.stackTraceToString().prependIndent("# ")) } + + success } } - """.trimIndent(), + """.formatForTest(), ) } @@ -137,14 +162,14 @@ class SmokeTestsRunnerGeneratorTest { fun invalidMessageErrorTest() { generatedCode.shouldContainOnlyOnceWithDiff( """ - private suspend fun invalidMessageErrorTest() { + private suspend fun invalidMessageErrorTest(): Boolean { val tags = setOf() if ((serviceFilter.isNotEmpty() && "Test" !in serviceFilter) || tags.any { it in skipTags }) { - println("ok Test InvalidMessageErrorTest - error expected from service # skip") - return + printer.appendLine("ok Test InvalidMessageErrorTest - error expected from service # skip") + return true } - try { + return try { TestClient { }.use { client -> client.testOperation( @@ -154,17 +179,20 @@ class SmokeTestsRunnerGeneratorTest { ) } + error("Unexpectedly completed smoke test operation without throwing exception") + } catch (exception: Exception) { val success: Boolean = exception is InvalidMessageError val status: String = if (success) "ok" else "not ok" - println("${'$'}status Test InvalidMessageErrorTest - error expected from service ") + printer.appendLine("${'$'}status Test InvalidMessageErrorTest - error expected from service ") if (!success) { - printExceptionStackTrace(exception) - exitCode = 1 + printer.appendLine(exception.stackTraceToString().prependIndent("# ")) } + + success } } - """.trimIndent(), + """.formatForTest(), ) } @@ -172,14 +200,14 @@ class SmokeTestsRunnerGeneratorTest { fun failureTest() { generatedCode.shouldContainOnlyOnceWithDiff( """ - private suspend fun failureTest() { + private suspend fun failureTest(): Boolean { val tags = setOf() if ((serviceFilter.isNotEmpty() && "Test" !in serviceFilter) || tags.any { it in skipTags }) { - println("ok Test FailureTest - error expected from service # skip") - return + printer.appendLine("ok Test FailureTest - error expected from service # skip") + return true } - try { + return try { TestClient { interceptors.add(SmokeTestsInterceptor()) }.use { client -> @@ -190,17 +218,20 @@ class SmokeTestsRunnerGeneratorTest { ) } + error("Unexpectedly completed smoke test operation without throwing exception") + } catch (exception: Exception) { val success: Boolean = exception is SmokeTestsFailureException val status: String = if (success) "ok" else "not ok" - println("${'$'}status Test FailureTest - error expected from service ") + printer.appendLine("${'$'}status Test FailureTest - error expected from service ") if (!success) { - printExceptionStackTrace(exception) - exitCode = 1 + printer.appendLine(exception.stackTraceToString().prependIndent("# ")) } + + success } } - """.trimIndent(), + """.formatForTest(), ) } diff --git a/codegen/smithy-kotlin-codegen/src/test/resources/sdk-ids-test-output.csv b/codegen/smithy-kotlin-codegen/src/test/resources/sdk-ids-test-output.csv index 2482d0640e..9c16785c67 100644 --- a/codegen/smithy-kotlin-codegen/src/test/resources/sdk-ids-test-output.csv +++ b/codegen/smithy-kotlin-codegen/src/test/resources/sdk-ids-test-output.csv @@ -113,7 +113,6 @@ ECS,Ecs EFS,Efs EKS,Eks Elastic Beanstalk,ElasticBeanstalk -Elastic Inference,ElasticInference Elastic Load Balancing v2,ElasticLoadBalancingV2 Elastic Load Balancing,ElasticLoadBalancing Elastic Transcoder,ElasticTranscoder diff --git a/codegen/smithy-kotlin-codegen/src/test/resources/sdk-ids.csv b/codegen/smithy-kotlin-codegen/src/test/resources/sdk-ids.csv index 52a5c68450..369edd3e01 100644 --- a/codegen/smithy-kotlin-codegen/src/test/resources/sdk-ids.csv +++ b/codegen/smithy-kotlin-codegen/src/test/resources/sdk-ids.csv @@ -113,7 +113,6 @@ ECS EFS EKS Elastic Beanstalk -Elastic Inference Elastic Load Balancing v2 Elastic Load Balancing Elastic Transcoder diff --git a/gradle.properties b/gradle.properties index 9e53dbcaf0..c64188b182 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ kotlinx.atomicfu.enableNativeIrTransformation=false org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=1G # SDK -sdkVersion=1.4.3-SNAPSHOT +sdkVersion=1.4.14-SNAPSHOT # codegen -codegenVersion=0.34.3-SNAPSHOT \ No newline at end of file +codegenVersion=0.34.14-SNAPSHOT diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bdc0c8e98b..f8871199a4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ kotlin-version = "2.1.0" dokka-version = "1.9.10" -aws-kotlin-repo-tools-version = "0.4.18" +aws-kotlin-repo-tools-version = "0.4.20" # libs coroutines-version = "1.9.0" @@ -28,9 +28,9 @@ kotlin-compile-testing-version = "0.7.0" kotlinx-benchmark-version = "0.4.12" kotlinx-serialization-version = "1.7.3" docker-java-version = "3.4.0" -ktor-version = "3.0.0" +ktor-version = "3.1.1" kaml-version = "0.55.0" -jsoup-version = "1.18.1" +jsoup-version = "1.19.1" [libraries] aws-kotlin-repo-tools-build-support = { module="aws.sdk.kotlin.gradle:build-support", version.ref = "aws-kotlin-repo-tools-version" } @@ -57,6 +57,7 @@ okhttp4 = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp4-versi okhttp-coroutines = { module = "com.squareup.okhttp3:okhttp-coroutines", version.ref = "okhttp-version" } opentelemetry-api = { module = "io.opentelemetry:opentelemetry-api", version.ref = "otel-version" } opentelemetry-sdk-testing = {module = "io.opentelemetry:opentelemetry-sdk-testing", version.ref = "otel-version" } +opentelemetry-kotlin-extension = { module = "io.opentelemetry:opentelemetry-extension-kotlin", version.ref = "otel-version" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j-version" } slf4j-api-v1x = { module = "org.slf4j:slf4j-api", version.ref = "slf4j-v1x-version" } slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j-version" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 3499ded5c1..b136486be8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https://services.gradle.org/distributions/gradle-8.12.1-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/runtime/auth/aws-signing-common/api/aws-signing-common.api b/runtime/auth/aws-signing-common/api/aws-signing-common.api index d128b0bd6f..c9353509be 100644 --- a/runtime/auth/aws-signing-common/api/aws-signing-common.api +++ b/runtime/auth/aws-signing-common/api/aws-signing-common.api @@ -55,6 +55,7 @@ public final class aws/smithy/kotlin/runtime/auth/awssigning/AwsSigningAlgorithm public static final field SIGV4 Laws/smithy/kotlin/runtime/auth/awssigning/AwsSigningAlgorithm; public static final field SIGV4_ASYMMETRIC Laws/smithy/kotlin/runtime/auth/awssigning/AwsSigningAlgorithm; public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getSigningName ()Ljava/lang/String; public static fun valueOf (Ljava/lang/String;)Laws/smithy/kotlin/runtime/auth/awssigning/AwsSigningAlgorithm; public static fun values ()[Laws/smithy/kotlin/runtime/auth/awssigning/AwsSigningAlgorithm; } diff --git a/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/AwsSigningConfig.kt b/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/AwsSigningConfig.kt index 0728e553e8..c26fcf624d 100644 --- a/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/AwsSigningConfig.kt +++ b/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/AwsSigningConfig.kt @@ -12,18 +12,19 @@ public typealias ShouldSignHeaderPredicate = (String) -> Boolean /** * Defines the AWS signature version to use + * @param signingName The name of this algorithm to use when signing requests. */ -public enum class AwsSigningAlgorithm { +public enum class AwsSigningAlgorithm(public val signingName: String) { /** * AWS Signature Version 4 * see: https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html */ - SIGV4, + SIGV4("AWS4-HMAC-SHA256"), /** * AWS Signature Version 4 Asymmetric */ - SIGV4_ASYMMETRIC, + SIGV4_ASYMMETRIC("AWS4-ECDSA-P256-SHA256"), } /** diff --git a/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/AwsSigningExceptions.kt b/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/AwsSigningExceptions.kt index 450337090b..8ff8c1e35f 100644 --- a/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/AwsSigningExceptions.kt +++ b/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/AwsSigningExceptions.kt @@ -17,6 +17,7 @@ import aws.smithy.kotlin.runtime.InternalApi * @param cause The cause of the exception */ @InternalApi +@Deprecated("This exception is no longer thrown. It will be removed in the next minor version, v1.5.x.") public class UnsupportedSigningAlgorithmException( message: String, public val signingAlgorithm: AwsSigningAlgorithm, diff --git a/runtime/auth/aws-signing-default/build.gradle.kts b/runtime/auth/aws-signing-default/build.gradle.kts index 088c82f658..22e20d39fd 100644 --- a/runtime/auth/aws-signing-default/build.gradle.kts +++ b/runtime/auth/aws-signing-default/build.gradle.kts @@ -18,6 +18,7 @@ kotlin { dependencies { implementation(project(":runtime:auth:aws-signing-tests")) implementation(libs.kotlinx.coroutines.test) + implementation(libs.kotlinx.serialization.json) } } diff --git a/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/BaseSigV4SignatureCalculator.kt b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/BaseSigV4SignatureCalculator.kt new file mode 100644 index 0000000000..ae90003070 --- /dev/null +++ b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/BaseSigV4SignatureCalculator.kt @@ -0,0 +1,98 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package aws.smithy.kotlin.runtime.auth.awssigning + +import aws.smithy.kotlin.runtime.hashing.HashSupplier +import aws.smithy.kotlin.runtime.hashing.Sha256 +import aws.smithy.kotlin.runtime.hashing.hash +import aws.smithy.kotlin.runtime.hashing.sha256 +import aws.smithy.kotlin.runtime.text.encoding.encodeToHex +import aws.smithy.kotlin.runtime.time.Instant +import aws.smithy.kotlin.runtime.time.TimestampFormat +import aws.smithy.kotlin.runtime.time.epochMilliseconds + +/** + * Common signature implementation used for SigV4 and SigV4a, primarily for forming the strings-to-sign which don't differ + * between the two signing algorithms (besides their names). + */ +internal abstract class BaseSigV4SignatureCalculator( + val algorithm: AwsSigningAlgorithm, + open val sha256Provider: HashSupplier = ::Sha256, +) : SignatureCalculator { + private val supportedAlgorithms = setOf(AwsSigningAlgorithm.SIGV4, AwsSigningAlgorithm.SIGV4_ASYMMETRIC) + + init { + check(algorithm in supportedAlgorithms) { + "This class should only be used for ${supportedAlgorithms.joinToString()}, got $algorithm" + } + } + + override fun stringToSign(canonicalRequest: String, config: AwsSigningConfig): String = buildString { + appendLine(algorithm.signingName) + appendLine(config.signingDate.format(TimestampFormat.ISO_8601_CONDENSED)) + appendLine(config.credentialScope) + append(canonicalRequest.encodeToByteArray().hash(sha256Provider).encodeToHex()) + } + + override fun chunkStringToSign(chunkBody: ByteArray, prevSignature: ByteArray, config: AwsSigningConfig): String = buildString { + appendLine("${algorithm.signingName}-PAYLOAD") + appendLine(config.signingDate.format(TimestampFormat.ISO_8601_CONDENSED)) + appendLine(config.credentialScope) + appendLine(prevSignature.decodeToString()) // Should already be a byte array of ASCII hex chars + + val nonSignatureHeadersHash = when (config.signatureType) { + AwsSignatureType.HTTP_REQUEST_EVENT -> eventStreamNonSignatureHeaders(config.signingDate) + else -> HashSpecification.EmptyBody.hash + } + + appendLine(nonSignatureHeadersHash) + append(chunkBody.hash(sha256Provider).encodeToHex()) + } + + override fun chunkTrailerStringToSign(trailingHeaders: ByteArray, prevSignature: ByteArray, config: AwsSigningConfig): String = + buildString { + appendLine("${algorithm.signingName}-TRAILER") + appendLine(config.signingDate.format(TimestampFormat.ISO_8601_CONDENSED)) + appendLine(config.credentialScope) + appendLine(prevSignature.decodeToString()) + append(trailingHeaders.hash(sha256Provider).encodeToHex()) + } +} + +private const val HEADER_TIMESTAMP_TYPE: Byte = 8 + +/** + * Return the sha256 hex representation of the encoded event stream date header + * + * ``` + * sha256Hex( Header(":date", HeaderValue::Timestamp(date)).encodeToByteArray() ) + * ``` + * + * NOTE: This duplicates parts of the event stream encoding implementation here to avoid a direct dependency. + * Should this become more involved than encoding a single date header we should reconsider this choice. + * + * see [Event Stream implementation](https://github.com/smithy-lang/smithy-kotlin/blob/612c39ba446e6403ea2bd9a51c4d1db111b6e26f/runtime/protocol/aws-event-stream/common/src/aws/smithy/kotlin/runtime/awsprotocol/eventstream/Header.kt#L52) + */ +private fun eventStreamNonSignatureHeaders(date: Instant): String { + val bytes = ByteArray(15) + // encode header name + val name = ":date".encodeToByteArray() + var offset = 0 + bytes[offset++] = name.size.toByte() + name.copyInto(bytes, destinationOffset = offset) + offset += name.size + + // encode header value + bytes[offset++] = HEADER_TIMESTAMP_TYPE + writeLongBE(bytes, offset, date.epochMilliseconds) + return bytes.sha256().encodeToHex() +} + +private fun writeLongBE(dest: ByteArray, offset: Int, x: Long) { + var idx = offset + for (i in 7 downTo 0) { + dest[idx++] = ((x ushr (i * 8)) and 0xff).toByte() + } +} diff --git a/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/Canonicalizer.kt b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/Canonicalizer.kt index c7c47fe5a2..37d85f2704 100644 --- a/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/Canonicalizer.kt +++ b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/Canonicalizer.kt @@ -124,12 +124,13 @@ internal class DefaultCanonicalizer(private val sha256Supplier: HashSupplier = : } param("Host", builder.url.hostAndPort, !signViaQueryParams, overwrite = false) - param("X-Amz-Algorithm", ALGORITHM_NAME, signViaQueryParams) + param("X-Amz-Algorithm", config.algorithm.signingName, signViaQueryParams) param("X-Amz-Credential", credentialValue(config), signViaQueryParams) param("X-Amz-Content-Sha256", hash, addHashHeader) param("X-Amz-Date", config.signingDate.format(TimestampFormat.ISO_8601_CONDENSED)) param("X-Amz-Expires", config.expiresAfter?.inWholeSeconds?.toString(), signViaQueryParams) param("X-Amz-Security-Token", sessionToken, !config.omitSessionToken) // Add pre-sig if omitSessionToken=false + param("X-Amz-Region-Set", config.region, config.algorithm == AwsSigningAlgorithm.SIGV4_ASYMMETRIC) val headers = builder .headers diff --git a/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/DefaultAwsSigner.kt b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/DefaultAwsSigner.kt index 791d8fce0a..85896349b1 100644 --- a/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/DefaultAwsSigner.kt +++ b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/DefaultAwsSigner.kt @@ -29,10 +29,15 @@ public class DefaultAwsSignerBuilder { ) } +private val AwsSigningAlgorithm.signatureCalculator + get() = when (this) { + AwsSigningAlgorithm.SIGV4 -> SignatureCalculator.SigV4 + AwsSigningAlgorithm.SIGV4_ASYMMETRIC -> SignatureCalculator.SigV4a + } + @OptIn(ExperimentalApi::class) internal class DefaultAwsSignerImpl( private val canonicalizer: Canonicalizer = Canonicalizer.Default, - private val signatureCalculator: SignatureCalculator = SignatureCalculator.Default, private val requestMutator: RequestMutator = RequestMutator.Default, private val telemetryProvider: TelemetryProvider? = null, ) : AwsSigner { @@ -40,19 +45,13 @@ internal class DefaultAwsSignerImpl( val logger = telemetryProvider?.loggerProvider?.getOrCreateLogger("DefaultAwsSigner") ?: coroutineContext.logger() - // TODO: implement SigV4a - if (config.algorithm != AwsSigningAlgorithm.SIGV4) { - throw UnsupportedSigningAlgorithmException( - "${config.algorithm} support is not yet implemented for the default signer.", - config.algorithm, - ) - } - val canonical = canonicalizer.canonicalRequest(request, config) if (config.logRequest) { logger.trace { "Canonical request:\n${canonical.requestString}" } } + val signatureCalculator = config.algorithm.signatureCalculator + val stringToSign = signatureCalculator.stringToSign(canonical.requestString, config) logger.trace { "String to sign:\n$stringToSign" } @@ -74,6 +73,8 @@ internal class DefaultAwsSignerImpl( val logger = telemetryProvider?.loggerProvider?.getOrCreateLogger("DefaultAwsSigner") ?: coroutineContext.logger() + val signatureCalculator = config.algorithm.signatureCalculator + val stringToSign = signatureCalculator.chunkStringToSign(chunkBody, prevSignature, config) logger.trace { "Chunk string to sign:\n$stringToSign" } @@ -93,6 +94,8 @@ internal class DefaultAwsSignerImpl( val logger = telemetryProvider?.loggerProvider?.getOrCreateLogger("DefaultAwsSigner") ?: coroutineContext.logger() + val signatureCalculator = config.algorithm.signatureCalculator + // FIXME - can we share canonicalization code more than we are..., also this reduce is inefficient. // canonicalize the headers val trailingHeadersBytes = trailingHeaders.entries().sortedBy { e -> e.key.lowercase() } @@ -117,16 +120,16 @@ internal class DefaultAwsSignerImpl( } } -/** The name of the SigV4 algorithm. */ -internal const val ALGORITHM_NAME = "AWS4-HMAC-SHA256" - /** - * Formats a credential scope consisting of a signing date, region, service, and a signature type + * Formats a credential scope consisting of a signing date, region (SigV4 only), service, and a signature type */ internal val AwsSigningConfig.credentialScope: String - get() { + get() = run { val signingDate = signingDate.format(TimestampFormat.ISO_8601_CONDENSED_DATE) - return "$signingDate/$region/$service/aws4_request" + return when (algorithm) { + AwsSigningAlgorithm.SIGV4 -> "$signingDate/$region/$service/aws4_request" + AwsSigningAlgorithm.SIGV4_ASYMMETRIC -> "$signingDate/$service/aws4_request" + } } /** diff --git a/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/RequestMutator.kt b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/RequestMutator.kt index 5bfe982e54..0c1882cd54 100644 --- a/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/RequestMutator.kt +++ b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/RequestMutator.kt @@ -43,7 +43,7 @@ internal class DefaultRequestMutator : RequestMutator { val credential = "Credential=${credentialValue(config)}" val signedHeaders = "SignedHeaders=${canonical.signedHeaders}" val signature = "Signature=$signatureHex" - canonical.request.headers["Authorization"] = "$ALGORITHM_NAME $credential, $signedHeaders, $signature" + canonical.request.headers["Authorization"] = "${config.algorithm.signingName} $credential, $signedHeaders, $signature" } AwsSignatureType.HTTP_REQUEST_VIA_QUERY_PARAMS -> diff --git a/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/SigV4SignatureCalculator.kt b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/SigV4SignatureCalculator.kt new file mode 100644 index 0000000000..c210c964c2 --- /dev/null +++ b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/SigV4SignatureCalculator.kt @@ -0,0 +1,30 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package aws.smithy.kotlin.runtime.auth.awssigning + +import aws.smithy.kotlin.runtime.hashing.HashSupplier +import aws.smithy.kotlin.runtime.hashing.Sha256 +import aws.smithy.kotlin.runtime.hashing.hmac +import aws.smithy.kotlin.runtime.text.encoding.encodeToHex +import aws.smithy.kotlin.runtime.time.TimestampFormat + +/** + * [SignatureCalculator] for the SigV4 ("AWS4-HMAC-SHA256") algorithm + * @param sha256Provider the [HashSupplier] to use for computing SHA-256 hashes + */ +internal class SigV4SignatureCalculator(override val sha256Provider: HashSupplier = ::Sha256) : BaseSigV4SignatureCalculator(AwsSigningAlgorithm.SIGV4, sha256Provider) { + override fun calculate(signingKey: ByteArray, stringToSign: String): String = + hmac(signingKey, stringToSign.encodeToByteArray(), sha256Provider).encodeToHex() + + override fun signingKey(config: AwsSigningConfig): ByteArray { + fun hmac(key: ByteArray, message: String) = hmac(key, message.encodeToByteArray(), sha256Provider) + + val initialKey = ("AWS4" + config.credentials.secretAccessKey).encodeToByteArray() + val kDate = hmac(initialKey, config.signingDate.format(TimestampFormat.ISO_8601_CONDENSED_DATE)) + val kRegion = hmac(kDate, config.region) + val kService = hmac(kRegion, config.service) + return hmac(kService, "aws4_request") + } +} diff --git a/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/SigV4aSignatureCalculator.kt b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/SigV4aSignatureCalculator.kt new file mode 100644 index 0000000000..c4fe4f5ca4 --- /dev/null +++ b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/SigV4aSignatureCalculator.kt @@ -0,0 +1,88 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package aws.smithy.kotlin.runtime.auth.awssigning + +import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials +import aws.smithy.kotlin.runtime.collections.PeriodicSweepCache +import aws.smithy.kotlin.runtime.content.BigInteger +import aws.smithy.kotlin.runtime.hashing.HashSupplier +import aws.smithy.kotlin.runtime.hashing.Sha256 +import aws.smithy.kotlin.runtime.hashing.ecdsaSecp256r1 +import aws.smithy.kotlin.runtime.hashing.hmac +import aws.smithy.kotlin.runtime.text.encoding.decodeHexBytes +import aws.smithy.kotlin.runtime.text.encoding.encodeToHex +import aws.smithy.kotlin.runtime.time.Instant +import aws.smithy.kotlin.runtime.util.ExpiringValue +import kotlinx.coroutines.runBlocking +import kotlin.time.Duration.Companion.hours + +/** + * The maximum number of iterations to attempt private key derivation using KDF in counter mode + * Taken from CRT: https://github.com/awslabs/aws-c-auth/blob/e8360a65e0f3337d4ac827945e00c3b55a641a5f/source/key_derivation.c#L22 + */ +internal const val MAX_KDF_COUNTER_ITERATIONS = 254.toByte() + +// N value from NIST P-256 curve, minus two. +internal val N_MINUS_TWO = "FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC63254F".decodeHexBytes().toPositiveBigInteger() + +/** + * A [SignatureCalculator] for the SigV4a ("AWS4-ECDSA-P256-SHA256") algorithm. + * @param sha256Provider the [HashSupplier] to use for computing SHA-256 hashes + */ +internal class SigV4aSignatureCalculator(override val sha256Provider: HashSupplier = ::Sha256) : BaseSigV4SignatureCalculator(AwsSigningAlgorithm.SIGV4_ASYMMETRIC, sha256Provider) { + private val privateKeyCache = PeriodicSweepCache( + minimumSweepPeriod = 1.hours, // note: Sweeps are effectively a no-op because expiration is [Instant.MAX_VALUE] + ) + + override fun calculate(signingKey: ByteArray, stringToSign: String): String = ecdsaSecp256r1(signingKey, stringToSign.encodeToByteArray()).encodeToHex() + + /** + * Retrieve a signing key based on the signing credentials. If not cached, the key will be derived using a counter-based key derivation function (KDF) + * as specified in NIST SP 800-108. + * + * See https://github.com/awslabs/aws-c-auth/blob/e8360a65e0f3337d4ac827945e00c3b55a641a5f/source/key_derivation.c#L70 and + * https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#derive-signing-key-sigv4a for + * more information on the derivation process. + */ + override fun signingKey(config: AwsSigningConfig): ByteArray = runBlocking { + privateKeyCache.get(config.credentials) { + var counter: Byte = 1 + var privateKey: ByteArray + + val inputKey = ("AWS4A" + config.credentials.secretAccessKey).encodeToByteArray() + + do { + val k0 = hmac(inputKey, fixedInputString(config.credentials.accessKeyId, counter), sha256Provider) + + val c = k0.toPositiveBigInteger() + privateKey = (c + BigInteger("1")).toByteArray() + + if (counter == MAX_KDF_COUNTER_ITERATIONS && c > N_MINUS_TWO) { + throw IllegalStateException("Counter exceeded maximum length") + } else { + counter++ + } + } while (c > N_MINUS_TWO) + + ExpiringValue(privateKey, Instant.MAX_VALUE) + } + } + + /** + * Forms the fixed input string used for ECDSA private key derivation + * The final output looks like: + * 0x00000001 || "AWS4-ECDSA-P256-SHA256" || 0x00 || AccessKeyId || counter || 0x00000100 + */ + private fun fixedInputString(accessKeyId: String, counter: Byte): ByteArray = + byteArrayOf(0x00, 0x00, 0x00, 0x01) + + AwsSigningAlgorithm.SIGV4_ASYMMETRIC.signingName.encodeToByteArray() + + byteArrayOf(0x00) + + accessKeyId.encodeToByteArray() + + counter + + byteArrayOf(0x00, 0x00, 0x01, 0x00) +} + +// Convert [this] [ByteArray] to a positive [BigInteger] by prepending 0x00. +private fun ByteArray.toPositiveBigInteger() = BigInteger(byteArrayOf(0x00) + this) diff --git a/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/SignatureCalculator.kt b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/SignatureCalculator.kt index fe27320ffc..80652e6dbf 100644 --- a/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/SignatureCalculator.kt +++ b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/SignatureCalculator.kt @@ -4,21 +4,20 @@ */ package aws.smithy.kotlin.runtime.auth.awssigning -import aws.smithy.kotlin.runtime.hashing.* -import aws.smithy.kotlin.runtime.text.encoding.encodeToHex -import aws.smithy.kotlin.runtime.time.Instant -import aws.smithy.kotlin.runtime.time.TimestampFormat -import aws.smithy.kotlin.runtime.time.epochMilliseconds - /** * An object that can calculate signatures based on canonical requests. */ internal interface SignatureCalculator { companion object { /** - * The default implementation of [SignatureCalculator]. + * The SigV4 implementation of [SignatureCalculator]. + */ + val SigV4 = SigV4SignatureCalculator() + + /** + * The SigV4a implementation of [SignatureCalculator]. */ - val Default = DefaultSignatureCalculator() + val SigV4a = SigV4aSignatureCalculator() } /** @@ -62,87 +61,3 @@ internal interface SignatureCalculator { */ fun chunkTrailerStringToSign(trailingHeaders: ByteArray, prevSignature: ByteArray, config: AwsSigningConfig): String } - -internal class DefaultSignatureCalculator(private val sha256Provider: HashSupplier = ::Sha256) : SignatureCalculator { - override fun calculate(signingKey: ByteArray, stringToSign: String): String = - hmac(signingKey, stringToSign.encodeToByteArray(), sha256Provider).encodeToHex() - - override fun chunkStringToSign(chunkBody: ByteArray, prevSignature: ByteArray, config: AwsSigningConfig): String = - buildString { - appendLine("AWS4-HMAC-SHA256-PAYLOAD") - appendLine(config.signingDate.format(TimestampFormat.ISO_8601_CONDENSED)) - appendLine(config.credentialScope) - appendLine(prevSignature.decodeToString()) // Should already be a byte array of ASCII hex chars - - val nonSignatureHeadersHash = when (config.signatureType) { - AwsSignatureType.HTTP_REQUEST_EVENT -> eventStreamNonSignatureHeaders(config.signingDate) - else -> HashSpecification.EmptyBody.hash - } - - appendLine(nonSignatureHeadersHash) - append(chunkBody.hash(sha256Provider).encodeToHex()) - } - - override fun chunkTrailerStringToSign(trailingHeaders: ByteArray, prevSignature: ByteArray, config: AwsSigningConfig): String = - buildString { - appendLine("AWS4-HMAC-SHA256-TRAILER") - appendLine(config.signingDate.format(TimestampFormat.ISO_8601_CONDENSED)) - appendLine(config.credentialScope) - appendLine(prevSignature.decodeToString()) - append(trailingHeaders.hash(sha256Provider).encodeToHex()) - } - - override fun signingKey(config: AwsSigningConfig): ByteArray { - fun hmac(key: ByteArray, message: String) = hmac(key, message.encodeToByteArray(), sha256Provider) - - val initialKey = ("AWS4" + config.credentials.secretAccessKey).encodeToByteArray() - val kDate = hmac(initialKey, config.signingDate.format(TimestampFormat.ISO_8601_CONDENSED_DATE)) - val kRegion = hmac(kDate, config.region) - val kService = hmac(kRegion, config.service) - return hmac(kService, "aws4_request") - } - - override fun stringToSign(canonicalRequest: String, config: AwsSigningConfig): String = - buildString { - appendLine("AWS4-HMAC-SHA256") - appendLine(config.signingDate.format(TimestampFormat.ISO_8601_CONDENSED)) - appendLine(config.credentialScope) - append(canonicalRequest.encodeToByteArray().hash(sha256Provider).encodeToHex()) - } -} - -private const val HEADER_TIMESTAMP_TYPE: Byte = 8 - -/** - * Return the sha256 hex representation of the encoded event stream date header - * - * ``` - * sha256Hex( Header(":date", HeaderValue::Timestamp(date)).encodeToByteArray() ) - * ``` - * - * NOTE: This duplicates parts of the event stream encoding implementation here to avoid a direct dependency. - * Should this become more involved than encoding a single date header we should reconsider this choice. - * - * see [Event Stream implementation](https://github.com/awslabs/aws-sdk-kotlin/blob/v0.16.4-beta/aws-runtime/protocols/aws-event-stream/common/src/aws/sdk/kotlin/runtime/protocol/eventstream/Header.kt#L51) - */ -private fun eventStreamNonSignatureHeaders(date: Instant): String { - val bytes = ByteArray(15) - // encode header name - val name = ":date".encodeToByteArray() - var offset = 0 - bytes[offset++] = name.size.toByte() - name.copyInto(bytes, destinationOffset = offset) - offset += name.size - - // encode header value - bytes[offset++] = HEADER_TIMESTAMP_TYPE - writeLongBE(bytes, offset, date.epochMilliseconds) - return bytes.sha256().encodeToHex() -} - -private fun writeLongBE(dest: ByteArray, offset: Int, x: Long) { - var idx = offset - for (i in 7 downTo 0) { - dest[idx++] = ((x ushr (i * 8)) and 0xff).toByte() - } -} diff --git a/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultBasicSigningTest.kt b/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultBasicSigningTest.kt index 5a36a1fd46..ba766f1692 100644 --- a/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultBasicSigningTest.kt +++ b/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultBasicSigningTest.kt @@ -5,14 +5,7 @@ package aws.smithy.kotlin.runtime.auth.awssigning import aws.smithy.kotlin.runtime.auth.awssigning.tests.BasicSigningTestBase -import kotlinx.coroutines.test.TestResult -import kotlin.test.Ignore -import kotlin.test.Test class DefaultBasicSigningTest : BasicSigningTestBase() { override val signer: AwsSigner = DefaultAwsSigner - - @Ignore - @Test - override fun testSignRequestSigV4Asymmetric(): TestResult = TODO("Add support for SigV4a in default signer") } diff --git a/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultRequestMutatorTest.kt b/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultRequestMutatorTest.kt index 7eb10ab692..5cb36c9a3f 100644 --- a/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultRequestMutatorTest.kt +++ b/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultRequestMutatorTest.kt @@ -16,11 +16,12 @@ import kotlin.test.assertEquals class DefaultRequestMutatorTest { @Test - fun testAppendAuthHeader() { + fun testSigV4AppendAuthHeader() { val canonical = CanonicalRequest(baseRequest.toBuilder(), "", "action;host;x-amz-date", "") val signature = "0123456789abcdef" val config = AwsSigningConfig { + algorithm = AwsSigningAlgorithm.SIGV4 region = "us-west-2" service = "fooservice" signingDate = Instant.fromIso8601("20220427T012345Z") @@ -45,6 +46,41 @@ class DefaultRequestMutatorTest { assertEquals(expectedHeaders, mutated.headers.entries()) } + + @Test + fun testSigV4aAppendAuthHeader() { + val canonical = CanonicalRequest(baseRequest.toBuilder(), "", "action;host;x-amz-date", "") + val signature = "0123456789abcdef" + + val config = AwsSigningConfig { + algorithm = AwsSigningAlgorithm.SIGV4_ASYMMETRIC + region = "us-west-2" + service = "fooservice" + signingDate = Instant.fromIso8601("20220427T012345Z") + credentials = Credentials("", "secret key") + omitSessionToken = true + } + + val mutated = RequestMutator.Default.appendAuth(config, canonical, signature) + + assertEquals(baseRequest.method, mutated.method) + assertEquals(baseRequest.url.toString(), mutated.url.toString()) + assertEquals(baseRequest.body, mutated.body) + + val expectedCredentialScope = "20220427/fooservice/aws4_request" + val expectedAuthValue = buildString { + append("AWS4-ECDSA-P256-SHA256 ") + append("Credential=${config.credentials.accessKeyId}/$expectedCredentialScope, ") + append("SignedHeaders=${canonical.signedHeaders}, ") + append("Signature=$signature") + } + val expectedHeaders = Headers { + appendAll(baseRequest.headers) + append("Authorization", expectedAuthValue) + }.entries() + + assertEquals(expectedHeaders, mutated.headers.entries()) + } } private val baseRequest = HttpRequest { diff --git a/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultSignatureCalculatorTest.kt b/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/SigV4SignatureCalculatorTest.kt similarity index 92% rename from runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultSignatureCalculatorTest.kt rename to runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/SigV4SignatureCalculatorTest.kt index fc5c23304c..b1df30fb5b 100644 --- a/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultSignatureCalculatorTest.kt +++ b/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/SigV4SignatureCalculatorTest.kt @@ -14,7 +14,7 @@ import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals -class DefaultSignatureCalculatorTest { +class SigV4SignatureCalculatorTest { // Test adapted from https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html @Test fun testCalculate() { @@ -27,7 +27,7 @@ class DefaultSignatureCalculatorTest { """.trimIndent() val expected = "5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7" - val actual = SignatureCalculator.Default.calculate(signingKey, stringToSign) + val actual = SignatureCalculator.SigV4.calculate(signingKey, stringToSign) assertEquals(expected, actual) } @@ -42,7 +42,7 @@ class DefaultSignatureCalculatorTest { } val expected = "c4afb1cc5771d871763a393e44b703571b55cc28424d1a5e86da6ed3c154a4b9" - val actual = SignatureCalculator.Default.signingKey(config).encodeToHex() + val actual = SignatureCalculator.SigV4.signingKey(config).encodeToHex() assertEquals(expected, actual) } @@ -74,7 +74,7 @@ class DefaultSignatureCalculatorTest { 20150830/us-east-1/iam/aws4_request f536975d06c0309214f805bb90ccff089219ecd68b2577efef23edd43b7e1a59 """.trimIndent() - val actual = SignatureCalculator.Default.stringToSign(canonicalRequest, config) + val actual = SignatureCalculator.SigV4.stringToSign(canonicalRequest, config) assertEquals(expected, actual) } @@ -114,7 +114,7 @@ class DefaultSignatureCalculatorTest { 813ca5285c28ccee5cab8b10ebda9c908fd6d78ed9dc94cc65ea6cb67a7f13ae """.trimIndent() - val actual = SignatureCalculator.Default.chunkStringToSign(chunkBody, prevSignature, config) + val actual = SignatureCalculator.SigV4.chunkStringToSign(chunkBody, prevSignature, config) assertEquals(expected, actual) } } diff --git a/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/SigV4aSignatureCalculatorTest.kt b/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/SigV4aSignatureCalculatorTest.kt new file mode 100644 index 0000000000..c4c4abe953 --- /dev/null +++ b/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/SigV4aSignatureCalculatorTest.kt @@ -0,0 +1,121 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package aws.smithy.kotlin.runtime.auth.awssigning + +import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials +import aws.smithy.kotlin.runtime.time.Instant +import aws.smithy.kotlin.runtime.util.PlatformProvider +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private const val SIGV4A_RESOURCES_BASE = "../aws-signing-tests/common/resources/aws-signing-test-suite/v4a" + +/** + * Tests which are defined in resources/sigv4a. + * Copied directly from https://github.com/awslabs/aws-c-auth/tree/e8360a65e0f3337d4ac827945e00c3b55a641a5f/tests/aws-signing-test-suite/v4a. + * get-vanilla-query-order-key and get-vanilla-query-order-value were deleted since they are not complete tests. + */ +private val TESTS = listOf( + "get-header-key-duplicate", + "get-header-value-multiline", + "get-header-value-order", + "get-header-value-trim", + "get-relative-normalized", + "get-relative-relative-normalized", + "get-relative-relative-unnormalized", + "get-relative-unnormalized", + "get-slash-dot-slash-normalized", + "get-slash-dot-slash-unnormalized", + "get-slash-normalized", + "get-slash-pointless-dot-normalized", + "get-slash-pointless-dot-unnormalized", + "get-slash-unnormalized", + "get-slashes-normalized", + "get-slashes-unnormalized", + "get-space-normalized", + "get-space-unnormalized", + "get-unreserved", + "get-utf8", + "get-vanilla", + "get-vanilla-empty-query-key", + "get-vanilla-query", + "get-vanilla-query-order-encoded", + "get-vanilla-query-order-key-case", + "get-vanilla-query-unreserved", + "get-vanilla-utf8-query", + "get-vanilla-with-session-token", + "post-header-key-case", + "post-header-key-sort", + "post-header-value-case", + "post-sts-header-after", + "post-sts-header-before", + "post-vanilla", + "post-vanilla-empty-query-value", + "post-vanilla-query", + "post-x-www-form-urlencoded", + "post-x-www-form-urlencoded-parameters", +) + +// TODO Add tests against header-signature.txt when java.security implements RFC 6979 / deterministic ECDSA. https://bugs.openjdk.org/browse/JDK-8239382 +/** + * Tests for [SigV4aSignatureCalculator]. Currently only tests forming the string-to-sign. + */ +class SigV4aSignatureCalculatorTest { + @Test + fun testStringToSign() = TESTS.forEach { testId -> + runTest { + val testDir = "$SIGV4A_RESOURCES_BASE/$testId/" + assertTrue(PlatformProvider.System.fileExists(testDir), "Failed to find test directory for $testId") + + val context = Json.parseToJsonElement(testDir.fileContents("context.json")).jsonObject + val signingConfig = context.parseAwsSigningConfig() + + val expectedStringToSign = testDir.fileContents("header-string-to-sign.txt") + val canonicalRequest = testDir.fileContents("header-canonical-request.txt") + val actualStringToSign = SignatureCalculator.SigV4a.stringToSign(canonicalRequest, signingConfig) + + assertEquals(expectedStringToSign, actualStringToSign, "$testId failed") + } + } + + private fun JsonObject.parseAwsSigningConfig(): AwsSigningConfig { + fun JsonObject.getStringValue(key: String): String { + val value = checkNotNull(get(key)) { "Failed to find key $key in JSON object $this" } + return value.toString().replace("\"", "") + } + + val contextCredentials = checkNotNull(get("credentials")?.jsonObject) { "credentials unexpectedly null" } + + val credentials = Credentials( + contextCredentials.getStringValue("access_key_id"), + contextCredentials.getStringValue("secret_access_key"), + ) + + val region = getStringValue("region") + val service = getStringValue("service") + val signingDate = Instant.fromIso8601(getStringValue("timestamp")) + + return AwsSigningConfig { + algorithm = AwsSigningAlgorithm.SIGV4_ASYMMETRIC + this.credentials = credentials + this.region = region + this.service = service + this.signingDate = signingDate + } + } + + private suspend fun String.fileContents(path: String): String = checkNotNull( + PlatformProvider.System.readFileOrNull(this + path) + ?.decodeToString() + ?.replace("\r\n", "\n"), + ) { + "Unable to read contents at ${this + path}" + } +} diff --git a/runtime/auth/aws-signing-default/jvm/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultSigningSuiteTest.kt b/runtime/auth/aws-signing-default/jvm/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultSigningSuiteTest.kt index 614c2fe016..1edbca9f88 100644 --- a/runtime/auth/aws-signing-default/jvm/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultSigningSuiteTest.kt +++ b/runtime/auth/aws-signing-default/jvm/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultSigningSuiteTest.kt @@ -17,13 +17,13 @@ class DefaultSigningSuiteTest : SigningSuiteTestBase() { override val signatureProvider: SigningStateProvider = { request, config -> val canonical = Canonicalizer.Default.canonicalRequest(request, config) - val stringToSign = SignatureCalculator.Default.stringToSign(canonical.requestString, config) - val signingKey = SignatureCalculator.Default.signingKey(config) - SignatureCalculator.Default.calculate(signingKey, stringToSign) + val stringToSign = SignatureCalculator.SigV4.stringToSign(canonical.requestString, config) + val signingKey = SignatureCalculator.SigV4.signingKey(config) + SignatureCalculator.SigV4.calculate(signingKey, stringToSign) } override val stringToSignProvider: SigningStateProvider = { request, config -> val canonical = Canonicalizer.Default.canonicalRequest(request, config) - SignatureCalculator.Default.stringToSign(canonical.requestString, config) + SignatureCalculator.SigV4.stringToSign(canonical.requestString, config) } } diff --git a/runtime/observability/telemetry-api/api/telemetry-api.api b/runtime/observability/telemetry-api/api/telemetry-api.api index 33adef236a..cf14c4c7c0 100644 --- a/runtime/observability/telemetry-api/api/telemetry-api.api +++ b/runtime/observability/telemetry-api/api/telemetry-api.api @@ -364,6 +364,7 @@ public final class aws/smithy/kotlin/runtime/telemetry/metrics/UpDownCounter$Def public abstract class aws/smithy/kotlin/runtime/telemetry/trace/AbstractTraceSpan : aws/smithy/kotlin/runtime/telemetry/trace/TraceSpan { public fun ()V + public fun asContextElement ()Lkotlin/coroutines/CoroutineContext; public fun close ()V public fun emitEvent (Ljava/lang/String;Laws/smithy/kotlin/runtime/collections/Attributes;)V public fun getSpanContext ()Laws/smithy/kotlin/runtime/telemetry/trace/SpanContext; @@ -424,6 +425,7 @@ public final class aws/smithy/kotlin/runtime/telemetry/trace/SpanStatus : java/l public abstract interface class aws/smithy/kotlin/runtime/telemetry/trace/TraceSpan : aws/smithy/kotlin/runtime/telemetry/context/Scope { public static final field Companion Laws/smithy/kotlin/runtime/telemetry/trace/TraceSpan$Companion; + public abstract fun asContextElement ()Lkotlin/coroutines/CoroutineContext; public abstract fun close ()V public abstract fun emitEvent (Ljava/lang/String;Laws/smithy/kotlin/runtime/collections/Attributes;)V public abstract fun getSpanContext ()Laws/smithy/kotlin/runtime/telemetry/trace/SpanContext; @@ -437,6 +439,7 @@ public final class aws/smithy/kotlin/runtime/telemetry/trace/TraceSpan$Companion } public final class aws/smithy/kotlin/runtime/telemetry/trace/TraceSpan$DefaultImpls { + public static fun asContextElement (Laws/smithy/kotlin/runtime/telemetry/trace/TraceSpan;)Lkotlin/coroutines/CoroutineContext; public static synthetic fun emitEvent$default (Laws/smithy/kotlin/runtime/telemetry/trace/TraceSpan;Ljava/lang/String;Laws/smithy/kotlin/runtime/collections/Attributes;ILjava/lang/Object;)V } diff --git a/runtime/observability/telemetry-api/common/src/aws/smithy/kotlin/runtime/telemetry/trace/AbstractTraceSpan.kt b/runtime/observability/telemetry-api/common/src/aws/smithy/kotlin/runtime/telemetry/trace/AbstractTraceSpan.kt index fbc6369ec3..1892098b7b 100644 --- a/runtime/observability/telemetry-api/common/src/aws/smithy/kotlin/runtime/telemetry/trace/AbstractTraceSpan.kt +++ b/runtime/observability/telemetry-api/common/src/aws/smithy/kotlin/runtime/telemetry/trace/AbstractTraceSpan.kt @@ -6,6 +6,8 @@ package aws.smithy.kotlin.runtime.telemetry.trace import aws.smithy.kotlin.runtime.collections.AttributeKey import aws.smithy.kotlin.runtime.collections.Attributes +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext /** * An abstract implementation of a trace span. By default, this class uses no-op implementations for all members unless @@ -18,4 +20,5 @@ public abstract class AbstractTraceSpan : TraceSpan { override operator fun set(key: AttributeKey, value: T) { } override fun mergeAttributes(attributes: Attributes) { } override fun close() { } + override fun asContextElement(): CoroutineContext = EmptyCoroutineContext } diff --git a/runtime/observability/telemetry-api/common/src/aws/smithy/kotlin/runtime/telemetry/trace/CoroutineContextTraceExt.kt b/runtime/observability/telemetry-api/common/src/aws/smithy/kotlin/runtime/telemetry/trace/CoroutineContextTraceExt.kt index cb6795fe9c..73e84dd756 100644 --- a/runtime/observability/telemetry-api/common/src/aws/smithy/kotlin/runtime/telemetry/trace/CoroutineContextTraceExt.kt +++ b/runtime/observability/telemetry-api/common/src/aws/smithy/kotlin/runtime/telemetry/trace/CoroutineContextTraceExt.kt @@ -71,7 +71,7 @@ public suspend inline fun withSpan( // or else traces may be disconnected from their parent val updatedCtx = coroutineContext[TelemetryProviderContext]?.provider?.contextManager?.current() val telemetryCtxElement = (updatedCtx?.let { TelemetryContextElement(it) } ?: coroutineContext[TelemetryContextElement]) ?: EmptyCoroutineContext - withContext(context + TraceSpanContext(span) + telemetryCtxElement) { + withContext(context + TraceSpanContext(span) + telemetryCtxElement + span.asContextElement()) { block(span) } } catch (ex: Exception) { diff --git a/runtime/observability/telemetry-api/common/src/aws/smithy/kotlin/runtime/telemetry/trace/TraceSpan.kt b/runtime/observability/telemetry-api/common/src/aws/smithy/kotlin/runtime/telemetry/trace/TraceSpan.kt index d3c7972a85..78a92e6057 100644 --- a/runtime/observability/telemetry-api/common/src/aws/smithy/kotlin/runtime/telemetry/trace/TraceSpan.kt +++ b/runtime/observability/telemetry-api/common/src/aws/smithy/kotlin/runtime/telemetry/trace/TraceSpan.kt @@ -9,6 +9,8 @@ import aws.smithy.kotlin.runtime.collections.AttributeKey import aws.smithy.kotlin.runtime.collections.Attributes import aws.smithy.kotlin.runtime.collections.emptyAttributes import aws.smithy.kotlin.runtime.telemetry.context.Scope +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext /** * Represents a single operation/task within a trace. Each trace contains a root span and @@ -27,6 +29,11 @@ public interface TraceSpan : Scope { */ public val spanContext: SpanContext + /** + * A representation of this span as a [CoroutineContext] element + */ + public fun asContextElement(): CoroutineContext = EmptyCoroutineContext + /** * Set an attribute on the span * @param key the attribute key to use diff --git a/runtime/observability/telemetry-provider-otel/build.gradle.kts b/runtime/observability/telemetry-provider-otel/build.gradle.kts index 8792275766..bdee5b1d6a 100644 --- a/runtime/observability/telemetry-provider-otel/build.gradle.kts +++ b/runtime/observability/telemetry-provider-otel/build.gradle.kts @@ -18,6 +18,7 @@ kotlin { jvmMain { dependencies { api(libs.opentelemetry.api) + api(libs.opentelemetry.kotlin.extension) } } all { diff --git a/runtime/observability/telemetry-provider-otel/jvm/src/aws/smithy/kotlin/runtime/telemetry/otel/OtelTracerProvider.kt b/runtime/observability/telemetry-provider-otel/jvm/src/aws/smithy/kotlin/runtime/telemetry/otel/OtelTracerProvider.kt index cf6eb39dfd..20ef72f139 100644 --- a/runtime/observability/telemetry-provider-otel/jvm/src/aws/smithy/kotlin/runtime/telemetry/otel/OtelTracerProvider.kt +++ b/runtime/observability/telemetry-provider-otel/jvm/src/aws/smithy/kotlin/runtime/telemetry/otel/OtelTracerProvider.kt @@ -10,6 +10,8 @@ import aws.smithy.kotlin.runtime.collections.Attributes import aws.smithy.kotlin.runtime.telemetry.context.Context import aws.smithy.kotlin.runtime.telemetry.trace.* import io.opentelemetry.api.OpenTelemetry +import io.opentelemetry.extension.kotlin.asContextElement +import kotlin.coroutines.CoroutineContext import io.opentelemetry.api.trace.Span as OtelSpan import io.opentelemetry.api.trace.SpanContext as OtelSpanContext import io.opentelemetry.api.trace.SpanKind as OtelSpanKind @@ -62,11 +64,11 @@ private class OtelSpanContextImpl(private val otelSpanContext: OtelSpanContext) internal class OtelTraceSpanImpl( private val otelSpan: OtelSpan, ) : TraceSpan { - - private val spanScope = otelSpan.makeCurrent() - override val spanContext: SpanContext get() = OtelSpanContextImpl(otelSpan.spanContext) + + override fun asContextElement(): CoroutineContext = otelSpan.asContextElement() + override fun set(key: AttributeKey, value: T) { key.otelAttrKeyOrNull(value)?.let { otelKey -> otelSpan.setAttribute(otelKey, value) @@ -90,7 +92,6 @@ internal class OtelTraceSpanImpl( override fun close() { otelSpan.end() - spanScope.close() } } diff --git a/runtime/protocol/aws-event-stream/common/src/aws/smithy/kotlin/runtime/awsprotocol/eventstream/Message.kt b/runtime/protocol/aws-event-stream/common/src/aws/smithy/kotlin/runtime/awsprotocol/eventstream/Message.kt index 10d19fc603..6280520355 100644 --- a/runtime/protocol/aws-event-stream/common/src/aws/smithy/kotlin/runtime/awsprotocol/eventstream/Message.kt +++ b/runtime/protocol/aws-event-stream/common/src/aws/smithy/kotlin/runtime/awsprotocol/eventstream/Message.kt @@ -12,15 +12,15 @@ import aws.smithy.kotlin.runtime.text.encoding.encodeToHex internal const val MESSAGE_CRC_BYTE_LEN = 4 -// max message size is 16 MB -internal const val MAX_MESSAGE_SIZE = 16 * 1024 * 1024 +// max message size is 24 MB +internal const val MAX_MESSAGE_SIZE = 24 * 1024 * 1024 // max header size is 128 KB internal const val MAX_HEADER_SIZE = 128 * 1024 /* Message Wire Format - See also: https://docs.aws.amazon.com/transcribe/latest/dg/event-stream-med.html + See also: https://docs.aws.amazon.com/transcribe/latest/dg/streaming-setting-up.html +--------------------------------------------------------------------+ -- | Total Len (32) | | diff --git a/runtime/runtime-core/api/runtime-core.api b/runtime/runtime-core/api/runtime-core.api index e45d75cb08..b9a535542c 100644 --- a/runtime/runtime-core/api/runtime-core.api +++ b/runtime/runtime-core/api/runtime-core.api @@ -104,6 +104,9 @@ public final class aws/smithy/kotlin/runtime/businessmetrics/BusinessMetricsUtil public final class aws/smithy/kotlin/runtime/businessmetrics/SmithyBusinessMetric : java/lang/Enum, aws/smithy/kotlin/runtime/businessmetrics/BusinessMetric { public static final field ACCOUNT_ID_BASED_ENDPOINT Laws/smithy/kotlin/runtime/businessmetrics/SmithyBusinessMetric; + public static final field ACCOUNT_ID_MODE_DISABLED Laws/smithy/kotlin/runtime/businessmetrics/SmithyBusinessMetric; + public static final field ACCOUNT_ID_MODE_PREFERRED Laws/smithy/kotlin/runtime/businessmetrics/SmithyBusinessMetric; + public static final field ACCOUNT_ID_MODE_REQUIRED Laws/smithy/kotlin/runtime/businessmetrics/SmithyBusinessMetric; public static final field FLEXIBLE_CHECKSUMS_REQ_CRC32 Laws/smithy/kotlin/runtime/businessmetrics/SmithyBusinessMetric; public static final field FLEXIBLE_CHECKSUMS_REQ_CRC32C Laws/smithy/kotlin/runtime/businessmetrics/SmithyBusinessMetric; public static final field FLEXIBLE_CHECKSUMS_REQ_SHA1 Laws/smithy/kotlin/runtime/businessmetrics/SmithyBusinessMetric; @@ -115,6 +118,7 @@ public final class aws/smithy/kotlin/runtime/businessmetrics/SmithyBusinessMetri public static final field GZIP_REQUEST_COMPRESSION Laws/smithy/kotlin/runtime/businessmetrics/SmithyBusinessMetric; public static final field PAGINATOR Laws/smithy/kotlin/runtime/businessmetrics/SmithyBusinessMetric; public static final field PROTOCOL_RPC_V2_CBOR Laws/smithy/kotlin/runtime/businessmetrics/SmithyBusinessMetric; + public static final field RESOLVED_ACCOUNT_ID Laws/smithy/kotlin/runtime/businessmetrics/SmithyBusinessMetric; public static final field RETRY_MODE_ADAPTIVE Laws/smithy/kotlin/runtime/businessmetrics/SmithyBusinessMetric; public static final field RETRY_MODE_STANDARD Laws/smithy/kotlin/runtime/businessmetrics/SmithyBusinessMetric; public static final field SERVICE_ENDPOINT_OVERRIDE Laws/smithy/kotlin/runtime/businessmetrics/SmithyBusinessMetric; @@ -312,6 +316,7 @@ public class aws/smithy/kotlin/runtime/collections/ValuesMapImpl : aws/smithy/ko public fun getAll (Ljava/lang/String;)Ljava/util/List; public fun getCaseInsensitiveName ()Z protected final fun getValues ()Ljava/util/Map; + public fun hashCode ()I public fun isEmpty ()Z public fun names ()Ljava/util/Set; } @@ -698,6 +703,10 @@ public final class aws/smithy/kotlin/runtime/hashing/Crc32cKt { public static final fun crc32c ([B)I } +public final class aws/smithy/kotlin/runtime/hashing/EcdsaJVMKt { + public static final fun ecdsaSecp256r1 ([B[B)[B +} + public abstract interface class aws/smithy/kotlin/runtime/hashing/HashFunction { public abstract fun digest ()[B public abstract fun getBlockSizeBytes ()I @@ -2090,6 +2099,7 @@ public final class aws/smithy/kotlin/runtime/smoketests/SmokeTestsFunctionsJVMKt } public final class aws/smithy/kotlin/runtime/smoketests/SmokeTestsFunctionsKt { + public static final fun getDefaultPrinter ()Ljava/lang/Appendable; public static final fun printExceptionStackTrace (Ljava/lang/Exception;)V } diff --git a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/businessmetrics/BusinessMetricsUtils.kt b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/businessmetrics/BusinessMetricsUtils.kt index 0600752a6e..13a88aea29 100644 --- a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/businessmetrics/BusinessMetricsUtils.kt +++ b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/businessmetrics/BusinessMetricsUtils.kt @@ -89,7 +89,11 @@ public enum class SmithyBusinessMetric(public override val identifier: String) : PROTOCOL_RPC_V2_CBOR("M"), SERVICE_ENDPOINT_OVERRIDE("N"), ACCOUNT_ID_BASED_ENDPOINT("O"), + ACCOUNT_ID_MODE_PREFERRED("P"), + ACCOUNT_ID_MODE_DISABLED("Q"), + ACCOUNT_ID_MODE_REQUIRED("R"), SIGV4A_SIGNING("S"), + RESOLVED_ACCOUNT_ID("T"), FLEXIBLE_CHECKSUMS_REQ_CRC32("U"), FLEXIBLE_CHECKSUMS_REQ_CRC32C("V"), FLEXIBLE_CHECKSUMS_REQ_SHA1("X"), diff --git a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/collections/CaseInsensitiveMap.kt b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/collections/CaseInsensitiveMap.kt index 096e1b6d3d..e888ad72bc 100644 --- a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/collections/CaseInsensitiveMap.kt +++ b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/collections/CaseInsensitiveMap.kt @@ -6,16 +6,6 @@ package aws.smithy.kotlin.runtime.collections import aws.smithy.kotlin.runtime.InternalApi -private class CaseInsensitiveString(val s: String) { - val hash: Int = s.lowercase().hashCode() - override fun hashCode(): Int = hash - override fun equals(other: Any?): Boolean = other is CaseInsensitiveString && other.s.equals(s, ignoreCase = true) - override fun toString(): String = s -} - -private fun String.toInsensitive(): CaseInsensitiveString = - CaseInsensitiveString(this) - /** * Map of case-insensitive [String] to [Value] */ @@ -30,17 +20,17 @@ internal class CaseInsensitiveMap : MutableMap { override fun containsValue(value: Value): Boolean = impl.containsValue(value) - override fun get(key: String): Value? = impl.get(key.toInsensitive()) + override fun get(key: String): Value? = impl[key.toInsensitive()] override fun isEmpty(): Boolean = impl.isEmpty() override val entries: MutableSet> get() = impl.entries.map { - Entry(it.key.s, it.value) + Entry(it.key.normalized, it.value) }.toMutableSet() override val keys: MutableSet - get() = impl.keys.map { it.s }.toMutableSet() + get() = CaseInsensitiveMutableStringSet(impl.keys) override val values: MutableCollection get() = impl.values @@ -57,6 +47,12 @@ internal class CaseInsensitiveMap : MutableMap { override fun remove(key: String): Value? = impl.remove(key.toInsensitive()) + override fun hashCode() = impl.hashCode() + + override fun equals(other: Any?) = other is CaseInsensitiveMap<*> && impl == other.impl + + override fun toString() = impl.toString() + private class Entry( override val key: Key, override var value: Value, @@ -67,7 +63,7 @@ internal class CaseInsensitiveMap : MutableMap { return value } - override fun hashCode(): Int = 17 * 31 + key!!.hashCode() + value!!.hashCode() + override fun hashCode(): Int = key.hashCode() xor value.hashCode() // Match JVM & K/N stdlib implementations override fun equals(other: Any?): Boolean { if (other == null || other !is Map.Entry<*, *>) return false diff --git a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/collections/CaseInsensitiveMutableStringSet.kt b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/collections/CaseInsensitiveMutableStringSet.kt new file mode 100644 index 0000000000..63924013c9 --- /dev/null +++ b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/collections/CaseInsensitiveMutableStringSet.kt @@ -0,0 +1,40 @@ +package aws.smithy.kotlin.runtime.collections + +internal class CaseInsensitiveMutableStringSet( + initialValues: Iterable = setOf(), +) : MutableSet { + private val delegate = initialValues.toMutableSet() + + override fun add(element: String) = delegate.add(element.toInsensitive()) + override fun clear() = delegate.clear() + override fun contains(element: String) = delegate.contains(element.toInsensitive()) + override fun containsAll(elements: Collection) = elements.all { it in this } + override fun equals(other: Any?) = other is CaseInsensitiveMutableStringSet && delegate == other.delegate + override fun hashCode() = delegate.hashCode() + override fun isEmpty() = delegate.isEmpty() + override fun remove(element: String) = delegate.remove(element.toInsensitive()) + override val size: Int get() = delegate.size + override fun toString() = delegate.toString() + + override fun addAll(elements: Collection) = + elements.fold(false) { modified, item -> add(item) || modified } + + override fun iterator() = object : MutableIterator { + val delegate = this@CaseInsensitiveMutableStringSet.delegate.iterator() + override fun hasNext() = delegate.hasNext() + override fun next() = delegate.next().normalized + override fun remove() = delegate.remove() + } + + override fun removeAll(elements: Collection) = + elements.fold(false) { modified, item -> remove(item) || modified } + + override fun retainAll(elements: Collection): Boolean { + val insensitiveElements = elements.map { it.toInsensitive() }.toSet() + val toRemove = delegate.filterNot { it in insensitiveElements } + return toRemove.fold(false) { modified, item -> delegate.remove(item) || modified } + } +} + +internal fun CaseInsensitiveMutableStringSet(initialValues: Iterable) = + CaseInsensitiveMutableStringSet(initialValues.map { it.toInsensitive() }) diff --git a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/collections/CaseInsensitiveString.kt b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/collections/CaseInsensitiveString.kt new file mode 100644 index 0000000000..e182ca813d --- /dev/null +++ b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/collections/CaseInsensitiveString.kt @@ -0,0 +1,11 @@ +package aws.smithy.kotlin.runtime.collections + +internal class CaseInsensitiveString(val original: String) { + val normalized = original.lowercase() + override fun hashCode() = normalized.hashCode() + override fun equals(other: Any?) = other is CaseInsensitiveString && normalized == other.normalized + override fun toString() = original +} + +internal fun String.toInsensitive(): CaseInsensitiveString = + CaseInsensitiveString(this) diff --git a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/collections/ValuesMap.kt b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/collections/ValuesMap.kt index 763fa21ece..efa7a70af3 100644 --- a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/collections/ValuesMap.kt +++ b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/collections/ValuesMap.kt @@ -89,15 +89,13 @@ public open class ValuesMapImpl( override fun isEmpty(): Boolean = values.isEmpty() - override fun equals(other: Any?): Boolean = - other is ValuesMap<*> && - caseInsensitiveName == other.caseInsensitiveName && - names().let { names -> - if (names.size != other.names().size) { - return false - } - names.all { getAll(it) == other.getAll(it) } - } + override fun equals(other: Any?): Boolean = when (other) { + is ValuesMapImpl<*> -> caseInsensitiveName == other.caseInsensitiveName && values == other.values + is ValuesMap<*> -> caseInsensitiveName == other.caseInsensitiveName && entries() == other.entries() + else -> false + } + + override fun hashCode(): Int = values.hashCode() private fun Map>.deepCopyValues(): Map> = mapValues { (_, v) -> v.toList() } } diff --git a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/hashing/Ecdsa.kt b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/hashing/Ecdsa.kt new file mode 100644 index 0000000000..ad58fc118b --- /dev/null +++ b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/hashing/Ecdsa.kt @@ -0,0 +1,10 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package aws.smithy.kotlin.runtime.hashing + +/** + * ECDSA on the SECP256R1 curve. + */ +public expect fun ecdsaSecp256r1(key: ByteArray, message: ByteArray): ByteArray diff --git a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/smoketests/SmokeTestsFunctions.kt b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/smoketests/SmokeTestsFunctions.kt index 682817193d..3da3bb8c23 100644 --- a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/smoketests/SmokeTestsFunctions.kt +++ b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/smoketests/SmokeTestsFunctions.kt @@ -15,7 +15,21 @@ public expect fun exitProcess(status: Int): Nothing * # at executors.JavaRunnerExecutor$Companion.main(JavaRunnerExecutor.kt:27) * # at executors.JavaRunnerExecutor.main(JavaRunnerExecutor.kt) */ +@Deprecated( + message = "No longer used, target for removal in 1.5", + replaceWith = ReplaceWith("println(exception.stackTraceToString().prependIndent(\"#\"))"), + level = DeprecationLevel.WARNING, +) public fun printExceptionStackTrace(exception: Exception): Unit = println(exception.stackTraceToString().split("\n").joinToString("\n") { "#$it" }) public class SmokeTestsException(message: String) : Exception(message) + +/** + * An [Appendable] which can be used for printing test results to the console + */ +public val DefaultPrinter: Appendable = object : Appendable { + override fun append(c: Char) = this.also { print(c) } + override fun append(csq: CharSequence?) = this.also { print(csq) } + override fun append(csq: CharSequence?, start: Int, end: Int) = this.also { print(csq?.subSequence(start, end)) } +} diff --git a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/util/SingleFlightGroup.kt b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/util/SingleFlightGroup.kt index 6234d20733..5f36f3a4ae 100644 --- a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/util/SingleFlightGroup.kt +++ b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/util/SingleFlightGroup.kt @@ -30,7 +30,7 @@ public class SingleFlightGroup { public suspend fun singleFlight(block: suspend () -> T): T { mu.lock() val job = inFlight - if (job != null) { + if (job?.isActive == true) { waitCount++ mu.unlock() diff --git a/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/collections/CaseInsensitiveMapTest.kt b/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/collections/CaseInsensitiveMapTest.kt index dcf919d52d..96fd83612b 100644 --- a/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/collections/CaseInsensitiveMapTest.kt +++ b/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/collections/CaseInsensitiveMapTest.kt @@ -6,6 +6,8 @@ package aws.smithy.kotlin.runtime.collections import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class CaseInsensitiveMapTest { @Test @@ -18,4 +20,84 @@ class CaseInsensitiveMapTest { assertEquals("json", map["content-type"]) assertEquals("json", map["CONTENT-TYPE"]) } + + @Test + fun testContains() { + val map = CaseInsensitiveMap() + map["A"] = "apple" + map["B"] = "banana" + map["C"] = "cherry" + + assertTrue("C" in map) + assertTrue("c" in map) + assertFalse("D" in map) + } + + @Test + fun testKeysContains() { + val map = CaseInsensitiveMap() + map["A"] = "apple" + map["B"] = "banana" + map["C"] = "cherry" + val keys = map.keys + + assertTrue("C" in keys) + assertTrue("c" in keys) + assertFalse("D" in keys) + } + + @Test + fun testEquality() { + val left = CaseInsensitiveMap() + left["A"] = "apple" + left["B"] = "banana" + left["C"] = "cherry" + + val right = CaseInsensitiveMap() + right["c"] = "cherry" + right["b"] = "banana" + right["a"] = "apple" + + assertEquals(left, right) + } + + @Test + fun testEntriesEquality() { + val left = CaseInsensitiveMap() + left["A"] = "apple" + left["B"] = "banana" + left["C"] = "cherry" + + val right = CaseInsensitiveMap() + right["c"] = "cherry" + right["b"] = "banana" + right["a"] = "apple" + + assertEquals(left.entries, right.entries) + } + + @Test + fun testEntriesEqualityWithNormalMap() { + val left = CaseInsensitiveMap() + left["A"] = "apple" + left["B"] = "banana" + left["C"] = "cherry" + + val right = mutableMapOf( + "c" to "cherry", + "b" to "banana", + "a" to "apple", + ) + + assertEquals(left.entries, right.entries) + } + + @Test + fun testToString() { + val map = CaseInsensitiveMap() + map["A"] = "apple" + map["B"] = "banana" + map["C"] = "cherry" + assertEquals("{A=apple, B=banana, C=cherry}", map.toString()) + } } diff --git a/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/collections/CaseInsensitiveMutableStringSetTest.kt b/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/collections/CaseInsensitiveMutableStringSetTest.kt new file mode 100644 index 0000000000..258ef4c7f3 --- /dev/null +++ b/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/collections/CaseInsensitiveMutableStringSetTest.kt @@ -0,0 +1,125 @@ +package aws.smithy.kotlin.runtime.collections + +import kotlin.test.* + +private val input = setOf("APPLE", "banana", "cHeRrY") +private val variations = (input + input.map { it.lowercase() } + input.map { it.uppercase() }) +private val disjoint = setOf("durIAN", "ELdeRBerRY", "FiG") +private val subset = input - "APPLE" +private val intersecting = subset + disjoint + +class CaseInsensitiveMutableStringSetTest { + private fun assertSize(size: Int, set: CaseInsensitiveMutableStringSet) { + assertEquals(size, set.size) + val emptyAsserter: (Boolean) -> Unit = if (size == 0) ::assertTrue else ::assertFalse + emptyAsserter(set.isEmpty()) + } + + @Test + fun testInitialization() { + val set = CaseInsensitiveMutableStringSet(input) + assertSize(3, set) + } + + @Test + fun testAdd() { + val set = CaseInsensitiveMutableStringSet(input) + set += "durIAN" + assertSize(4, set) + } + + @Test + fun testAddAll() { + val set = CaseInsensitiveMutableStringSet(input) + assertFalse(set.addAll(set)) + + val intersecting = input + "durian" + assertTrue(set.addAll(intersecting)) + assertSize(4, set) + } + + @Test + fun testClear() { + val set = CaseInsensitiveMutableStringSet(input) + set.clear() + assertSize(0, set) + } + + @Test + fun testContains() { + val set = CaseInsensitiveMutableStringSet(input) + variations.forEach { assertTrue("Set should contain element $it") { it in set } } + + assertFalse("durian" in set) + } + + @Test + fun testContainsAll() { + val set = CaseInsensitiveMutableStringSet(input) + assertTrue(set.containsAll(variations)) + + val intersecting = input + "durian" + assertFalse(set.containsAll(intersecting)) + } + + @Test + fun testEquality() { + val left = CaseInsensitiveMutableStringSet(input) + val right = CaseInsensitiveMutableStringSet(input) + assertEquals(left, right) + + left -= "apple" + assertNotEquals(left, right) + + right -= "ApPlE" + assertEquals(left, right) + } + + @Test + fun testIterator() { + val set = CaseInsensitiveMutableStringSet(input) + val iterator = set.iterator() + + assertTrue(iterator.hasNext()) + assertEquals("apple", iterator.next()) + + assertTrue(iterator.hasNext()) + assertEquals("banana", iterator.next()) + iterator.remove() + assertSize(2, set) + + assertTrue(iterator.hasNext()) + assertEquals("cherry", iterator.next()) + + assertFalse(iterator.hasNext()) + assertTrue(set.containsAll(input - "banana")) + } + + @Test + fun testRemove() { + val set = CaseInsensitiveMutableStringSet(input) + set -= "BANANA" + assertSize(2, set) + } + + @Test + fun testRemoveAll() { + val set = CaseInsensitiveMutableStringSet(input) + assertFalse(set.removeAll(disjoint)) + + assertTrue(set.removeAll(intersecting)) + assertSize(1, set) + assertTrue("apple" in set) + } + + @Test + fun testRetainAll() { + val set = CaseInsensitiveMutableStringSet(input) + assertFalse(set.retainAll(set)) + assertSize(3, set) + + assertTrue(set.retainAll(intersecting)) + assertSize(2, set) + assertTrue(set.containsAll(subset)) + } +} diff --git a/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/collections/CaseInsensitiveStringTest.kt b/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/collections/CaseInsensitiveStringTest.kt new file mode 100644 index 0000000000..f3a0c7b600 --- /dev/null +++ b/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/collections/CaseInsensitiveStringTest.kt @@ -0,0 +1,27 @@ +package aws.smithy.kotlin.runtime.collections + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class CaseInsensitiveStringTest { + @Test + fun testEquality() { + val left = "Banana".toInsensitive() + val right = "baNAna".toInsensitive() + assertEquals(left, right) + assertNotEquals("Banana", left) + assertNotEquals("baNAna", right) + + val nonMatching = "apple".toInsensitive() + assertNotEquals(nonMatching, left) + assertNotEquals(nonMatching, right) + } + + @Test + fun testProperties() { + val s = "BANANA".toInsensitive() + assertEquals("BANANA", s.original) + assertEquals("banana", s.normalized) + } +} diff --git a/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/util/SingleFlightGroupTest.kt b/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/util/SingleFlightGroupTest.kt index cfe9d626fb..f468889d7b 100644 --- a/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/util/SingleFlightGroupTest.kt +++ b/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/util/SingleFlightGroupTest.kt @@ -109,4 +109,14 @@ class SingleFlightGroupTest { } } } + + @Test + fun testSequential() = runTest { + val group = SingleFlightGroup() + val first = group.singleFlight { "Foo" } + assertEquals("Foo", first) + + val second = group.singleFlight { "Bar" } + assertEquals("Bar", second) + } } diff --git a/runtime/runtime-core/jvm/src/aws/smithy/kotlin/runtime/hashing/EcdsaJVM.kt b/runtime/runtime-core/jvm/src/aws/smithy/kotlin/runtime/hashing/EcdsaJVM.kt new file mode 100644 index 0000000000..2f2e86b139 --- /dev/null +++ b/runtime/runtime-core/jvm/src/aws/smithy/kotlin/runtime/hashing/EcdsaJVM.kt @@ -0,0 +1,37 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package aws.smithy.kotlin.runtime.hashing + +import aws.smithy.kotlin.runtime.content.BigInteger +import java.security.* +import java.security.interfaces.* +import java.security.spec.* + +/** + * ECDSA on the SECP256R1 curve. + */ +public actual fun ecdsaSecp256r1(key: ByteArray, message: ByteArray): ByteArray { + // Convert private key to BigInteger + val d = BigInteger(key) + + // Create key pair generator to get curve parameters + val keyGen = KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1")) + } + val params = (keyGen.generateKeyPair().private as ECPrivateKey).params + + // Create private key directly from the provided key bytes + val privateKeySpec = ECPrivateKeySpec(d.toJvm(), params) + val keyFactory = KeyFactory.getInstance("EC") + val privateKey = keyFactory.generatePrivate(privateKeySpec) + + // Sign the message + return Signature.getInstance("SHA256withECDSA").apply { + initSign(privateKey) + update(message) + }.sign() +} + +private fun BigInteger.toJvm(): java.math.BigInteger = java.math.BigInteger(1, toByteArray()) diff --git a/runtime/runtime-core/jvm/test/aws/smithy/kotlin/runtime/hashing/EcdsaJVMTest.kt b/runtime/runtime-core/jvm/test/aws/smithy/kotlin/runtime/hashing/EcdsaJVMTest.kt new file mode 100644 index 0000000000..0072d112d1 --- /dev/null +++ b/runtime/runtime-core/jvm/test/aws/smithy/kotlin/runtime/hashing/EcdsaJVMTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package aws.smithy.kotlin.runtime.hashing + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import java.security.* +import java.security.interfaces.* +import java.security.spec.* +import kotlin.test.Test + +class EcdsaJVMTest { + // Helper function to generate valid test key + private fun generateValidPrivateKey(): ByteArray { + val keyGen = KeyPairGenerator.getInstance("EC") + keyGen.initialize(ECGenParameterSpec("secp256r1")) + val keyPair = keyGen.generateKeyPair() + val privateKey = keyPair.private as ECPrivateKey + return privateKey.s.toByteArray() + } + + @Test + fun testValidSignature() { + val privateKey = generateValidPrivateKey() + val message = "Hello, World!".toByteArray() + + val signature = ecdsaSecp256r1(privateKey, message) + + assertTrue(signature.isNotEmpty()) + assertTrue(signature.size >= 64) // ECDSA signatures are typically 70-72 bytes in DER format + } + + @Test + fun testDifferentMessages() { + val privateKey = generateValidPrivateKey() + val message1 = "Hello, World!".toByteArray() + val message2 = "Different message".toByteArray() + + val signature1 = ecdsaSecp256r1(privateKey, message1) + val signature2 = ecdsaSecp256r1(privateKey, message2) + + assertTrue(signature1.isNotEmpty()) + assertTrue(signature2.isNotEmpty()) + assertFalse(signature1.contentEquals(signature2)) + } + + @Test + fun testEmptyMessage() { + val privateKey = generateValidPrivateKey() + val message = ByteArray(0) + + val signature = ecdsaSecp256r1(privateKey, message) + assertTrue(signature.isNotEmpty()) + } + + @Test + fun testLargeMessage() { + val privateKey = generateValidPrivateKey() + val largeMessage = ByteArray(1000000) { it.toByte() } + + val signature = ecdsaSecp256r1(privateKey, largeMessage) + assertTrue(signature.isNotEmpty()) + } + + @Test + fun testVerifySignature() { + val keyGen = KeyPairGenerator.getInstance("EC") + keyGen.initialize(ECGenParameterSpec("secp256r1")) + val keyPair = keyGen.generateKeyPair() + val privateKey = (keyPair.private as ECPrivateKey).s.toByteArray() + val publicKey = keyPair.public + + val message = "Hello, World!".toByteArray() + val signature = ecdsaSecp256r1(privateKey, message) + + val verifier = Signature.getInstance("SHA256withECDSA") + verifier.initVerify(publicKey) + verifier.update(message) + + assertTrue(verifier.verify(signature)) + } +} diff --git a/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/hashing/EcdsaNative.kt b/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/hashing/EcdsaNative.kt new file mode 100644 index 0000000000..a2bca1c4e6 --- /dev/null +++ b/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/hashing/EcdsaNative.kt @@ -0,0 +1,13 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package aws.smithy.kotlin.runtime.hashing + +// FIXME Implement using aws-c-cal: https://github.com/awslabs/aws-c-cal/blob/main/include/aws/cal/ecc.h +// Will need to be implemented and exposed in aws-crt-kotlin. Or maybe we can _only_ offer the CRT signer on Native? +// Will require updating DefaultAwsSigner to be expect/actual and set to CrtSigner on Native. +/** + * ECDSA on the SECP256R1 curve. + */ +public actual fun ecdsaSecp256r1(key: ByteArray, message: ByteArray): ByteArray = TODO("Not yet implemented") diff --git a/runtime/smithy-client/common/src/aws/smithy/kotlin/runtime/client/SdkClientOption.kt b/runtime/smithy-client/common/src/aws/smithy/kotlin/runtime/client/SdkClientOption.kt index 49045379b3..226ff8efbd 100644 --- a/runtime/smithy-client/common/src/aws/smithy/kotlin/runtime/client/SdkClientOption.kt +++ b/runtime/smithy-client/common/src/aws/smithy/kotlin/runtime/client/SdkClientOption.kt @@ -5,7 +5,6 @@ package aws.smithy.kotlin.runtime.client -import aws.smithy.kotlin.runtime.InternalApi import aws.smithy.kotlin.runtime.collections.AttributeKey import aws.smithy.kotlin.runtime.operation.ExecutionContext @@ -48,27 +47,23 @@ public object SdkClientOption { /** * Get the [IdempotencyTokenProvider] from the context. If one is not set the default will be returned. */ -@InternalApi public val ExecutionContext.idempotencyTokenProvider: IdempotencyTokenProvider get() = getOrNull(SdkClientOption.IdempotencyTokenProvider) ?: IdempotencyTokenProvider.Default /** * Get the [LogMode] from the context. If one is not set a default will be returned */ -@InternalApi public val ExecutionContext.logMode: LogMode get() = getOrNull(SdkClientOption.LogMode) ?: LogMode.Default /** * Get the name of the operation being invoked from the context. */ -@InternalApi public val ExecutionContext.operationName: String? get() = getOrNull(SdkClientOption.OperationName) /** * Get the name of the service being invoked from the context. */ -@InternalApi public val ExecutionContext.serviceName: String? get() = getOrNull(SdkClientOption.ServiceName) diff --git a/settings.gradle.kts b/settings.gradle.kts index 414df686a0..2c8d3df112 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,6 +26,40 @@ dependencyResolutionManagement { } } +// TODO This is largely shared with aws-sdk-kotlin, consider commonizing +// Set up a sibling directory aws-crt-kotlin as a composite build, if it exists. +// Allows overrides via local.properties: +// compositeProjects=~/repos/aws-crt-kotlin,/tmp/some/other/thing,../../another/project +val compositeProjectList = try { + val localProperties = java.util.Properties().also { + it.load(File(rootProject.projectDir, "local.properties").inputStream()) + } + val compositeProjects = localProperties.getProperty("compositeProjects") ?: "../aws-crt-kotlin" + + val compositeProjectPaths = compositeProjects.split(",") + .map { it.replaceFirst("^~".toRegex(), System.getProperty("user.home")) } // expand ~ to user's home directory + .filter { it.isNotBlank() } + .map { file(it) } + + compositeProjectPaths.also { + if (it.isNotEmpty()) { + println("Adding composite build projects from local.properties: ${compositeProjectPaths.joinToString { it.name }}") + } + } +} catch (_: Throwable) { + logger.error("Could not load composite project paths from local.properties") + listOf(file("../aws-crt-kotlin")) +} + +compositeProjectList.forEach { + if (it.exists()) { + println("Including build '$it'") + includeBuild(it) + } else { + println("Ignoring invalid build directory '$it'") + } +} + rootProject.name = "smithy-kotlin" include(":dokka-smithy") diff --git a/tests/codegen/waiter-tests/model/function-keys.smithy b/tests/codegen/waiter-tests/model/function-keys.smithy index c35989f345..f625ee0791 100644 --- a/tests/codegen/waiter-tests/model/function-keys.smithy +++ b/tests/codegen/waiter-tests/model/function-keys.smithy @@ -32,6 +32,20 @@ use smithy.waiters#waitable } ] }, + KeysFunctionMapStringEquals: { + acceptors: [ + { + state: "success", + matcher: { + output: { + path: "keys(maps.strings)", + expected: "key", + comparator: "anyStringEquals" + } + } + } + ] + }, ) @readonly @http(method: "GET", uri: "/keys/{name}", code: 200) diff --git a/tests/codegen/waiter-tests/src/test/kotlin/com/test/FunctionKeysTest.kt b/tests/codegen/waiter-tests/src/test/kotlin/com/test/FunctionKeysTest.kt index 26b11a9d1b..f39a193685 100644 --- a/tests/codegen/waiter-tests/src/test/kotlin/com/test/FunctionKeysTest.kt +++ b/tests/codegen/waiter-tests/src/test/kotlin/com/test/FunctionKeysTest.kt @@ -5,10 +5,12 @@ package com.test +import com.test.model.EntityMaps import com.test.model.EntityPrimitives import com.test.model.GetFunctionKeysEqualsRequest import com.test.model.GetFunctionKeysEqualsResponse import com.test.utils.successTest +import com.test.waiters.waitUntilKeysFunctionMapStringEquals import com.test.waiters.waitUntilKeysFunctionPrimitivesIntegerEquals import com.test.waiters.waitUntilKeysFunctionPrimitivesStringEquals import kotlin.test.Test @@ -27,4 +29,11 @@ class FunctionKeysTest { WaitersTestClient::waitUntilKeysFunctionPrimitivesIntegerEquals, GetFunctionKeysEqualsResponse { primitives = EntityPrimitives { } }, ) + + @Test + fun testKeysFunctionMapStringEquals() = successTest( + GetFunctionKeysEqualsRequest { name = "test" }, + WaitersTestClient::waitUntilKeysFunctionMapStringEquals, + GetFunctionKeysEqualsResponse { maps = EntityMaps { strings = mapOf("key" to "value") } }, + ) } diff --git a/tests/compile/src/test/kotlin/software/amazon/smithy/kotlin/codegen/SmithySdkTest.kt b/tests/compile/src/test/kotlin/software/amazon/smithy/kotlin/codegen/SmithySdkTest.kt index 8cad9fc623..a062d6c941 100644 --- a/tests/compile/src/test/kotlin/software/amazon/smithy/kotlin/codegen/SmithySdkTest.kt +++ b/tests/compile/src/test/kotlin/software/amazon/smithy/kotlin/codegen/SmithySdkTest.kt @@ -224,6 +224,107 @@ class SmithySdkTest { assertEquals(KotlinCompilation.ExitCode.OK, compilationResult.exitCode, compileOutputStream.toString()) } + + // https://github.com/smithy-lang/smithy-kotlin/issues/1129 + @Test + fun `it compiles models with unions with members that have the same name as the union`() { + val model = """ + namespace aws.sdk.kotlin.test + + use aws.protocols#awsJson1_0 + use smithy.rules#operationContextParams + use smithy.rules#endpointRuleSet + use aws.api#service + + @awsJson1_0 + @service(sdkId: "UnionOperationTest") + service TestService { + operations: [UnionOperation], + version: "1" + } + + operation UnionOperation { + input: UnionOperationRequest + } + + structure UnionOperationRequest { + Union: Foo + } + + union Foo { + foo: Boolean + } + + """.asSmithy() + + val compileOutputStream = ByteArrayOutputStream() + val compilationResult = compileSdkAndTest(model = model, outputSink = compileOutputStream, emitSourcesToTmp = Debug.emitSourcesToTemp) + compileOutputStream.flush() + + assertEquals(KotlinCompilation.ExitCode.OK, compilationResult.exitCode, compileOutputStream.toString()) + } + + // https://github.com/smithy-lang/smithy-kotlin/issues/1127 + @Test + fun `it compiles models with union member names that match their types`() { + val model = """ + namespace aws.sdk.kotlin.test + + use aws.protocols#awsJson1_0 + use smithy.rules#operationContextParams + use smithy.rules#endpointRuleSet + use aws.api#service + + @awsJson1_0 + @service(sdkId: "UnionOperationTest") + service TestService { + operations: [DeleteObjects], + version: "1" + } + + operation DeleteObjects { + input: DeleteObjectsRequest + } + + structure DeleteObjectsRequest { + Delete: Foo + } + + union Foo { + list: BarList + map: IntegerMap + instant: Timestamp + byteArray: Blob + boolean: Boolean + string: String + bigInteger: BigInteger + bigDecimal: BigDecimal + double: Double + float: Float + long: Long + short: Short + int: Integer + byte: Byte + } + + list BarList { + member: Bar + } + + map IntegerMap { + key: String + value: Integer + } + + string Bar + """.asSmithy() + + val compileOutputStream = ByteArrayOutputStream() + val compilationResult = compileSdkAndTest(model = model, outputSink = compileOutputStream, emitSourcesToTmp = Debug.emitSourcesToTemp) + compileOutputStream.flush() + + assertEquals(KotlinCompilation.ExitCode.OK, compilationResult.exitCode, compileOutputStream.toString()) + } } /**