A Beginner's Guide to Create AWS CDK Construct Library with projen

·

16 min read

Introduction

AWS CDK allows you to create your own Construct Library and publish it to npm or PyPI.

Using projen makes the development of Construct Library very comfortable.

The content of this article has been tested with the following versions

  • projen: v0.3.162
  • AWS CDK: v1.73.0

What is projen?

projen is a tool for defining and managing increasingly complex project configurations in code.

github.com/projen/projen

With projen, you no longer need to manage files such as package.json by yourself.

projen does not only generate various files during project creation but also continuously updates and maintains these settings.

You can easily start a new project using the pre-defined project types.
As of November 2020, the following project types are supported.

Commands:
  projen new awscdk-app-ts     AWS CDK app in TypeScript.
  projen new awscdk-construct  AWS CDK construct library project.
  projen new cdk8s-construct   CDK8s construct library 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 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 creates an environment for building Contruct using jsii.
jsii allows you to generate libraries from TypeScript code to work in Python, Java, and .NET.

Create project

Create a Construct Library project with projen new awscdk-construct.

$ mkdir cdk-sample-lib && cd cdk-sample-lib

$ npx projen new awscdk-construct
npx: installed 63 in 8.37s
🤖 Created .projenrc.js for AwsCdkConstructLibrary
🤖 Synthesizing project...
$GIT_USER_NAME is not defined

Under the project directory, .projenrc.js has been created.

const { AwsCdkConstructLibrary } = require('projen');

const project = new AwsCdkConstructLibrary({
  authorAddress: "user@domain.com",
  authorName: $GIT_USER_NAME,
  cdkVersion: "1.60.0",
  name: "cdk-sample-lib",
  repository: "https://github.com/user/cdk-sample-lib.git",
});

project.synth();

You can add dependencies on AWS CDKs and other modules to be used.

  cdkDependencies: [
    '@aws-cdk/core',
    '@aws-cdk/aws-apigatewayv2',
    '@aws-cdk/aws-apigatewayv2-integrations',
    '@aws-cdk/aws-lambda'
  ],
  deps: [ 
    'super-useful-lib' 
  ]

If you want to cross-compile to languages other than TypeScript with jsii, add the target language.

  python: {
    distName: 'cdk-sample-lib',
    module: 'cdk_sample_lib',
  },

See API.md for other available options.
As an example, the modified file looks like this

const { AwsCdkConstructLibrary } = require('projen');

const PROJECT_NAME = "cdk-sample-lib" 

const project = new AwsCdkConstructLibrary({
  authorAddress: "hayaok333@gmail.com",
  authorName: "hayao-k",
  cdkVersion: "1.73.0",
  name: PROJECT_NAME,
  repository: "https://github.com/hayao-k/cdk-sample-lib.git",
  defaultReleaseBranch: 'main',
  cdkDependencies: [ 
    '@aws-cdk/core',
    '@aws-cdk/aws-apigatewayv2',
    '@aws-cdk/aws-apigatewayv2-integrations',
    '@aws-cdk/aws-lambda'
  ],
  python: {
    distName: PROJECT_NAME,
    module: 'cdk_sample_lib',
  },
});

project.synth();

Once you have edited .projenrc.js, run the projen command to reflect the changes (yarn is required).

$ npx projen
npx: installed 63 in 5.491s
🤖 Synthesizing project...
🤖 yarn install --check-files
yarn install v1.22.5
info No lockfile found.
[1/5] Validating package.json...
[2/5] Resolving packages...
[3/5] Fetching packages...
info fsevents@2.2.1: The platform "linux" is incompatible with this module.
info "fsevents@2.2.1" is an optional dependency and failed compatibility check. Excluding it from installation.
[4/5] Linking dependencies...
[5/5] Building fresh packages...
success Saved lockfile.
Done in 20.34s.
🤖 jest: * => ^26.6.3
🤖 @types/jest: * => ^26.0.15
🤖 ts-jest: * => ^26.4.4
🤖 eslint: * => ^7.13.0
🤖 eslint-import-resolver-node: * => ^0.3.4
🤖 eslint-import-resolver-typescript: * => ^2.3.0
🤖 eslint-plugin-import: * => ^2.22.1
🤖 json-schema: * => ^0.2.5
🤖 Synthesis complete

