CLOVER🍀

That was when it all began.

AWS SAMのTypeScriptサポート(プレビュー)をLocalStackで試す

これは、なにをしたくて書いたもの?

自分はAWS SAMでTyepScriptを使う時はほぼ自前で設定を書いているのですが、今年の2月に出ていたAWS SAMのTypeScriptサポートを
1度試しておこうかなと思い始めたので、やってみようかなと。

AWS Serverless Application Model (SAM) CLI は、TypeScript ネイティブサポートのパブリックプレビューを発表します。

AWS Serverless Application Model (AWS SAM) CLI での TypeScript ネイティブサポート (esbuild を使用) (パブリックプレビュー) を発表

今まで試してこなかった理由は、未だにプレビュー扱いだからなのですが。まあ、そこはいったんいいかなということで。

AWS SAMのTypeScriptサポート

AWS SAMのTypeScriptサポートについて、より具体的な利用イメージを書いたブログエントリーはこちら。

Building TypeScript projects with AWS SAM CLI | AWS Compute Blog

プレビュー段階なのでベータ機能を有効にする旨やプロジェクトの構成、template.yamlのポイントなどが書かれています。

AWS SAMのTypeScriptサポートに関するドキュメントとしては、こちらになります。esbuildサポートという形でページが記述されています。

esbuild による Node.js Lambda関数の構築 (プレビュー) - AWS Serverless Application Model

esbuild自体は、こちら。

esbuild - An extremely fast JavaScript bundler

AWSのドキュメントに以下のように書かれている内容が、TypeScriptサポート(というかesbuildサポート)の実体ですね。

esbuild と共に AWS SAM CLI を使用して Node.js Lambda 関数をビルドおよびパッケージ化します。esbuild は TypeScript で記述する Lambda 関数をサポートします。

esbuild を使用して Node.js Lambda 関数を構築するには、AWS:Serverless::Function リソースに Metadata オブジェクトを追加し、BuildMethod に esbuild を指定します。sam build を実行すると、AWS SAM は esbuild を使用して、Lambda 関数コードをバンドルします。

template.yamlMetadataにesbuildの設定を記述することになります。

    Metadata:
      BuildMethod: esbuild
      BuildProperties:
        Minify: false
        Target: "es2020"
        Sourcemap: true
        EntryPoints: 
          - app.ts

Metadataでサポートされているesbuildのプロパティは、以下がリファレンスになります。

esbuild による Node.js Lambda関数の構築 (プレビュー) / Metadata プロパティ

また、esbuildによるTypeScriptサポートを使うためには、AWS SAMの設定ファイル、環境変数コマンドライン引数のいずれかで
ベータ機能を有効化する必要があります。

esbuild による Node.js Lambda関数の構築 (プレビュー) / esbuild プレビュー機能の使用

今回は、この機能をLocalStackを使って試してみましょう。

環境

今回の環境は、こちら。

$ python3 -V
Python 3.8.10


$ localstack --version
1.0.3

LocalStackを起動。

$ LAMBDA_EXECUTOR=docker-reuse localstack start

AWS CLIおよびAWS SAM CLI、加えてLocalStackの提供ツール。

$ awslocal --version
aws-cli/2.7.20 Python/3.9.11 Linux/5.4.0-122-generic exe/x86_64.ubuntu.20 prompt/off


$ samlocal --version
SAM CLI, version 1.53.0

アプリケーションを作成する際に使用するNode.jsのバージョン。

$ node --version
v16.16.0


$ npm --version
8.11.0

AWS Lambda関数としても、Node.js 16で動かすことにします。

TypeScriptサポートを使ったAWS SAMプロジェクトを作成する

では、AWS SAMプロジェクトを作成します。

sam init相当のことを行いますが、この時にTypeScript用のテンプレートを指定します。今回は、hello-world-typescriptを選択。
Amazon API GatewayAWS Lambdaの構成ですね。

$ samlocal init --name sam-typescript-support-hello-world --runtime nodejs16.x --app-template hello-world-typescript --package-type Zip --no-tracing

