projen ではじめる快適 AWS CDK Construct Library 開発生活

## はじめに
AWS CDK では 独自の Construct Library を作成し、npm や PyPI に公開することができます。
私も [いくつか](https://constructs.dev/search?q=hayao-k&cdk=aws-cdk&offset=0) Construct Library を公開しているのですが、projen を使用することで Construct Library の作成～リリースがとても捗ったのでご紹介します。

2022/5/29 以下のバージョン時点の内容に更新しました。

* projen:  v0.56.33
* AWS CDK:  v2.25.0

## What is projen? 
projen は近年複雑化しているプロジェクト構成をコードで定義し、管理するためのツールです。
元 AWS のエンジニアで AWS CDK の作成者である Elad Ben-Israel 氏を中心に開発されています。

https://github.com/projen/projen

projen では `package.json`、 `.gitignore` 等、通常は自分で管理する必要のある多くのプロジェクトファイルを自動で管理します。ジェネレーターとしてプロジェクト作成時に各種ファイルを生成するだけでなく、projen が継続してこれらの構成を更新し、維持します。

あらかじめ定義されているプロジェクトタイプを使用して新規プロジェクトを簡単に開始できます。 
2022/5 時点で以下のプロジェクトタイプをサポートしています。
React アプリなど CDK 以外のプロジェクト作成にも使用できます。

```shell-session
Commands:
  projen new awscdk-app-java   AWS CDK app in Java.
  projen new awscdk-app-py     AWS CDK app in Python.
  projen new awscdk-app-ts     AWS CDK app in TypeScript.
  projen new awscdk-construct  AWS CDK construct library project.
  projen new cdk8s-app-ts      CDK8s app in TypeScript.
  projen new cdk8s-construct   CDK8s construct library project.
  projen new cdktf-construct   CDKTF construct library project.
  projen new java              Java project.
  projen new jsii              Multi-language jsii library project.
  projen new nextjs            Next.js project without TypeScript.
  projen new nextjs-ts         Next.js project with TypeScript.
  projen new node              Node.js project.
  projen new project           Base project.
  projen new python            Python project.
  projen new react             React project without TypeScript.
  projen new react-ts          React project with TypeScript.
  projen new typescript        TypeScript project.
  projen new typescript-app    TypeScript app.
```

awscdk-construct は [jsii](https://github.com/aws/jsii) を使用した Contruct をビルドするための環境を作成します。
jsii により TypeScript のコードから Python, Java, .NET で動作するライブラリを生成できます。

## Create project
### 新規プロジェクトの作成
`projen new awscdk-construct` で Construct Library プロジェクトを作成します。

```shell-session
$ mkdir cdk-sample-lib && cd cdk-sample-lib

$ npx projen new awscdk-construct
👾 Project definition file was created at /home/ec2-user/environment/cdk-sample-lib/.projenrc.js
yarn install v1.22.18
info No lockfile found.
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...

success Saved lockfile.
Done in 38.61s.
yarn install v1.22.18
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...

success Saved lockfile.
Done in 7.21s.

> cdk-sample-lib@0.0.0 eslint
> npx projen eslint

Initialized empty Git repository in /home/ec2-user/environment/cdk-sample-lib/.git/
[main (root-commit) 924b25c] chore: project created with projen
 21 files changed, 7704 insertions(+)
 create mode 100644 .eslintrc.json
 create mode 100644 .gitattributes
 create mode 100644 .github/pull_request_template.md
 create mode 100644 .github/workflows/build.yml
 create mode 100644 .github/workflows/pull-request-lint.yml
 create mode 100644 .github/workflows/release.yml
 create mode 100644 .github/workflows/upgrade-main.yml
 create mode 100644 .gitignore
 create mode 100644 .mergify.yml
 create mode 100644 .npmignore
 create mode 100644 .projen/deps.json
 create mode 100644 .projen/files.json
 create mode 100644 .projen/tasks.json
 create mode 100644 .projenrc.js
 create mode 100644 LICENSE
 create mode 100644 README.md
 create mode 100644 package.json
 create mode 100644 src/index.ts
 create mode 100644 test/hello.test.ts
 create mode 100644 tsconfig.dev.json
 create mode 100644 yarn.lock
```

この時点でプロジェクトディレクトリ配下に、`.projenrc.js` が作成されます。

```js
const { awscdk } = require('projen');
const project = new awscdk.AwsCdkConstructLibrary({
  author: 'user',
  authorAddress: 'user@example.com',
  cdkVersion: '2.1.0',
  defaultReleaseBranch: 'main',
  name: 'cdk-sample-lib',
  repositoryUrl: 'https://github.com/user/cdk-sample-lib.git',

  // deps: [],                /* Runtime dependencies of this module. */
  // description: undefined,  /* The description is just a string that helps people understand the purpose of the package. */
  // devDeps: [],             /* Build dependencies for this module. */
  // packageName: undefined,  /* The "name" in package.json. */
});
project.synth();
```

### .projenrc.js の修正
使用する AWS CDK やその他のモジュールの依存関係を追加できます。

```js
  deps: [
    '@aws-cdk/aws-apigatewayv2-alpha',
    '@aws-cdk/aws-apigatewayv2-integrations-alpha',
    'other-useful-lib' 
  ]
```

jsii で TypeScript 以外の言語にクロスコンパイルを行う場合はターゲット言語を追加します。

```js
  publishToPypi: {
    distName: 'cdk-sample-lib',
    module: 'cdk_sample_lib',
  },
```


その他に指定可能なオプションについては [API リファレンス](https://projen.io/api/API.html#projen-awscdk-awscdkconstructlibrary) をご確認ください。
参考例として、修正後のファイルは以下のようになります。

```js
const { awscdk } = require('projen');

const cdkVersion = '2.25.0';

const project = new awscdk.AwsCdkConstructLibrary({
  author: 'hayao-k',
  authorAddress: '30886141+hayao-k@users.noreply.github.com',
  cdkVersion,
  defaultReleaseBranch: 'main',
  name: 'cdk-sample-lib',
  repositoryUrl: 'https://github.com/hayao-k/cdk-sample-lib.git',
  description: 'Sample AWS CDK Construct Library by projen',
  keywords: ['sample'],
  license: 'Apache-2.0',
  deps: [
    `@aws-cdk/aws-apigatewayv2-alpha@${cdkVersion}-alpha.0`,
    `@aws-cdk/aws-apigatewayv2-integrations-alpha@${cdkVersion}-alpha.0`
  ],
  publishToPypi: {
    distName: 'cdk-sample-lib',
    module: 'cdk_sample_lib',
  },
  stability: 'experimental',
});
project.synth();
```

### projen コマンドの実行
`.projenrc.js` を編集したら、projen コマンドを実行し、変更を反映します。 

```shell-session
$ npx projen
👾 default | node .projenrc.js
yarn install v1.22.18
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Saved lockfile.
Done in 14.87s.
```

`package.json` の作成とインストールをはじめ、`.gitignore` や `.npmignore`、eslint、jsii の構成、ライセンスファイル等が projen により自動で生成されていることがわかります。新規プロジェクトを作成するたびに、既存のプロジェクトからコピーしてくるといった作業が不要になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125105/fc12d0ba-a066-38ac-f09c-a6a6a3e7dfa1.png)

これらのファイルを編集する場合、必ず `.projenrc.js` を修正し、projen コマンドを再実行する必要があります。手動で編集した場合はビルドが失敗します。


## Development
ここでは Hello World の Lambda 関数を API Gateway (HTTP API) から呼び出すシンプルな例を考えてみます。Lambda 関数のコードは CDK のコードにインラインでも挿入できますが、今回は 別途 `functions` ディレクトリを作成し、配置、参照させます。

```functions/index.js
exports.handler = async (event) => {
    const response = {
        statusCode: 200,
        body: JSON.stringify('Hello from Lambda!'),
    };
    return response;
};
```

`src` ディレクトリに以下の2ファイルを作成します。

```src/index.ts
import { HttpApi } from '@aws-cdk/aws-apigatewayv2-alpha';
import { HttpLambdaIntegration } from '@aws-cdk/aws-apigatewayv2-integrations-alpha';
import * as cdk from 'aws-cdk-lib';
import { Code, Function, Runtime } from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';

export class CdkSampleLib extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    const handler = new Function(this, 'HelloWorld', {
      handler: 'index.handler',
      code: Code.fromAsset('functions'),
      runtime: Runtime.NODEJS_16_X,
    });

    const api = new HttpApi(this, 'API', {
      defaultIntegration: new HttpLambdaIntegration('LambdaIntegration', handler),
    });

    new cdk.CfnOutput(this, 'ApiURL', { value: api.url! });
  }
}
```

```src/integ.default.ts
import * as cdk from 'aws-cdk-lib';
import { CdkSampleLib } from './index';

const app = new cdk.App();
const stack = new cdk.Stack(app, 'MyStack');

new CdkSampleLib(stack, 'Cdk-Sample-Lib');
```

`test` ディレクトリには以下のファイルを作成しました。

```test/cdk-sample-lib.test.ts
import { App, Stack } from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { CdkSampleLib } from '../src/index';

const mockApp = new App();
const stack = new Stack(mockApp);
new CdkSampleLib(stack, 'testing-stack');
const template = Template.fromStack(stack);

test('Lambda functions should be configured with properties and execution roles', () => {
  template.hasResourceProperties('AWS::Lambda::Function', {
    Runtime: 'nodejs16.x',
  });

  template.hasResourceProperties('AWS::IAM::Role', {
    AssumeRolePolicyDocument: {
      Statement: [
        {
          Action: 'sts:AssumeRole',
          Effect: 'Allow',
          Principal: {
            Service: 'lambda.amazonaws.com',
          },
        },
      ],
      Version: '2012-10-17',
    },
  });
});

test('HTTP API should be created', () => {
  template.hasResourceProperties('AWS::ApiGatewayV2::Api', {
    ProtocolType: 'HTTP',
  });
});

test('Lambda Integration should be created', () => {
  template.hasResourceProperties('AWS::ApiGatewayV2::Integration', {
    IntegrationType: 'AWS_PROXY',
  });
});
```


## Unit Test, Build, Release
projen から生成された package.json により、各種 scripts が定義済みです。

### Unit Test
`yarn test` (`npx projen test`) でテストを実行します。
`yarn build` (`npx projen build`) 実行時もテストが走るため、ここでは実行例は割愛します。

### Build
`yarn build` でテストを実行し、TypeScript を jsii モジュールにコンパイルします。
また [jsii-docgen](https://github.com/aws/jsii-docgen) によりコード内のコメントから API ドキュメント (API.md) を生成します。
さらに [jsii-pacmak](https://github.com/aws/jsii/tree/main/packages/jsii-pacmak) により `dist` ディレクトリに各言語固有の公開パッケージを作成します。

```shell-session
$ yarn build
👾 build » default | node .projenrc.js
yarn install v1.22.18
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...

success Saved lockfile.
Done in 55.48s.
👾 build » compile | jsii --silence-warnings=reserved-word
👾 build » post-compile » docgen | jsii-docgen -o API.md
👾 build » test | jest --passWithNoTests --all --updateSnapshot
 PASS  test/cdk-sample-lib.test.ts (9.805 s)
  ✓ Lambda functions should be configured with properties and execution roles (3 ms)
  ✓ HTTP API should be created (1 ms)
  ✓ Lambda Integration should be created (1 ms)

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
----------|---------|----------|---------|---------|-------------------
All files |     100 |      100 |     100 |     100 |                   
 index.ts |     100 |      100 |     100 |     100 |                   
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        10.121 s
Ran all test suites.
👾 build » test » eslint | eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern src test build-tools .projenrc.js
👾 build » package | if [ ! -z ${CI} ]; then mkdir -p dist && rsync -a . dist --exclude .git --exclude node_modules; else npx projen package-all; fi
👾 package-all » package:js | jsii-pacmak -v --target js
[jsii-pacmak] [INFO] Found 1 modules to package
[jsii-pacmak] [INFO] Packaging NPM bundles
[jsii-pacmak] [INFO] Loading jsii assemblies and translations
[jsii-pacmak] [INFO] Packaging 'js' for cdk-sample-lib
[jsii-pacmak] [INFO] js finished
[jsii-pacmak] [INFO] Packaged. load jsii (2.1s) | npm pack (0.4s) | js (0.0s) | cleanup (0.0s)
👾 package-all » package:python | jsii-pacmak -v --target python
[jsii-pacmak] [INFO] Found 1 modules to package
[jsii-pacmak] [INFO] Packaging NPM bundles
[jsii-pacmak] [INFO] Loading jsii assemblies and translations
[jsii-pacmak] [INFO] Packaging 'python' for cdk-sample-lib
[jsii-pacmak] [INFO] python finished
[jsii-pacmak] [INFO] Packaged. python (15.9s) | load jsii (1.9s) | npm pack (0.4s) | cleanup (0.0s)
```

ビルドが成功したら、以下のようにローカルでデプロイを試すことができます。

```shell-session
$ cdk deploy --app='./lib/integ.default.js'

✨  Synthesis time: 1.27s

current credentials could not be used to assume 'arn:aws:iam::123456789012:role/cdk-hnb659fds-lookup-role-123456789012-ap-northeast-1', but are for the right account. Proceeding anyway.
(To get rid of this warning, please upgrade to bootstrap version >= 8)
current credentials could not be used to assume 'arn:aws:iam::123456789012:role/cdk-hnb659fds-deploy-role-123456789012-ap-northeast-1', but are for the right account. Proceeding anyway.
This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:

IAM Statement Changes
┌───┬────────────────────────────────────────────────────────────────────┬────────┬───────────────────────┬──────────────────────────────────┬─────────────────────────────────────────────────────────────────────┐
│   │ Resource                                                           │ Effect │ Action                │ Principal                        │ Condition                                                           │
├───┼────────────────────────────────────────────────────────────────────┼────────┼───────────────────────┼──────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ + │ ${Cdk-Sample-Lib/HelloWorld.Arn}                                   │ Allow  │ lambda:InvokeFunction │ Service:apigateway.amazonaws.com │ "ArnLike": {                                                        │
│   │                                                                    │        │                       │                                  │   "AWS:SourceArn": "arn:${AWS::Partition}:execute-api:${AWS::Region │
│   │                                                                    │        │                       │                                  │ }:${AWS::AccountId}:${CdkSampleLibAPI6FD5D6E6}/*/*"                 │
│   │                                                                    │        │                       │                                  │ }                                                                   │
├───┼────────────────────────────────────────────────────────────────────┼────────┼───────────────────────┼──────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ + │ ${Cdk-Sample-Lib/HelloWorld/ServiceRole.Arn}                       │ Allow  │ sts:AssumeRole        │ Service:lambda.amazonaws.com     │                                                                     │
└───┴────────────────────────────────────────────────────────────────────┴────────┴───────────────────────┴──────────────────────────────────┴─────────────────────────────────────────────────────────────────────┘
IAM Policy Changes
┌───┬──────────────────────────────────────────┬────────────────────────────────────────────────────────────────────────────────┐
│   │ Resource                                 │ Managed Policy ARN                                                             │
├───┼──────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────┤
│ + │ ${Cdk-Sample-Lib/HelloWorld/ServiceRole} │ arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole │
└───┴──────────────────────────────────────────┴────────────────────────────────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Do you wish to deploy these changes (y/n)? y
MyStack: deploying...
current credentials could not be used to assume 'arn:aws:iam::123456789012:role/cdk-hnb659fds-deploy-role-123456789012-ap-northeast-1', but are for the right account. Proceeding anyway.
[0%] start: Publishing 91983f53eff8228528504631ea088fb748d797c41b9117601e9b1ed390057a51:current_account-current_region
[0%] start: Publishing 9c1606accb41e40678fb0f9503bf3fbfe23c7c6f77c1550c8421da0b84444171:current_account-current_region
current credentials could not be used to assume 'arn:aws:iam::123456789012:role/cdk-hnb659fds-file-publishing-role-123456789012-ap-northeast-1', but are for the right account. Proceeding anyway.
current credentials could not be used to assume 'arn:aws:iam::123456789012:role/cdk-hnb659fds-file-publishing-role-123456789012-ap-northeast-1', but are for the right account. Proceeding anyway.
[50%] success: Published 91983f53eff8228528504631ea088fb748d797c41b9117601e9b1ed390057a51:current_account-current_region
[100%] success: Published 9c1606accb41e40678fb0f9503bf3fbfe23c7c6f77c1550c8421da0b84444171:current_account-current_region
MyStack: creating CloudFormation changeset...

 ✅  MyStack

✨  Deployment time: 68.01s

Outputs:
MyStack.CdkSampleLibApiURL32C6192A = https://4p9zte6ny8.execute-api.ap-northeast-1.amazonaws.com/
Stack ARN:
arn:aws:cloudformation:ap-northeast-1:123456789012:stack/MyStack/a41998b0-de9f-11ec-88d1-0afcbfc50359

✨  Total time: 69.28s

```

出力された API にリクエストを投げると、Lambda からの応答を確認できます。

```shell-session
$ curl https://4p9zte6ny8.execute-api.ap-northeast-1.amazonaws.com/
"Hello from Lambda!"
```

削除も同様です。

```shell-session
$ cdk destroy --app='./lib/integ.default.js'   
Are you sure you want to delete: MyStack (y/n)? y
MyStack: destroying...
current credentials could not be used to assume 'arn:aws:iam::123456789012:role/cdk-hnb659fds-deploy-role-123456789012-ap-northeast-1', but are for the right account. Proceeding anyway.

 ✅  MyStack: destroyed

```

### Release
変更をコミットし、GitHub にコードを Push します。

```shell-session
$ git add . 
$ git commit -m "feat: initial release"
```
projen は [Conventional Commits](https://www.conventionalcommits.org/ja/v1.0.0/) に基づいて自動的にセマンティックバージョニングを行います。

例えば以下のような感じです。

* fix: で PATCH バージョン (`v0.0.1`) をバンプ
* feat: で MINOR バージョン (`v0.1.0`) をバンプ

MAJAR バージョンについては重大な変更からユーザーを保護するために `.projenrc.js` に `majorVersion: 1,` のように追記して明示的にバンプする必要があります。

projen は Github Actions の Workflow 定義も生成します。これにより各言語パッケージリポジトリへのリリースを簡単に自動化することができます。

* Build workflow (.github/workflows/build.yaml): 
pull request 作成時に起動
ライブラリのビルドおよび、改ざん (手動修正されていないか) のチェック

* Release workflow (.github/workflows/release.yaml):
リリースブランチへの push 時に起動
ライブラリのビルドおよび、改ざん (手動修正されていないか) のチェック
Conventional Commits による Release Version のバンプ
changelog の作成
GitHub Releases、npm や PyPI などの各パッケージリポジトリへの自動リリース

リポジトリへのリリースには [publib](https://github.com/cdklabs/publib) が使用されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125105/a3531a28-b737-46d6-d97b-9ae283bac6c3.png)



Workflow を正常に動作させるには、projen が使用する Personal Access Token および公開先のリポジトリに応じた API_KEY などを Actions secrets に登録しておく必要があります。

* projen 用の PAT: `PROJEN_GITHUB_TOKEN` (scope は `repo`, `workflows`, `packages`)
* npm: `NPM_TOKEN`
* .NET: `NUGET_API_KEY`
* Java: `MAVEN_GPG_PRIVATE_KEY`, `MAVEN_GPG_PRIVATE_KEY_PASSPHRASE`, `MAVEN_PASSWORD`, `MAVEN_USERNAME`, `MAVEN_STAGING_PROFILE_ID`
* Python: `TWINE_USERNAME`, `TWINE_PASSWORD`

## Construct Hub への公開
Construct Hub はコミュニティや AWS、AWS パートナーから提供されるカスタム Construct Library を検出・共有するためのレジストリサイトです。昨年 12 月 に AWS CDK v2 と同日に GA になっています。現在 1000 以上の Construct Library が掲載されています。

https://constructs.dev/

自身が作成したライブラリを Construct Hub 上で公開するには以下の条件を満たしている必要があります。

* JSII-compatible であること
* オープンソースライセンスであること
  * Apache, BSD, EPL, MPL-2.0, ISC, and CDDL or MIT
* CDK Keyword を使用して npm Registry に公開されていること
  * cdk, awscdk, aws-cdk, cdk8s, or cdktf

これらを満たすと 約 30 分で自動的に検出、掲載されるような仕組みになっています。対象のオープンソースライセンスであれば、projen を使用して作成・公開したライブラリはこれらを満たしていることになります。そのため特別な対応をしなくとも Construct Hub に掲載されます。


## さいごに
projen を使用することで、Construct Library の実装に集中することができます。(もちろん通常の CDK App についても)
参考の GitHub リポジトリとしては冒頭でご紹介した [cdk-ecr-image-scan-notify](https://github.com/hayao-k/cdk-ecr-image-scan-notify) をご確認いただければと思います。Construct Library 自体もぜひ使ってみていただけると嬉しいです。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ECR のイメージスキャン結果を Slack 通知する Lambda 関数 と EventBrigde のイベントルールを AWS CDK で Construct Library として公開してみました！<br>TypeScript と Python で利用できます。<a href="https://t.co/AseGERM45u">https://t.co/AseGERM45u</a> <a href="https://t.co/kPSMHBal91">pic.twitter.com/kPSMHBal91</a></p>&mdash; hayao_k (@hayaok3) <a href="https://twitter.com/hayaok3/status/1323593051319668737?ref_src=twsrc%5Etfw">November 3, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

projen を使用した Construct Library の作成については台北の [AWS Community Builder](https://aws.amazon.com/jp/developer/community/community-builders/) である Neil Kuan (@[guan840912](https://github.com/guan840912)) からの紹介と多くの協力により学ぶことができました。彼の GitHub アカウントで複数の Construct Library が公開されているので、こちらもチェックしてみてください!

以上です。
参考になれば幸いです。