----------------------------------------------------------------------------------------------------
Commands:

BUILD
compile          Only compile
watch            Watch & compile in the background
build            Full release build (test+compile)

TEST
test             Run tests
test:watch       Run jest in watch mode
eslint           Runs eslint against the codebase

RELEASE
compat           Perform API compatibility check against latest version
release          Bumps version & push to master
docgen           Generate API.md from .jsii manifest
bump             Commits a bump to the package version based on conventional commits
package          Create an npm tarball

MAINTAIN
projen           Synthesize project configuration from .projenrc.js
projen:upgrade   upgrades projen to the latest version

MISC
start            Shows this menu

Tips:
💡 The VSCode jest extension watches in the background and shows inline test results
💡 Install Mergify in your GitHub repository to enable automatic merges of approved PRs
💡 Set `autoUpgradeSecret` to enable automatic projen upgrade pull requests
💡 `API.md` includes the API reference for your library
💡 Set "compat" to "true" to enable automatic API breaking-change validation

You will see that projen automatically generates the package.json, the .gitignore, .npmignore, eslint, jsii configuration, license files, etc., as well as the creation and installation of the package.json.

You no longer have to copy from an existing project every time you create a new project.

Whenever you edit these files, you need to modify the .projenrc.js file and re-run the projen command.
If you edit them manually, the build will fail.

$ tree -L 1 -a
.
├── .eslintrc.json
├── .github
├── .gitignore
├── LICENSE
├── .mergify.yml
├── node_modules
├── .npmignore
├── package.json
├── .projenrc.js
├── README.md
├── src
├── test
├── tsconfig.jest.json
├── version.json
├── .versionrc.json
└── yarn.lock

Development

Let's try a simple example of calling Hello World's Lambda from the API Gateway (HTTP API).
Please note that the HTTP API L2 Constructs has an Experimental status as of November 2020.

The following directory has already been created by projen.

.
├── lib/ 
├── src/
├── test/

The lib directory will contain the compiled files.

The code for the Lambda functions can also be inserted inline into the CDK code, but in this example create index.js in the functions directory.

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

Create the following two files in the src directory

  • src/index.ts
import { HttpApi } from '@aws-cdk/aws-apigatewayv2';
import { LambdaProxyIntegration } from '@aws-cdk/aws-apigatewayv2-integrations';
import { Code, Function, Runtime } from '@aws-cdk/aws-lambda';
import * as cdk from '@aws-cdk/core';

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

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

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

    new cdk.CfnOutput(this, 'ApiURL', { value: api.url! });
  }
}
  • src/integ.default.ts
import * as cdk from '@aws-cdk/core';
import { CdkSampleLib } from './index';

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

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

Create the following files in the test directory

  • test/hello.test.ts
import * as cdk from '@aws-cdk/core';
import { CdkSampleLib } from '../src/index';
import '@aws-cdk/assert/jest';

test('create app', () => {
  const app = new cdk.App();
  const stack = new cdk.Stack(app);
  new CdkSampleLib(stack, 'TestStack');
  expect(stack).toHaveResource('AWS::Lambda::Function');
  expect(stack).toHaveResource('AWS::ApiGatewayV2::Api');
  expect(stack).toHaveResource('AWS::ApiGatewayV2::Integration');
});

Unit Test

Various scripts are predefined in the package.json generated from projen.

Run the test with yarn test. yarn build also run test, so omit the example output here.

Build

Run yarn build and compile TypeScript to the jsii module.

jsii-docgen generates API documentation (API.md) from comments in the code.

In addition, jsii-pacmak creates language-specific public packages in the dist directory.