TypeScript用のテンプレートは、まだ少ないようですが。

プロジェクト内に移動。

$ cd sam-typescript-support-hello-world

生成されたディレクトリおよびファイルを確認してみます。

$ tree -a
.
├── .gitignore
├── README.md
├── events
│   └── event.json
├── hello-world
│   ├── .eslintignore
│   ├── .eslintrc.js
│   ├── .npmignore
│   ├── .prettierrc.js
│   ├── app.ts
│   ├── jest.config.ts
│   ├── package.json
│   ├── tests
│   │   └── unit
│   │       └── test-handler.test.ts
│   └── tsconfig.json
└── template.yaml

4 directories, 13 files

主要なファイルの中身も見てみましょう。

hello-world/package.json

{
  "name": "hello_world",
  "version": "1.0.0",
  "description": "hello world sample for NodeJS",
  "main": "app.js",
  "repository": "https://github.com/awslabs/aws-sam-cli/tree/develop/samcli/local/init/templates/cookiecutter-aws-sam-hello-nodejs",
  "author": "SAM CLI",
  "license": "MIT",
  "dependencies": {
  },
  "scripts": {
    "unit": "jest",
    "lint": "eslint '*.ts' --quiet --fix",
    "compile": "tsc",
    "test": "npm run compile && npm run unit"
  },
  "devDependencies": {
    "@types/aws-lambda": "^8.10.92",
    "@types/jest": "^27.4.0",
    "@types/node": "^17.0.13",
    "@typescript-eslint/eslint-plugin": "^5.10.2",
    "@typescript-eslint/parser": "^5.10.2",
    "esbuild": "^0.14.14",
    "esbuild-jest": "^0.5.0",
    "eslint": "^8.8.0",
    "eslint-config-prettier": "^8.3.0",
    "eslint-plugin-prettier": "^4.0.0",
    "jest": "^27.5.0",
    "prettier": "^2.5.1",
    "ts-node": "^10.4.0",
    "typescript": "^4.5.5"
  }
}

hello-world/tsconfig.json

{
    "compilerOptions": {
      "target": "es2020",
      "strict": true,
      "preserveConstEnums": true,
      "noEmit": true,
      "sourceMap": false,
      "module":"es2015",
      "moduleResolution":"node",
      "esModuleInterop": true,
      "skipLibCheck": true,
      "forceConsistentCasingInFileNames": true,
    },
    "exclude": ["node_modules", "**/*.test.ts"]
  }

hello-world/.npmignore

tests/*

hello-world/app.ts

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';

/**
 *
 * Event doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format
 * @param {Object} event - API Gateway Lambda Proxy Input Format
 *
 * Return doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html
 * @returns {Object} object - API Gateway Lambda Proxy Output Format
 *
 */

export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
    let response: APIGatewayProxyResult;
    try {
        response = {
            statusCode: 200,
            body: JSON.stringify({
                message: 'hello world',
            }),
        };
    } catch (err) {
        console.log(err);
        response = {
            statusCode: 500,
            body: JSON.stringify({
                message: 'some error happened',
            }),
        };
    }

    return response;
};

hello-world/jest.config.ts

/*
 * For a detailed explanation regarding each configuration property and type check, visit:
 * https://jestjs.io/docs/configuration
 */

export default {
    transform: {
        '^.+\\.ts?$': 'esbuild-jest',
    },
    clearMocks: true,
    collectCoverage: true,
    coverageDirectory: 'coverage',
    coverageProvider: 'v8',
    testMatch: ['**/tests/unit/*.test.ts'],
};

hello-world/tests/unit/test-handler.test.ts

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { lambdaHandler } from '../../app';

