From 758d4419c49737595fd6301c1ea3500ce26fe055 Mon Sep 17 00:00:00 2001 From: Arthur Weber Date: Mon, 11 Mar 2024 17:47:53 +0100 Subject: [PATCH] feat: Codegen clients (#38) * refactor: Reorder types * feat: Codegen clients * refactor: Regenerate IAM client * feat: add a choice what command must be tested, documentation * revert: back to iam CreateRoleCommand test --------- Co-authored-by: Victor Korzunin <5180700+floydspace@users.noreply.github.com> --- .projen/deps.json | 10 + .projenrc.ts | 5 + package.json | 7 +- packages/client-iam/src/Errors.ts | 64 +-- packages/client-iam/src/IAMService.ts | 6 +- packages/client-iam/test/IAM.test.ts | 150 ++---- pnpm-lock.yaml | 25 +- scripts/codegen-client.ts | 673 ++++++++++++++++++++++++++ 8 files changed, 788 insertions(+), 152 deletions(-) create mode 100644 scripts/codegen-client.ts diff --git a/.projen/deps.json b/.projen/deps.json index a1a872c2..a450faa5 100644 --- a/.projen/deps.json +++ b/.projen/deps.json @@ -120,6 +120,16 @@ { "name": "cdk-nag", "type": "runtime" + }, + { + "name": "effect", + "version": "^2.3.1", + "type": "runtime" + }, + { + "name": "enquirer", + "version": "^2.4.1", + "type": "runtime" } ], "//": "~~ Generated by projen. To modify, edit .projenrc.ts and run \"pnpm exec projen\"." diff --git a/.projenrc.ts b/.projenrc.ts index 2faee479..040a5f78 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -31,6 +31,11 @@ new Docgen(project); new Vitest(project); +project.addScripts({ + "codegen-client": "tsx ./scripts/codegen-client.ts", +}); +project.addDeps("effect@^2.3.1", "enquirer@^2.4.1"); + const commonDeps = ["@aws-sdk/types@^3"]; const commonDevDeps = ["aws-sdk-client-mock", "aws-sdk-client-mock-jest"]; const commonPeerDeps = ["effect@>=2.3.1 <2.5.0"]; diff --git a/package.json b/package.json index 727aca1e..0e7a32d6 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "watch": "pnpm exec projen watch", "install:ci": "pnpm exec projen install:ci", "synth-workspace": "pnpm exec projen", - "changeset": "changeset" + "changeset": "changeset", + "codegen-client": "tsx ./scripts/codegen-client.ts" }, "author": { "name": "Victor Korzunin", @@ -57,7 +58,9 @@ "dependencies": { "@aws-cdk/aws-cognito-identitypool-alpha": "latest", "aws-cdk-lib": "^2.126.0", - "cdk-nag": "^2.28.27" + "cdk-nag": "^2.28.27", + "effect": "^2.3.1", + "enquirer": "^2.4.1" }, "pnpm": { "overrides": { diff --git a/packages/client-iam/src/Errors.ts b/packages/client-iam/src/Errors.ts index 036d2106..488a9706 100644 --- a/packages/client-iam/src/Errors.ts +++ b/packages/client-iam/src/Errors.ts @@ -34,51 +34,51 @@ export type TaggedException = T & { readonly _tag: T["name"]; }; -export type InvalidInputError = TaggedException; -export type LimitExceededError = TaggedException; -export type NoSuchEntityError = TaggedException; -export type ServiceFailureError = TaggedException; -export type EntityAlreadyExistsError = - TaggedException; -export type UnmodifiableEntityError = - TaggedException; -export type PolicyNotAttachableError = - TaggedException; -export type EntityTemporarilyUnmodifiableError = - TaggedException; -export type InvalidUserTypeError = TaggedException; -export type PasswordPolicyViolationError = - TaggedException; export type ConcurrentModificationError = TaggedException; -export type MalformedPolicyDocumentError = - TaggedException; -export type ServiceNotSupportedError = - TaggedException; -export type DeleteConflictError = TaggedException; -export type InvalidAuthenticationCodeError = - TaggedException; -export type ReportGenerationLimitExceededError = - TaggedException; export type CredentialReportExpiredError = TaggedException; export type CredentialReportNotPresentError = TaggedException; export type CredentialReportNotReadyError = TaggedException; -export type UnrecognizedPublicKeyEncodingError = - TaggedException; -export type PolicyEvaluationError = TaggedException; -export type KeyPairMismatchError = TaggedException; -export type MalformedCertificateError = - TaggedException; +export type DeleteConflictError = TaggedException; export type DuplicateCertificateError = TaggedException; -export type InvalidCertificateError = - TaggedException; export type DuplicateSSHPublicKeyError = TaggedException; +export type EntityAlreadyExistsError = + TaggedException; +export type EntityTemporarilyUnmodifiableError = + TaggedException; +export type InvalidAuthenticationCodeError = + TaggedException; +export type InvalidCertificateError = + TaggedException; +export type InvalidInputError = TaggedException; export type InvalidPublicKeyError = TaggedException; +export type InvalidUserTypeError = TaggedException; +export type KeyPairMismatchError = TaggedException; +export type LimitExceededError = TaggedException; +export type MalformedCertificateError = + TaggedException; +export type MalformedPolicyDocumentError = + TaggedException; +export type NoSuchEntityError = TaggedException; +export type PasswordPolicyViolationError = + TaggedException; +export type PolicyEvaluationError = TaggedException; +export type PolicyNotAttachableError = + TaggedException; +export type ReportGenerationLimitExceededError = + TaggedException; +export type ServiceFailureError = TaggedException; +export type ServiceNotSupportedError = + TaggedException; +export type UnmodifiableEntityError = + TaggedException; +export type UnrecognizedPublicKeyEncodingError = + TaggedException; export type IAMServiceError = TaggedException< IAMServiceException & { name: "IAMServiceError" } diff --git a/packages/client-iam/src/IAMService.ts b/packages/client-iam/src/IAMService.ts index 5f8d5af9..4c3f49dc 100644 --- a/packages/client-iam/src/IAMService.ts +++ b/packages/client-iam/src/IAMService.ts @@ -484,6 +484,7 @@ import { import { type HttpHandlerOptions as __HttpHandlerOptions } from "@aws-sdk/types"; import { Context, Effect, Layer, ReadonlyRecord, Data } from "effect"; import { + IAMServiceError, ConcurrentModificationError, CredentialReportExpiredError, CredentialReportNotPresentError, @@ -493,7 +494,6 @@ import { DuplicateSSHPublicKeyError, EntityAlreadyExistsError, EntityTemporarilyUnmodifiableError, - IAMServiceError, InvalidAuthenticationCodeError, InvalidCertificateError, InvalidInputError, @@ -508,12 +508,12 @@ import { PolicyEvaluationError, PolicyNotAttachableError, ReportGenerationLimitExceededError, - SdkError, ServiceFailureError, ServiceNotSupportedError, - TaggedException, UnmodifiableEntityError, UnrecognizedPublicKeyEncodingError, + SdkError, + TaggedException, } from "./Errors"; import { IAMClientInstance, IAMClientInstanceLayer } from "./IAMClientInstance"; import { DefaultIAMClientConfigLayer } from "./IAMClientInstanceConfig"; diff --git a/packages/client-iam/test/IAM.test.ts b/packages/client-iam/test/IAM.test.ts index b8b1b251..161b6898 100644 --- a/packages/client-iam/test/IAM.test.ts +++ b/packages/client-iam/test/IAM.test.ts @@ -1,7 +1,7 @@ import { + type CreateRoleCommandInput, CreateRoleCommand, IAMClient, - CreateRoleCommandInput, } from "@aws-sdk/client-iam"; import { mockClient } from "aws-sdk-client-mock"; import * as Effect from "effect/Effect"; @@ -21,29 +21,17 @@ import { import "aws-sdk-client-mock-jest"; -const iamMock = mockClient(IAMClient); -const { createRole } = Effect.serviceFunctions(IAMService); +const clientMock = mockClient(IAMClient); describe("IAMClientImpl", () => { it("default", async () => { - iamMock.reset().on(CreateRoleCommand).resolves({}); - - const args: CreateRoleCommandInput = { - RoleName: "test", - AssumeRolePolicyDocument: JSON.stringify({ - Version: "2012-10-17", - Statement: [ - { - Sid: "Statement1", - Effect: "Allow", - Principal: {}, - Action: "sts:AssumeRole", - }, - ], - }), - }; - - const program = createRole(args); + clientMock.reset().on(CreateRoleCommand).resolves({}); + + const args = {} as unknown as CreateRoleCommandInput; + + const program = Effect.flatMap(IAMService, (service) => + service.createRole(args), + ); const result = await pipe( program, @@ -52,29 +40,18 @@ describe("IAMClientImpl", () => { ); expect(result).toEqual(Exit.succeed({})); - expect(iamMock).toHaveReceivedCommandTimes(CreateRoleCommand, 1); - expect(iamMock).toHaveReceivedCommandWith(CreateRoleCommand, args); + expect(clientMock).toHaveReceivedCommandTimes(CreateRoleCommand, 1); + expect(clientMock).toHaveReceivedCommandWith(CreateRoleCommand, args); }); it("configurable", async () => { - iamMock.reset().on(CreateRoleCommand).resolves({}); - - const args: CreateRoleCommandInput = { - RoleName: "test", - AssumeRolePolicyDocument: JSON.stringify({ - Version: "2012-10-17", - Statement: [ - { - Sid: "Statement1", - Effect: "Allow", - Principal: {}, - Action: "sts:AssumeRole", - }, - ], - }), - }; - - const program = createRole(args); + clientMock.reset().on(CreateRoleCommand).resolves({}); + + const args = {} as unknown as CreateRoleCommandInput; + + const program = Effect.flatMap(IAMService, (service) => + service.createRole(args), + ); const IAMClientConfigLayer = Layer.succeed(IAMClientInstanceConfig, { region: "eu-central-1", @@ -90,29 +67,18 @@ describe("IAMClientImpl", () => { ); expect(result).toEqual(Exit.succeed({})); - expect(iamMock).toHaveReceivedCommandTimes(CreateRoleCommand, 1); - expect(iamMock).toHaveReceivedCommandWith(CreateRoleCommand, args); + expect(clientMock).toHaveReceivedCommandTimes(CreateRoleCommand, 1); + expect(clientMock).toHaveReceivedCommandWith(CreateRoleCommand, args); }); it("base", async () => { - iamMock.reset().on(CreateRoleCommand).resolves({}); - - const args: CreateRoleCommandInput = { - RoleName: "test", - AssumeRolePolicyDocument: JSON.stringify({ - Version: "2012-10-17", - Statement: [ - { - Sid: "Statement1", - Effect: "Allow", - Principal: {}, - Action: "sts:AssumeRole", - }, - ], - }), - }; - - const program = createRole(args); + clientMock.reset().on(CreateRoleCommand).resolves({}); + + const args = {} as unknown as CreateRoleCommandInput; + + const program = Effect.flatMap(IAMService, (service) => + service.createRole(args), + ); const IAMClientInstanceLayer = Layer.succeed( IAMClientInstance, @@ -129,29 +95,18 @@ describe("IAMClientImpl", () => { ); expect(result).toEqual(Exit.succeed({})); - expect(iamMock).toHaveReceivedCommandTimes(CreateRoleCommand, 1); - expect(iamMock).toHaveReceivedCommandWith(CreateRoleCommand, args); + expect(clientMock).toHaveReceivedCommandTimes(CreateRoleCommand, 1); + expect(clientMock).toHaveReceivedCommandWith(CreateRoleCommand, args); }); it("extended", async () => { - iamMock.reset().on(CreateRoleCommand).resolves({}); - - const args: CreateRoleCommandInput = { - RoleName: "test", - AssumeRolePolicyDocument: JSON.stringify({ - Version: "2012-10-17", - Statement: [ - { - Sid: "Statement1", - Effect: "Allow", - Principal: {}, - Action: "sts:AssumeRole", - }, - ], - }), - }; - - const program = createRole(args); + clientMock.reset().on(CreateRoleCommand).resolves({}); + + const args = {} as unknown as CreateRoleCommandInput; + + const program = Effect.flatMap(IAMService, (service) => + service.createRole(args), + ); const IAMClientInstanceLayer = Layer.effect( IAMClientInstance, @@ -172,29 +127,18 @@ describe("IAMClientImpl", () => { ); expect(result).toEqual(Exit.succeed({})); - expect(iamMock).toHaveReceivedCommandTimes(CreateRoleCommand, 1); - expect(iamMock).toHaveReceivedCommandWith(CreateRoleCommand, args); + expect(clientMock).toHaveReceivedCommandTimes(CreateRoleCommand, 1); + expect(clientMock).toHaveReceivedCommandWith(CreateRoleCommand, args); }); it("fail", async () => { - iamMock.reset().on(CreateRoleCommand).rejects(new Error("test")); - - const args: CreateRoleCommandInput = { - RoleName: "test", - AssumeRolePolicyDocument: JSON.stringify({ - Version: "2012-10-17", - Statement: [ - { - Sid: "Statement1", - Effect: "Allow", - Principal: {}, - Action: "sts:AssumeRole", - }, - ], - }), - }; - - const program = createRole(args, { requestTimeout: 1000 }); + clientMock.reset().on(CreateRoleCommand).rejects(new Error("test")); + + const args = {} as unknown as CreateRoleCommandInput; + + const program = Effect.flatMap(IAMService, (service) => + service.createRole(args), + ); const result = await pipe( program, @@ -212,7 +156,7 @@ describe("IAMClientImpl", () => { }), ), ); - expect(iamMock).toHaveReceivedCommandTimes(CreateRoleCommand, 1); - expect(iamMock).toHaveReceivedCommandWith(CreateRoleCommand, args); + expect(clientMock).toHaveReceivedCommandTimes(CreateRoleCommand, 1); + expect(clientMock).toHaveReceivedCommandWith(CreateRoleCommand, args); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 123859a5..2e54584b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,17 +15,23 @@ importers: dependencies: '@aws-cdk/aws-cognito-identitypool-alpha': specifier: latest - version: 2.130.0-alpha.0(aws-cdk-lib@2.126.0)(constructs@10.3.0) + version: 2.131.0-alpha.0(aws-cdk-lib@2.126.0)(constructs@10.3.0) aws-cdk-lib: specifier: ^2.126.0 version: 2.126.0(constructs@10.3.0) cdk-nag: specifier: ^2.28.27 version: 2.28.27(aws-cdk-lib@2.126.0)(constructs@10.3.0) + effect: + specifier: ^2.3.1 + version: 2.3.1 + enquirer: + specifier: ^2.4.1 + version: 2.4.1 devDependencies: '@aws/pdk': specifier: ^0 - version: 0.23.2(@aws-cdk/aws-cognito-identitypool-alpha@2.130.0-alpha.0)(aws-cdk-lib@2.126.0)(cdk-nag@2.28.27)(constructs@10.3.0)(projen@0.79.7) + version: 0.23.2(@aws-cdk/aws-cognito-identitypool-alpha@2.131.0-alpha.0)(aws-cdk-lib@2.126.0)(cdk-nag@2.28.27)(constructs@10.3.0)(projen@0.79.7) '@changesets/changelog-github': specifier: ^0.4.8 version: 0.4.8 @@ -808,11 +814,11 @@ packages: /@aws-cdk/asset-node-proxy-agent-v6@2.0.1: resolution: {integrity: sha512-DDt4SLdLOwWCjGtltH4VCST7hpOI5DzieuhGZsBpZ+AgJdSI2GCjklCXm0GCTwJG/SolkL5dtQXyUKgg9luBDg==} - /@aws-cdk/aws-cognito-identitypool-alpha@2.130.0-alpha.0(aws-cdk-lib@2.126.0)(constructs@10.3.0): - resolution: {integrity: sha512-xqsoJC2AAWQe/v5S4SCit5cU5ZlDy+OkNgEtHhckIoV3m6otosWCdxihIvajNMnr81lGe8Qo3BidK0/HFfpQ8A==} + /@aws-cdk/aws-cognito-identitypool-alpha@2.131.0-alpha.0(aws-cdk-lib@2.126.0)(constructs@10.3.0): + resolution: {integrity: sha512-8dXyElW94uuSDbP2ERp90BkQ9qDuXW38p+ArjSEg/zzQo8PGFq6UMeAigBfgygPoP2N72CRZhaM8nGdtI9bv/A==} engines: {node: '>= 14.15.0'} peerDependencies: - aws-cdk-lib: ^2.130.0 + aws-cdk-lib: ^2.131.0 constructs: ^10.0.0 dependencies: aws-cdk-lib: 2.126.0(constructs@10.3.0) @@ -1933,7 +1939,7 @@ packages: tslib: 2.6.2 dev: false - /@aws/pdk@0.23.2(@aws-cdk/aws-cognito-identitypool-alpha@2.130.0-alpha.0)(aws-cdk-lib@2.126.0)(cdk-nag@2.28.27)(constructs@10.3.0)(projen@0.79.7): + /@aws/pdk@0.23.2(@aws-cdk/aws-cognito-identitypool-alpha@2.131.0-alpha.0)(aws-cdk-lib@2.126.0)(cdk-nag@2.28.27)(constructs@10.3.0)(projen@0.79.7): resolution: {integrity: sha512-TFQKuuIVrjrR9yvWSJ2C8H5rU52m/HVNjD4E2BkUOTaD3rUl9PobPotBuhAAsAJsg0YQGuFprPElrfB+KyxJoA==} hasBin: true peerDependencies: @@ -1943,7 +1949,7 @@ packages: constructs: ^10.3.0 projen: ^0.79.6 dependencies: - '@aws-cdk/aws-cognito-identitypool-alpha': 2.130.0-alpha.0(aws-cdk-lib@2.126.0)(constructs@10.3.0) + '@aws-cdk/aws-cognito-identitypool-alpha': 2.131.0-alpha.0(aws-cdk-lib@2.126.0)(constructs@10.3.0) aws-cdk-lib: 2.126.0(constructs@10.3.0) cdk-nag: 2.28.27(aws-cdk-lib@2.126.0)(constructs@10.3.0) constructs: 10.3.0 @@ -3651,12 +3657,10 @@ packages: /ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} - dev: true /ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - dev: true /ansi-regex@6.0.1: resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} @@ -4325,7 +4329,6 @@ packages: /effect@2.3.1: resolution: {integrity: sha512-bOmhWIt3WjccP2DXan8eJmIqjVXwan0hj+Y6BHJOauHt8HNEuQndp6D7bAUsUsjPoKGCU70J9sMeT5QEVYlgbQ==} - dev: true /emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -4362,7 +4365,6 @@ packages: dependencies: ansi-colors: 4.1.3 strip-ansi: 6.0.1 - dev: true /error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} @@ -6970,7 +6972,6 @@ packages: engines: {node: '>=8'} dependencies: ansi-regex: 5.0.1 - dev: true /strip-ansi@7.1.0: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} diff --git a/scripts/codegen-client.ts b/scripts/codegen-client.ts new file mode 100644 index 00000000..2480a377 --- /dev/null +++ b/scripts/codegen-client.ts @@ -0,0 +1,673 @@ +/** + * How to use: + * + * 1. Define a new package in `.projenrc.ts` (the package must have the same name as the AWS client) and run `pnpm run synth-workspace`. + * 2. Run `pnpm run codegen-client`, select the package to generate. + * 3. Run `Run pnpm run eslint --fix` to fix the formatting. + * 4. Commit the changes and enjoy. + */ +import { mkdir, readdir, writeFile } from "node:fs/promises"; + +import { + Effect, + Option, + ReadonlyArray, + ReadonlyRecord, + String, + Struct, + Tuple, +} from "effect"; +import { flow, pipe } from "effect/Function"; +import { isNotUndefined } from "effect/Predicate"; +import Enquirer from "enquirer"; + +type Shape = + | { type: "boolean" } + | { type: "integer" } + | { type: "double" } + | { type: "string" } + | { type: "timestamp" } + | { type: "enum" } + | { type: "list" } + | { + type: "operation"; + errors: { target: string }[]; + } + | { + type: "service"; + operations: { target: string }[]; + traits: { + "aws.api#service": { + sdkId: string; + }; + }; + } + | { type: "structure" }; + +interface Manifest { + shapes: Record; +} + +main().catch(console.error); + +async function main() { + const enquirer = new Enquirer<{ + services: string[]; + commandToTest: string; + }>(); + + const { services } = await enquirer.prompt({ + type: "autocomplete", + name: "services", + message: "Which clients do you want to generate ?", + multiple: true, + choices: (await readdir("./packages")).filter((s) => + s.startsWith("client-"), + ), + }); + + const each = services.map((packageName) => + Effect.promise(async () => { + const serviceName = pipe(packageName, String.replace(/^client-/, "")); + + const manifest = (await ( + await fetch( + `https://raw.githubusercontent.com/aws/aws-sdk-js-v3/main/codegen/sdk-codegen/aws-models/${serviceName}.json`, + ) + ).json()) as Manifest; + + const serviceShape = pipe( + manifest.shapes, + ReadonlyRecord.values, + ReadonlyArray.findFirst( + (shape): shape is Extract => + shape.type === "service", + ), + Option.getOrThrowWith(() => new TypeError("ServiceShape is not found")), + ); + + const operationTargets = pipe( + serviceShape.operations, + ReadonlyArray.map(({ target }) => target), + ); + + const operationNames = pipe( + operationTargets, + ReadonlyArray.map(getNameFromTarget), + ); + + const { commandToTest } = await enquirer.prompt({ + type: "autocomplete", + name: "commandToTest", + message: `Which command do you want to test in ${packageName} ?`, + multiple: false, + choices: operationNames, + }); + + return [packageName, commandToTest] as const; + }), + ); + + const results = await Effect.runPromise(Effect.all(each, { concurrency: 1 })); + + return Promise.all(results.map(generateClient)); +} + +const getNameFromTarget = flow( + String.split("#"), + ReadonlyArray.get(1), + Option.getOrThrow, +); + +const lowerFirst = flow( + ReadonlyArray.modify(0, String.toLowerCase), + ReadonlyArray.join(""), +); + +async function generateClient([packageName, commandToTest]: readonly [ + string, + string, +]) { + const serviceName = pipe(packageName, String.replace(/^client-/, "")); + + const manifest = (await ( + await fetch( + `https://raw.githubusercontent.com/aws/aws-sdk-js-v3/main/codegen/sdk-codegen/aws-models/${serviceName}.json`, + ) + ).json()) as Manifest; + + const serviceShape = pipe( + manifest.shapes, + ReadonlyRecord.values, + ReadonlyArray.findFirst( + (shape): shape is Extract => + shape.type === "service", + ), + Option.getOrThrowWith(() => new TypeError("ServiceShape is not found")), + ); + + const { sdkId } = serviceShape.traits["aws.api#service"]; + + const awsClient = await import( + `../packages/client-${serviceName}/node_modules/@aws-sdk/client-${serviceName}/dist-cjs/index.js` + ); + + const exceptions = pipe( + awsClient, + ReadonlyRecord.keys, + ReadonlyArray.filter((s) => s.endsWith("Exception")), + ); + + const exportedErrors = pipe( + awsClient, + ReadonlyRecord.keys, + ReadonlyArray.filter(String.endsWith("Exception")), + ReadonlyArray.map(String.replace(/Exception$/, "")), + ); + const serviceException = `${sdkId}Service`; + const taggedErrors = pipe( + exportedErrors, + ReadonlyArray.filter((s) => s !== serviceException), + ); + + await writeFile( + `./packages/client-${serviceName}/src/Errors.ts`, + `import type { ${exceptions.join(", ")} } from "@aws-sdk/client-${serviceName}"; +import * as Data from "effect/Data"; + +export type TaggedException = T & { + readonly _tag: T["name"]; +}; + +${pipe( + taggedErrors, + ReadonlyArray.map( + (taggedError) => + `export type ${taggedError}Error = TaggedException<${taggedError}Exception>;`, + ), + ReadonlyArray.join("\n"), +)} + +export type ${serviceException}Error = TaggedException< + ${serviceException}Exception & { name: "${serviceException}Error" } +>; +export const ${serviceException}Error = Data.tagged<${serviceException}Error>("${serviceException}Error"); +export type SdkError = TaggedException; +export const SdkError = Data.tagged("SdkError"); +`, + ); + + await writeFile( + `./packages/client-${serviceName}/src/${sdkId}ClientInstance.ts`, + `/** + * @since 1.0.0 + */ +import { ${sdkId}Client } from "@aws-sdk/client-${serviceName}"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { + Default${sdkId}ClientConfigLayer, + ${sdkId}ClientInstanceConfig, +} from "./${sdkId}ClientInstanceConfig"; + +/** + * @since 1.0.0 + * @category tags + */ +export class ${sdkId}ClientInstance extends Context.Tag( + "@effect-aws/client-${serviceName}/${sdkId}ClientInstance", +)<${sdkId}ClientInstance, ${sdkId}Client>() {} + +/** + * @since 1.0.0 + * @category constructors + */ +export const make${sdkId}ClientInstance = Effect.map( + ${sdkId}ClientInstanceConfig, + (config) => new ${sdkId}Client(config), +); + +/** + * @since 1.0.0 + * @category layers + */ +export const ${sdkId}ClientInstanceLayer = Layer.effect( + ${sdkId}ClientInstance, + make${sdkId}ClientInstance, +); + +/** + * @since 1.0.0 + * @category layers + */ +export const Default${sdkId}ClientInstanceLayer = ${sdkId}ClientInstanceLayer.pipe( + Layer.provide(Default${sdkId}ClientConfigLayer), +); +`, + ); + + await writeFile( + `./packages/client-${serviceName}/src/${sdkId}ClientInstanceConfig.ts`, + `/** + * @since 1.0.0 + */ +import type { ${sdkId}ClientConfig } from "@aws-sdk/client-${serviceName}"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Runtime from "effect/Runtime"; + +/** + * @since 1.0.0 + * @category tags + */ +export class ${sdkId}ClientInstanceConfig extends Context.Tag( + "@effect-aws/client-${serviceName}/${sdkId}ClientInstanceConfig", +)<${sdkId}ClientInstanceConfig, ${sdkId}ClientConfig>() {} + +/** + * @since 1.0.0 + * @category constructors + */ +export const makeDefault${sdkId}ClientInstanceConfig: Effect.Effect<${sdkId}ClientConfig> = + Effect.gen(function* (_) { + const runtime = yield* _(Effect.runtime()); + const runSync = Runtime.runSync(runtime); + + return { + logger: { + info(m) { + Effect.logInfo(m).pipe(runSync); + }, + warn(m) { + Effect.logWarning(m).pipe(runSync); + }, + error(m) { + Effect.logError(m).pipe(runSync); + }, + debug(m) { + Effect.logDebug(m).pipe(runSync); + }, + trace(m) { + Effect.logTrace(m).pipe(runSync); + }, + }, + }; + }); + +/** + * @since 1.0.0 + * @category layers + */ +export const Default${sdkId}ClientConfigLayer = Layer.effect( + ${sdkId}ClientInstanceConfig, + makeDefault${sdkId}ClientInstanceConfig, +); +`, + ); + + await writeFile( + `./packages/client-${serviceName}/src/index.ts`, + `export * from "./Errors"; +export * from "./${sdkId}ClientInstance"; +export * from "./${sdkId}ClientInstanceConfig"; +export * from "./${sdkId}Service"; +`, + ); + const operationTargets = pipe( + serviceShape.operations, + ReadonlyArray.map(({ target }) => target), + ); + const operationShapes = pipe( + manifest.shapes, + ReadonlyRecord.filter( + (shape): shape is Extract => + shape.type === "operation", + ), + Struct.pick(...operationTargets), + ReadonlyRecord.filter(isNotUndefined), + ReadonlyRecord.mapKeys(getNameFromTarget), + ReadonlyRecord.toEntries, + ); + + const operationNames = pipe( + operationTargets, + ReadonlyArray.map(getNameFromTarget), + ); + + const importedErrors = pipe( + operationShapes, + ReadonlyArray.map(Tuple.getSecond), + ReadonlyArray.filter( + (shape): shape is Extract => + shape.type === "operation", + ), + ReadonlyArray.flatMap(({ errors }) => errors ?? []), + ReadonlyArray.map( + flow( + ({ target }) => target, + getNameFromTarget, + String.replace(/Exception$/, ""), + ), + ), + ReadonlyArray.dedupe, + ReadonlyArray.sort(String.Order), + ReadonlyArray.intersection(exportedErrors), + ); + + await writeFile( + `./packages/client-${serviceName}/src/${sdkId}Service.ts`, + `/** + * @since 1.0.0 + */ +import { + ${sdkId}ServiceException, + ${pipe( + operationNames, + ReadonlyArray.map( + (name) => `${name}Command, + type ${name}CommandInput, + type ${name}CommandOutput,`, + ), + ReadonlyArray.join("\n "), + )} +} from "@aws-sdk/client-${serviceName}"; +import { type HttpHandlerOptions as __HttpHandlerOptions } from "@aws-sdk/types"; +import { Context, Effect, Layer, ReadonlyRecord, Data } from "effect"; +import { + ${sdkId}ClientInstance, + ${sdkId}ClientInstanceLayer, +} from "./${sdkId}ClientInstance"; +import { Default${sdkId}ClientConfigLayer } from "./${sdkId}ClientInstanceConfig"; +import { + ${pipe( + importedErrors, + ReadonlyArray.map((error) => `${error}Error`), + ReadonlyArray.prepend(`${sdkId}ServiceError`), + ReadonlyArray.join(","), + )}, + SdkError, + TaggedException, +} from "./Errors"; + +const commands = { + ${pipe( + operationNames, + ReadonlyArray.map((name) => `${name}Command`), + )} +}; + +/** + * @since 1.0.0 + * @category models + */ +export type ${sdkId}Service = { + readonly _: unique symbol; + +${pipe( + operationShapes, + ReadonlyArray.map(([operationName, operationShape]) => { + const errors = pipe( + operationShape.errors || [], + ReadonlyArray.map( + flow( + Struct.get("target"), + getNameFromTarget, + String.replace(/Exception$/, ""), + ), + ), + ReadonlyArray.intersection(importedErrors), + ReadonlyArray.map((error) => `${error}Error`), + ); + return ` /** + * @see {@link ${operationName}Command} + */ + readonly ${pipe(operationName, lowerFirst)}: ( + args: ${operationName}CommandInput, + options?: __HttpHandlerOptions, + ) => Effect.Effect< + ${operationName}CommandOutput, + ${pipe(["SdkError", `${sdkId}ServiceError`, ...errors], ReadonlyArray.join(" | "))} + >`; + }), + ReadonlyArray.join("\n\n"), +)} +}; + +/** + * @since 1.0.0 + * @category tags + */ +export const ${sdkId}Service = Context.GenericTag<${sdkId}Service>( + "@effect-aws/client-${serviceName}/${sdkId}Service", +); + +/** + * @since 1.0.0 + * @category constructors + */ +export const make${sdkId}Service = Effect.gen(function* (_) { + const client = yield* _(${sdkId}ClientInstance); + + return ReadonlyRecord.toEntries(commands).reduce((acc, [command]) => { + const CommandCtor = commands[command] as any; + const methodImpl = (args: any, options: any) => + Effect.tryPromise({ + try: () => client.send(new CommandCtor(args), options ?? {}), + catch: (e) => { + if (e instanceof ${sdkId}ServiceException) { + const ServiceException = Data.tagged< + TaggedException<${sdkId}ServiceException> + >(e.name); + + return ServiceException({ + ...e, + message: e.message, + stack: e.stack, + }); + } + if (e instanceof Error) { + return SdkError({ + ...e, + name: "SdkError", + message: e.message, + stack: e.stack, + }); + } + throw e; + }, + }); + const methodName = (command[0].toLowerCase() + command.slice(1)).replace( + /Command$/, + "", + ); + return { ...acc, [methodName]: methodImpl }; + }, {}) as ${sdkId}Service; +}); + +/** + * @since 1.0.0 + * @category layers + */ +export const Base${sdkId}ServiceLayer = Layer.effect( + ${sdkId}Service, + make${sdkId}Service, +); + +/** + * @since 1.0.0 + * @category layers + */ +export const ${sdkId}ServiceLayer = Base${sdkId}ServiceLayer.pipe( + Layer.provide(${sdkId}ClientInstanceLayer), +); + +/** + * @since 1.0.0 + * @category layers + */ +export const Default${sdkId}ServiceLayer = ${sdkId}ServiceLayer.pipe( + Layer.provide(Default${sdkId}ClientConfigLayer), +); +`, + ); + + await mkdir(`./packages/client-${serviceName}/test`, { recursive: true }); + await writeFile( + `./packages/client-${serviceName}/test/${sdkId}.test.ts`, + `import { + type ${commandToTest}CommandInput, + ${commandToTest}Command, + ${sdkId}Client, +} from "@aws-sdk/client-${serviceName}"; +import { mockClient } from "aws-sdk-client-mock"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import { pipe } from "effect/Function"; +import * as Layer from "effect/Layer"; +import { + Base${sdkId}ServiceLayer, + Default${sdkId}ClientConfigLayer, + Default${sdkId}ServiceLayer, + ${sdkId}ClientInstance, + ${sdkId}ClientInstanceConfig, + ${sdkId}Service, + ${sdkId}ServiceLayer, + SdkError, +} from "../src"; + +import "aws-sdk-client-mock-jest"; + +const clientMock = mockClient(${sdkId}Client); + +describe("${sdkId}ClientImpl", () => { + it("default", async () => { + clientMock.reset().on(${commandToTest}Command).resolves({}); + + const args = {} as unknown as ${commandToTest}CommandInput; + + const program = Effect.flatMap(${sdkId}Service, (service) => service.${pipe(commandToTest, lowerFirst)}(args)); + + const result = await pipe( + program, + Effect.provide(Default${sdkId}ServiceLayer), + Effect.runPromiseExit, + ); + + expect(result).toEqual(Exit.succeed({})); + expect(clientMock).toHaveReceivedCommandTimes(${commandToTest}Command, 1); + expect(clientMock).toHaveReceivedCommandWith(${commandToTest}Command, args); + }); + + it("configurable", async () => { + clientMock.reset().on(${commandToTest}Command).resolves({}); + + const args = {} as unknown as ${commandToTest}CommandInput; + + const program = Effect.flatMap(${sdkId}Service, (service) => service.${pipe(commandToTest, lowerFirst)}(args)); + + const ${sdkId}ClientConfigLayer = Layer.succeed(${sdkId}ClientInstanceConfig, { + region: "eu-central-1", + }); + const Custom${sdkId}ServiceLayer = ${sdkId}ServiceLayer.pipe( + Layer.provide(${sdkId}ClientConfigLayer), + ); + + const result = await pipe( + program, + Effect.provide(Custom${sdkId}ServiceLayer), + Effect.runPromiseExit, + ); + + expect(result).toEqual(Exit.succeed({})); + expect(clientMock).toHaveReceivedCommandTimes(${commandToTest}Command, 1); + expect(clientMock).toHaveReceivedCommandWith(${commandToTest}Command, args); + }); + + it("base", async () => { + clientMock.reset().on(${commandToTest}Command).resolves({}); + + const args = {} as unknown as ${commandToTest}CommandInput; + + const program = Effect.flatMap(${sdkId}Service, (service) => service.${pipe(commandToTest, lowerFirst)}(args)); + + const ${sdkId}ClientInstanceLayer = Layer.succeed( + ${sdkId}ClientInstance, + new ${sdkId}Client({ region: "eu-central-1" }), + ); + const Custom${sdkId}ServiceLayer = Base${sdkId}ServiceLayer.pipe( + Layer.provide(${sdkId}ClientInstanceLayer), + ); + + const result = await pipe( + program, + Effect.provide(Custom${sdkId}ServiceLayer), + Effect.runPromiseExit, + ); + + expect(result).toEqual(Exit.succeed({})); + expect(clientMock).toHaveReceivedCommandTimes(${commandToTest}Command, 1); + expect(clientMock).toHaveReceivedCommandWith(${commandToTest}Command, args); + }); + + it("extended", async () => { + clientMock.reset().on(${commandToTest}Command).resolves({}); + + const args = {} as unknown as ${commandToTest}CommandInput; + + const program = Effect.flatMap(${sdkId}Service, (service) => service.${pipe(commandToTest, lowerFirst)}(args)); + + const ${sdkId}ClientInstanceLayer = Layer.effect( + ${sdkId}ClientInstance, + Effect.map( + ${sdkId}ClientInstanceConfig, + (config) => new ${sdkId}Client({ ...config, region: "eu-central-1" }), + ), + ); + const Custom${sdkId}ServiceLayer = Base${sdkId}ServiceLayer.pipe( + Layer.provide(${sdkId}ClientInstanceLayer), + Layer.provide(Default${sdkId}ClientConfigLayer), + ); + + const result = await pipe( + program, + Effect.provide(Custom${sdkId}ServiceLayer), + Effect.runPromiseExit, + ); + + expect(result).toEqual(Exit.succeed({})); + expect(clientMock).toHaveReceivedCommandTimes(${commandToTest}Command, 1); + expect(clientMock).toHaveReceivedCommandWith(${commandToTest}Command, args); + }); + + it("fail", async () => { + clientMock.reset().on(${commandToTest}Command).rejects(new Error("test")); + + const args = {} as unknown as ${commandToTest}CommandInput; + + const program = Effect.flatMap(${sdkId}Service, (service) => service.${pipe(commandToTest, lowerFirst)}(args)); + + const result = await pipe( + program, + Effect.provide(Default${sdkId}ServiceLayer), + Effect.runPromiseExit, + ); + + expect(result).toEqual( + Exit.fail( + SdkError({ + ...new Error("test"), + name: "SdkError", + message: "test", + stack: expect.any(String), + }), + ), + ); + expect(clientMock).toHaveReceivedCommandTimes(${commandToTest}Command, 1); + expect(clientMock).toHaveReceivedCommandWith(${commandToTest}Command, args); + }); +}); +`, + ); +}