$ yarn build
yarn run v1.22.5
$ yarn run test && yarn run compile && yarn run package
$ rm -fr lib/ && jest --passWithNoTests --updateSnapshot && yarn run eslint
 PASS  test/hello.test.ts
  ✓ create app (197 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:       1 passed, 1 total
Snapshots:   0 total
Time:        4.713 s, estimated 7 s
Ran all test suites.
$ eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern src test
$ jsii --silence-warnings=reserved-word --no-fix-peer-dependencies && jsii-docgen
$ jsii-pacmak
Done in 51.38s.

Once the build is successful, let's try deploying locally.

$ cdk deploy --app='./lib/integ.default.js'
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...
[0%] start: Publishing 20472c64d312cc547a9359d36b04cfc75633027c0b13c3c07b96dfdf1b1c428f:current
[100%] success: Published 20472c64d312cc547a9359d36b04cfc75633027c0b13c3c07b96dfdf1b1c428f:current
MyStack: creating CloudFormation changeset...
[██████████████████████████████████████████████████████████] (9/9)

 ✅  MyStack

Outputs:
MyStack.CdkSampleLibApiURL32C6192A = https://4p9zte6ny8.execute-api.ap-northeast-1.amazonaws.com/

Stack ARN:
arn:aws:cloudformation:ap-northeast-1:123456789012:stack/MyStack/8fc38650-2a78-11eb-8d53-0a6e476ede30

You can check the response of the Lambda function from the output API URL.

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

To remove it, run the cdk destory.

$ cdk destroy --app='./lib/integ.default.js'                                        
Are you sure you want to delete: MyStack (y/n)? y
MyStack: destroying...
5:27:29 PM | DELETE_IN_PROGRESS   | AWS::CloudFormation::Stack | MyStack

 ✅  MyStack: destroyed

Release

First, commit the changes.

$ git add . 
$ git commit -m "feat: release 0.0.1"

yarn release bumps the version and automatically updates CANGELOG.md. Then push to the release branch of GitHub.

$ yarn release
yarn run v1.22.5
$ yarn run --silent no-changes || (yarn run bump && git push --follow-tags origin main)
$ yarn run --silent no-changes || standard-version
✔ bumping version in version.json from 0.0.0 to 0.0.1
✔ Running lifecycle script "postbump"
ℹ - execute command: "yarn run projen && git add ."
🤖 yarn install --check-files
🤖 Synthesis complete

----------------------------------------------------------------------------------------------------
Commands:

BUILD
compile          Only compile
watch            Watch & compile in the background
build            Full release build (test+compile)

TEST
test             Run tests
test:watch       Run jest in watch mode
eslint           Runs eslint against the codebase

RELEASE
compat           Perform API compatibility check against latest version
release          Bumps version & push to main
docgen           Generate API.md from .jsii manifest
bump             Commits a bump to the package version based on conventional commits
package          Create an npm tarball

MAINTAIN
projen           Synthesize project configuration from .projenrc.js
projen:upgrade   upgrades projen to the latest version

MISC
start            Shows this menu

Tips:
💡 The VSCode jest extension watches in the background and shows inline test results
💡 Install Mergify in your GitHub repository to enable automatic merges of approved PRs
💡 Set `autoUpgradeSecret` to enable automatic projen upgrade pull requests
💡 `API.md` includes the API reference for your library
💡 Set "compat" to "true" to enable automatic API breaking-change validation

✔ created CHANGELOG.md
✔ outputting changes to CHANGELOG.md
✔ committing version.json and CHANGELOG.md and all staged files
✔ tagging release v0.0.1
ℹ Run `git push --follow-tags origin master` to publish

Or you can bump to any version by running yarn bump.

$ yarn bump --release-as 1.0.0 && git push --follow-tags origin main

The Github Actions workflow definition is also generated when the projen command is executed, which makes it easy to automate the release to the package repository.

  • Build workflow (.github/workflows/build.yaml) Runs when a pull request is created.
    Builds the library and checks for tampering (i.e., manual modification).

  • Release workflow (.github/workflows/release.yaml): It is triggered by push to the release branch.
    After building in the release branch, it automatically publishes to the repositories, such as npm and PyPI by jsii-release.

Alt Text

In order for the Release job to work properly, you need to register the Secrets for the language you want to publish in the GitHub repository.

  • 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

Try It!

With projen, you can focus on the implementation of the Construct Library (and of course, on the regular CDK App).

Do you want to understand how to use projen in videos?
The following video published by @pahud, an AWS Developer Advocate, is very helpful.

I recently published a construct library called cdk-ecr-image-scan-notify using projen.

I hope this article will help you.