describe('Unit test for app handler', function () {
    it('verifies successful response', async () => {
        const event: APIGatewayProxyEvent = {
            httpMethod: 'get',
            body: '',
            headers: {},
            isBase64Encoded: false,
            multiValueHeaders: {},
            multiValueQueryStringParameters: {},
            path: '/hello',
            pathParameters: {},
            queryStringParameters: {},
            requestContext: {
                accountId: '123456789012',
                apiId: '1234',
                authorizer: {},
                httpMethod: 'get',
                identity: {
                    accessKey: '',
                    accountId: '',
                    apiKey: '',
                    apiKeyId: '',
                    caller: '',
                    clientCert: {
                        clientCertPem: '',
                        issuerDN: '',
                        serialNumber: '',
                        subjectDN: '',
                        validity: { notAfter: '', notBefore: '' },
                    },
                    cognitoAuthenticationProvider: '',
                    cognitoAuthenticationType: '',
                    cognitoIdentityId: '',
                    cognitoIdentityPoolId: '',
                    principalOrgId: '',
                    sourceIp: '',
                    user: '',
                    userAgent: '',
                    userArn: '',
                },
                path: '/hello',
                protocol: 'HTTP/1.1',
                requestId: 'c6af9ac6-7b61-11e6-9a41-93e8deadbeef',
                requestTimeEpoch: 1428582896000,
                resourceId: '123456',
                resourcePath: '/hello',
                stage: 'dev',
            },
            resource: '',
            stageVariables: {},
        };
        const result: APIGatewayProxyResult = await lambdaHandler(event);

        expect(result.statusCode).toEqual(200);
        expect(result.body).toEqual(
            JSON.stringify({
                message: 'hello world',
            }),
        );
    });
});

hello-world/.prettierrc.js

module.exports = {
    semi: true,
    trailingComma: "all",
    singleQuote: true,
    printWidth: 120,
    tabWidth: 4
  };

hello-world/.eslintrc.js

module.exports = {
    parser: "@typescript-eslint/parser",
    parserOptions: {
      ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features
      sourceType: "module"
    },
    extends: [
      "plugin:@typescript-eslint/recommended", // recommended rules from the @typescript-eslint/eslint-plugin
      "plugin:prettier/recommended" // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array.
    ],
    rules: {
      // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs
      // e.g. "@typescript-eslint/explicit-function-return-type": "off",
    }
  };

hello-world/.eslintignore

node_modules

events/event.json

{
  "body": "{\"message\": \"hello world\"}",
  "resource": "/{proxy+}",
  "path": "/path/to/resource",
  "httpMethod": "POST",
  "isBase64Encoded": false,
  "queryStringParameters": {
    "foo": "bar"
  },
  "pathParameters": {
    "proxy": "/path/to/resource"
  },
  "stageVariables": {
    "baz": "qux"
  },
  "headers": {
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
    "Accept-Encoding": "gzip, deflate, sdch",
    "Accept-Language": "en-US,en;q=0.8",
    "Cache-Control": "max-age=0",
    "CloudFront-Forwarded-Proto": "https",
    "CloudFront-Is-Desktop-Viewer": "true",
    "CloudFront-Is-Mobile-Viewer": "false",
    "CloudFront-Is-SmartTV-Viewer": "false",
    "CloudFront-Is-Tablet-Viewer": "false",
    "CloudFront-Viewer-Country": "US",
    "Host": "1234567890.execute-api.us-east-1.amazonaws.com",
    "Upgrade-Insecure-Requests": "1",
    "User-Agent": "Custom User Agent String",
    "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)",
    "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==",
    "X-Forwarded-For": "127.0.0.1, 127.0.0.2",
    "X-Forwarded-Port": "443",
    "X-Forwarded-Proto": "https"
  },
  "requestContext": {
    "accountId": "123456789012",
    "resourceId": "123456",
    "stage": "prod",
    "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef",
    "requestTime": "09/Apr/2015:12:34:56 +0000",
    "requestTimeEpoch": 1428582896000,
    "identity": {
      "cognitoIdentityPoolId": null,
      "accountId": null,
      "cognitoIdentityId": null,
      "caller": null,
      "accessKey": null,
      "sourceIp": "127.0.0.1",
      "cognitoAuthenticationType": null,
      "cognitoAuthenticationProvider": null,
      "userArn": null,
      "userAgent": "Custom User Agent String",
      "user": null
    },
    "path": "/prod/path/to/resource",
    "resourcePath": "/{proxy+}",
    "httpMethod": "POST",
    "apiId": "1234567890",
    "protocol": "HTTP/1.1"
  }
}

template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  sam-typescript-support-hello-world

  Sample SAM Template for sam-typescript-support-hello-world

# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
  Function:
    Timeout: 3

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: hello-world/
      Handler: app.lambdaHandler
      Runtime: nodejs16.x
      Architectures:
        - x86_64
      Events:
        HelloWorld:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
            Path: /hello
            Method: get
    Metadata: # Manage esbuild properties
      BuildMethod: esbuild
      BuildProperties:
        Minify: true
        Target: "es2020"
        Sourcemap: true
        EntryPoints:
        - app.ts

Outputs:
  # ServerlessRestApi is an implicit API created out of Events key under Serverless::Function
  # Find out more about other implicit resources you can reference within SAM
  # https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api
  HelloWorldApi:
    Description: "API Gateway endpoint URL for Prod stage for Hello World function"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"
  HelloWorldFunction:
    Description: "Hello World Lambda Function ARN"
    Value: !GetAtt HelloWorldFunction.Arn
  HelloWorldFunctionIamRole:
    Description: "Implicit IAM Role created for Hello World function"
    Value: !GetAtt HelloWorldFunctionRole.Arn

こんな感じですね。

そのままデプロイしてみる

最初にビルドしてみます。

$ samlocal build

すると、ベータ機能を有効にするかどうかを聞かれるので

Your template contains a resource with logical ID "ServerlessRestApi", which is a reserved logical ID in AWS SAM. It could result in unexpected behaviors and is not recommended.
Using esbuild for bundling Node.js and TypeScript is a beta feature.
Please confirm if you would like to proceed with using esbuild to build your function.
You can also enable this beta feature with 'sam build --beta-features'. [y/N]:

ここでyと答えると、ビルドが行われます。

Experimental features are enabled for this session.
Visit the docs page to learn more about the AWS Beta terms https://aws.amazon.com/service-terms/.

Building codeuri: /path/to/sam-typescript-support-hello-world/hello-world runtime: nodejs16.x metadata: {'BuildMethod': 'esbuild', 'BuildProperties': {'Minify': True, 'Target': 'es2020', 'Sourcemap': True, 'EntryPoints': ['app.ts']}} architecture: x86_64 functions: HelloWorldFunction
Running NodejsNpmEsbuildBuilder:CopySource
Running NodejsNpmEsbuildBuilder:NpmInstall
Running NodejsNpmEsbuildBuilder:CleanUp
Clean up action: .aws-sam/deps/04eafaf7-b6a4-4610-9814-286e40fa522e does not exist and will be skipped.
Running NodejsNpmEsbuildBuilder:EsbuildBundle
Running NodejsNpmEsbuildBuilder:CopyDependencies

Build Succeeded

Built Artifacts  : .aws-sam/build
Built Template   : .aws-sam/build/template.yaml

Commands you can use next
=========================
[*] Validate SAM template: sam validate
[*] Invoke Function: sam local invoke
[*] Test Function in the Cloud: sam sync --stack-name {stack-name} --watch
[*] Deploy: sam deploy --guided

ビルド完了。

いったん、ビルド結果を削除します。

$ rm -rf .aws-sam

このようにコマンド実行の度に確認を求められるのもなんなので、今回はsamconfig.tomlにベータ機能を有効にする設定をしておきました。

samconfig.toml

version=0.1

[default.build.parameters]
beta_features = true

[default.sync.parameters]
beta_features = true

この部分ですね。

esbuild による Node.js Lambda関数の構築 (プレビュー) / esbuild プレビュー機能の使用

なお、ドキュメントに書かれているような以下の内容で素直に書くと

[default.build.parameters]
beta_features = true

[default.sync.parameters]
beta_features = true

versionが記載されていないので以下のようにエラーになります。

$ yes | samlocal sync --stack-name $(uuidgen) --region us-east-1 --no-dependency-layer
Error: Error reading configuration: 'version' key is not present or is in unrecognized format.

では、デプロイしてみます。

$ yes | samlocal sync --stack-name $(uuidgen) --region us-east-1 --no-dependency-layer

デプロイが完了しました。

        Deploying with following values
        ===============================
        Stack name                   : 36149b33-72d1-4e57-a136-6c7f3968f14f
        Region                       : us-east-1
        Disable rollback             : False
        Deployment s3 bucket         : aws-sam-cli-managed-default-samclisourcebucket-3777f670
        Capabilities                 : ["CAPABILITY_NAMED_IAM", "CAPABILITY_AUTO_EXPAND"]
        Parameter overrides          : {}
        Signing Profiles             : null

Initiating deployment
=====================

2022-08-04 01:38:14 - Waiting for stack create/update to complete

CloudFormation events from stack operations (refresh every 0.5 seconds)
---------------------------------------------------------------------------------------------------------------------------------------------------------------------
ResourceStatus                            ResourceType                              LogicalResourceId                         ResourceStatusReason
---------------------------------------------------------------------------------------------------------------------------------------------------------------------
CREATE_COMPLETE                           AWS::CloudFormation::Stack                HelloWorldFunctionRole                    -
CREATE_COMPLETE                           AWS::CloudFormation::Stack                ServerlessRestApi                         -
CREATE_COMPLETE                           AWS::CloudFormation::Stack                HelloWorldFunction                        -
CREATE_COMPLETE                           AWS::CloudFormation::Stack                HelloWorldFunctionHelloWorldPermissionP   -
                                                                                    rod
UPDATE_COMPLETE                           AWS::CloudFormation::Stack                HelloWorldFunctionRole                    -
CREATE_COMPLETE                           AWS::CloudFormation::Stack                ServerlessRestApiDeployment47fc2d5f9d     -
UPDATE_COMPLETE                           AWS::CloudFormation::Stack                ServerlessRestApi                         -
UPDATE_COMPLETE                           AWS::CloudFormation::Stack                HelloWorldFunction                        -
CREATE_COMPLETE                           AWS::CloudFormation::Stack                HelloWorldFunctionHelloWorldPermissionP   -
                                                                                    rod
CREATE_COMPLETE                           AWS::CloudFormation::Stack                ServerlessRestApiProdStage                -
CREATE_COMPLETE                           AWS::CloudFormation::Stack                ServerlessRestApiDeployment47fc2d5f9d     -
UPDATE_COMPLETE                           AWS::CloudFormation::Stack                ServerlessRestApiProdStage                -
CREATE_COMPLETE                           AWS::CloudFormation::Stack                36149b33-72d1-4e57-a136-6c7f3968f14f      -
---------------------------------------------------------------------------------------------------------------------------------------------------------------------
CloudFormation outputs from deployed stack
------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Outputs
------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Key                 HelloWorldApi
Description         API Gateway endpoint URL for Prod stage for Hello World function
Value               https://3lt1ixk1uk.execute-api.amazonaws.com:4566/Prod/hello/

Key                 HelloWorldFunction
Description         Hello World Lambda Function ARN
Value               arn:aws:lambda:us-east-1:000000000000:function:36149b33-72d1-4e57-a136-6c7f3968f14-HelloWorldFunction-cb0cac49

Key                 HelloWorldFunctionIamRole
Description         Implicit IAM Role created for Hello World function
Value               arn:aws:iam::000000000000:role/36149b33-72d1-4e57-a136-6c7f396-HelloWorldFunctionRole-722273d9
------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Stack creation succeeded. Sync infra completed.

Amazon API GatewayREST APIのIDを取得して

$ REST_API_ID=$(awslocal apigateway get-rest-apis --query 'reverse(sort_by(items[], &createdDate))[0].id' --output text)

動作確認…したら、1回目はタイムアウト…。

$ curl http://localhost:4566/restapis/$REST_API_ID/Prod/_user_request_/hello
{"errorMessage": "2022-08-03T16:41:02.682Z 997ef993-61d0-15cc-05dc-b9b69b20c6ad Task timed out after 3.00 seconds"}

もう1度アクセスしたら、確認できました。

$ curl http://localhost:4566/restapis/$REST_API_ID/Prod/_user_request_/hello
{"message":"hello world"}

まずは、デプロイ完了ですね。

もう少しカスタマイズしてみる

これで終わってもなんなので、生成されたプロジェクトを変更していってみましょう。

AWS Lambda関数があるディレクトリ内に移動。

$ cd hello-world

依存ライブラリをインストール。これで、package-lock.jsonも作成されます。

$ npm i

テストも確認。

$ npm test

こんな感じになりました。

> hello_world@1.0.0 test
> npm run compile && npm run unit


> hello_world@1.0.0 compile
> tsc


> hello_world@1.0.0 unit
> jest

 PASS  tests/unit/test-handler.test.ts
  Unit test for app handler
    ✓ verifies successful response (3 ms)

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |     100 |    88.88 |     100 |     100 |
 app.ts   |     100 |    88.88 |     100 |     100 | 8
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.742 s
Ran all test suites.

コンパイル用のコマンドも用意されているんですね。

$ npm run compile

> hello_world@1.0.0 compile
> tsc

lint。

$ npm run lint

> hello_world@1.0.0 lint
> eslint '*.ts' --quiet --fix

なんとなく、AWS Lambda関数本体はsrcディレクトリに移すことにしました。

$ mkdir src
$ mv app.ts src/

template.yamlCodeUriは変更しません。CodeUriで指定したディレクトリ直下に、package.jsonpackage-lock.jsonが配置されていないと
困るので。

  HelloWorldFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: hello-world/
      Handler: app.lambdaHandler

代わりに、MetadataEntryPointssrc配下であることを指定。また、package-lock.jsonも生成されたのでUseNpmCiTrueにして
おきます。

    Metadata: # Manage esbuild properties
      BuildMethod: esbuild
      BuildProperties:
        Minify: true
        Target: "es2020"
        Sourcemap: true
        EntryPoints: 
        - src/app.ts
        UseNpmCi: True

package.jsonscriptsも少しカスタマイズして、Prettierを使えるようにしたり、TypeScriptのビルドで--watchできるようにしておきました。

  "scripts": {
    "unit": "jest",
    "lint": "eslint '*.ts' --quiet --fix",
    "compile": "tsc",
    "compile:watch": "tsc --watch",
    "test": "npm run compile && npm run unit",
    "format": "prettier --write src tests"
  },

tsconfig.jsonでは以下のようにnoEmittrueになっているので、tscでTypeScriptをビルドしてもJavaScriptファイルが生成されることは
ありません。

      "noEmit": true,

TypeScriptからJavaScriptを生成する役割を負うのは、AWS SAMにより実行されるesbuildです。もともと定義されているcompile、そして
今回追加したようなtsc--watchの組み合わせで、型のチェックなどを行うようにするという感じですね。

esbuildは、型情報の確認を行わないので。

次に、ソースコードも変えていきましょう。

ひとつ、TypeScriptファイルを追加します。

src/decorate.ts

export const decorateMessage = (message: string) => {
    return `★★★ ${message} ★★★`;
};

このファイルを使うように、app.tsを修正します。

src/app.ts

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { decorateMessage } from './decorate';

/**
 *
 * Event doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format
 * @param {Object} event - API Gateway Lambda Proxy Input Format
 *
 * Return doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html
 * @returns {Object} object - API Gateway Lambda Proxy Output Format
 *
 */

export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
    let response: APIGatewayProxyResult;
    try {
        response = {
            statusCode: 200,
            body: JSON.stringify({
                message: decorateMessage(
                    event.queryStringParameters != null
                        ? (event.queryStringParameters['message'] as string)
                        : 'hello world',
                ),
            }),
        };
    } catch (err) {
        console.log(err);
        response = {
            statusCode: 500,
            body: JSON.stringify({
                message: 'some error happened',
            }),
        };
    }

    return response;
};

追加したファイルに渡すパラメーターは、QueryStringから取得するようにしています。

            body: JSON.stringify({
                message: decorateMessage(
                    event.queryStringParameters != null
                        ? (event.queryStringParameters['message'] as string)
                        : 'hello world',
                ),
            }),

これに合わせて、テストコードも修正。

tests/unit/test-handler.test.ts

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { lambdaHandler } from '../../src/app';

describe('Unit test for app handler', function () {
    it('verifies successful response', async () => {
        const event: APIGatewayProxyEvent = {
            httpMethod: 'get',
            body: '',
            headers: {},
            isBase64Encoded: false,
            multiValueHeaders: {},
            multiValueQueryStringParameters: {},
            path: '/hello',
            pathParameters: {},
            queryStringParameters: { message: 'Hello TypeScript!!' },
            requestContext: {
                accountId: '123456789012',
                apiId: '1234',
                authorizer: {},
                httpMethod: 'get',
                identity: {
                    accessKey: '',
                    accountId: '',
                    apiKey: '',
                    apiKeyId: '',
                    caller: '',
                    clientCert: {
                        clientCertPem: '',
                        issuerDN: '',
                        serialNumber: '',
                        subjectDN: '',
                        validity: { notAfter: '', notBefore: '' },
                    },
                    cognitoAuthenticationProvider: '',
                    cognitoAuthenticationType: '',
                    cognitoIdentityId: '',
                    cognitoIdentityPoolId: '',
                    principalOrgId: '',
                    sourceIp: '',
                    user: '',
                    userAgent: '',
                    userArn: '',
                },
                path: '/hello',
                protocol: 'HTTP/1.1',
                requestId: 'c6af9ac6-7b61-11e6-9a41-93e8deadbeef',
                requestTimeEpoch: 1428582896000,
                resourceId: '123456',
                resourcePath: '/hello',
                stage: 'dev',
            },
            resource: '',
            stageVariables: {},
        };
        const result: APIGatewayProxyResult = await lambdaHandler(event);

        expect(result.statusCode).toEqual(200);
        expect(result.body).toEqual(
            JSON.stringify({
                message: '★★★ Hello TypeScript!! ★★★',
            }),
        );
    });
});

app.tsを移動したのでそれに合わせ、

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { lambdaHandler } from '../../src/app';

QueryStringの追加と

            path: '/hello',
            pathParameters: {},
            queryStringParameters: { message: 'Hello TypeScript!!' },

exceptの修正。

        expect(result.statusCode).toEqual(200);
        expect(result.body).toEqual(
            JSON.stringify({
                message: '★★★ Hello TypeScript!! ★★★',
            }),
        );

フォーマットして

$ npm run format

テストを実行して確認。

$ npm test

OKだったので、ひとつ上のディレクトリに戻って

$ cd ..

デプロイ。

$ yes | samlocal sync --stack-name $(uuidgen) --region us-east-1 --no-dependency-layer

REST APIのIDを取得して

$ REST_API_ID=$(awslocal apigateway get-rest-apis --query 'reverse(sort_by(items[], &createdDate))[0].id' --output text)

動作確認。

$ curl http://localhost:4566/restapis/$REST_API_ID/Prod/_user_request_/hello?message=Hello
{"message":"★★★ Hello ★★★"}

OKですね。

とりあえず、こんなところでしょうか。

まとめ

プレビュー段階ですが、AWS SAMのTypeScriptサポートを試してみました。

ベータ機能を有効にする必要があったり、テンプレートから生成されたプロジェクトはいろいろ変更した方が良さそうですが、とっかかりとしては
良いかなと。