API Gateway の HTTP API を AWS CDK で作成する

CDKを使用してAPI GatewayのHTTP APIを作成するサンプルです。
認証はCognitoを使用して認可コードグラントを利用します。

## AWS CDK
```console
% npm install @aws-cdk/aws-apigatewayv2-alpha @aws-cdk/aws-apigatewayv2-authorizers-alpha @aws-cdk/aws-apigatewayv2-integrations-alpha
```

cognitoドメインのdomainPrefixはユニークな文字列である必要があります。cognitoドメインの代わりにカスタムドメインを使用することも可能です。

```ts:bin/sample.ts
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { SampleStack } from '../lib/sample-stack';

const app = new cdk.App();
const contextKey = process.env.ENV === 'prod' ? 'prod' : 'dev';
const context = app.node.tryGetContext(contextKey);

new SampleStack(app, 'SampleStack', { context });
```

```ts:lib/sample-stack.ts
import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as lambdaNodeJs from 'aws-cdk-lib/aws-lambda-nodejs';
import * as apigw from '@aws-cdk/aws-apigatewayv2-alpha';
import * as authz from '@aws-cdk/aws-apigatewayv2-authorizers-alpha';
import * as intg from '@aws-cdk/aws-apigatewayv2-integrations-alpha';

export type SampleStackProps = cdk.StackProps & { context: any };

export class SampleStack extends cdk.Stack {
    private context: any;

    constructor(scope: Construct, id: string, props: SampleStackProps) {
        super(scope, id, props);
        this.context = props.context;

        // Cognito User Pool & User Pool Client
        const userPool = this.createUserPool(this.context.cognito.domainPrefix);
        const userPoolClient = this.createUserPoolClient(userPool, this.context.cognito.callbackUrls);

        // Authorizer
        const authorizer = this.createAuthorizer(userPool, userPoolClient);

        // Lambda
        const func = this.createFunc();

        // API Gateway
        this.createApiGateway(func, authorizer);
    }

    /**
     * Cognito ユーザープールの作成
     * {@link UserPool | https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cognito.UserPool.html}
     * {@link UserPoolDomainOptions | https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cognito.UserPoolDomainOptions.html}
     */
    private createUserPool(cognitoDomainPrefix: string): cognito.IUserPool {
        const userPool = new cognito.UserPool(this, 'UserPool', {
            selfSignUpEnabled: false,
            standardAttributes: {
                email: { required: true, mutable: true },
                phoneNumber: { required: false },
            },
            autoVerify: { email: true },
            signInAliases: { email: true },
            accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
            removalPolicy: cdk.RemovalPolicy.DESTROY,
        });

        userPool.addDomain('UserPoolDomain', {
            // cognitoドメインまたはカスタムドメインが選択可能
            cognitoDomain: { domainPrefix: cognitoDomainPrefix },
            // customDomain: {}
        });
        return userPool;
    }

    /**
     * Cognito ユーザープールクライアントの作成
     * {@link UserPoolClient | https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cognito.UserPoolClient.html}
     */
    private createUserPoolClient(userPool: cognito.IUserPool, callbackUrls: string[]): cognito.IUserPoolClient {
        return userPool.addClient('client', {
            generateSecret: true,
            oAuth: {
                callbackUrls,
                scopes: [cognito.OAuthScope.OPENID],
                flows: {
                    authorizationCodeGrant: true,
                    clientCredentials: false,
                    implicitCodeGrant: false,
                },
            },
            authFlows: {
                adminUserPassword: false,
                custom: false,
                userPassword: false,
                userSrp: false,
            },
        });
    }

    /**
     * @description API Gateway用オーソライザーの作成
     * {@link class HttpUserPoolAuthorizer | https://docs.aws.amazon.com/cdk/api/v2/docs/@aws-cdk_aws-apigatewayv2-authorizers-alpha.HttpUserPoolAuthorizer.html}
     */
    private createAuthorizer(
        userPool: cognito.IUserPool,
        userPoolClient: cognito.IUserPoolClient
    ): apigw.IHttpRouteAuthorizer {
        return new authz.HttpUserPoolAuthorizer('Authorizer', userPool, {
            userPoolClients: [userPoolClient],
        });
    }

    /**
     * @description Lambdaの作成
     * {@link ParamsAndSecretsLayerVersion | https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda.ParamsAndSecretsLayerVersion.html}
     * {@link NodejsFunction | https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda_nodejs.NodejsFunction.html}
     */
    private createFunc(): lambdaNodeJs.NodejsFunction {
        return new lambdaNodeJs.NodejsFunction(this, 'SampleFunc', {
            entry: 'src/lambda/sample/index.ts',
            handler: 'handler',
            runtime: lambda.Runtime.NODEJS_18_X,
            timeout: cdk.Duration.minutes(5),
        });
    }

    /**
     * @description ラムダの作成
     * {@link class HttpApi | https://docs.aws.amazon.com/cdk/api/v2/docs/@aws-cdk_aws-apigatewayv2-alpha.HttpApi.html}
     */
    private createApiGateway(
        func: lambdaNodeJs.NodejsFunction,
        authorizer: apigw.IHttpRouteAuthorizer
    ): apigw.IHttpApi {
        const httpApi = new apigw.HttpApi(this, 'SampleHttpApi');
        const integration = new intg.HttpLambdaIntegration('Integration', func);
        httpApi.addRoutes({
            methods: [apigw.HttpMethod.GET],
            path: '/sample',
            authorizer,
            integration,
        });
        return httpApi;
    }
}
```

```ts:src/lambda/sample/index.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';

export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> {
    return {
        statusCode: 200,
        body: '',
    };
}
```

```jsonc:cdk.json
{
    // 略
    "context": {
        // 略
        "dev": {
            "cognito": {
                "domainPrefix": "taaazyyy-sample-230818",
                "callbackUrls": ["https://example.com/callback"]
            }
        },
        "prod": {
            "cognito": {
                "domainPrefix": "taaazyyy-sample-230818",
                "callbackUrls": ["https://example.com/callback"]
            }
        }
    }
}
```

## Postmanで検証
認可コードグラントを使用した時のPostmanを使った検証例です。
あらかじめユーザープールにユーザーを作成しておく必要があります。

PostmanのHTTPリクエスト作成ページでのAuthorizationタブで下記のように設定します。

- Grant Type: Authorization Code
- Callback URL: ※ユーザープールクライアント作成時に指定したコールバックURLのいずれか
- Auth URL: `https://{Cognitoドメイン}/oauth2/authorize`
- Access Token URL：　`https://{Cognitoドメイン}/oauth2/token`
- Client ID: Cognitoのアプリケーションクライアントに表示されているクライアントID
- Client Secret: Cognitoのアプリケーションクライアントに表示されているクライアントシークレット
- Scope: openid

![Postman Authorization](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125621/93b7b315-8b03-b461-f2f4-eb104a65f2b0.png)


「Get New Access Token」ボタンをクリックするとログイン画面が表示されるので、ログインします。
認証に成功したらAPI Gatewayのエンドポイントを入力してSendボタンをクリックして検証を行うことができます。

![Cognito Login](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125621/3ce3304c-f95b-b05f-e39f-2ccb168a8da9.png)

## テスト
<details><summary>test/sample.test.ts</summary>

```typescript:test/sample.test.ts
import * as cdk from 'aws-cdk-lib';
import { Template, Match } from 'aws-cdk-lib/assertions';
import { SampleStack } from '../lib/sample-stack';

const context = {
    cognito: {
        domainPrefix: 'taaazyyy-sample-test',
        callbackUrls: ['https://example.com/callback'],
    },
};

test('UserPool', () => {
    const app = new cdk.App();
    const stack = new SampleStack(app, 'SampleStack', { context });
    const template = Template.fromStack(stack);

    // User Pool
    template.resourceCountIs('AWS::Cognito::UserPool', 1);
    template.hasResourceProperties('AWS::Cognito::UserPool', {
        Schema: [
            { Mutable: true, Name: 'email', Required: true },
            {
                Mutable: true,
                Name: 'phone_number',
                Required: false,
            },
        ],
        AutoVerifiedAttributes: ['email'],
        UsernameAttributes: ['email'],
        AccountRecoverySetting: {
            RecoveryMechanisms: [{ Name: 'verified_email', Priority: 1 }],
        },
    });

    // User Pool Domain
    template.resourceCountIs('AWS::Cognito::UserPoolDomain', 1);
    template.hasResourceProperties('AWS::Cognito::UserPoolDomain', {
        Domain: 'taaazyyy-sample-test',
        UserPoolId: Match.anyValue(),
    });

    // User Pool Client
    template.resourceCountIs('AWS::Cognito::UserPoolClient', 1);
    template.hasResourceProperties('AWS::Cognito::UserPoolClient', {
        GenerateSecret: true,
        CallbackURLs: ['https://example.com/callback'],
        AllowedOAuthScopes: ['openid'],
        AllowedOAuthFlows: ['code'],
        AllowedOAuthFlowsUserPoolClient: true,
        ExplicitAuthFlows: ['ALLOW_REFRESH_TOKEN_AUTH'],
    });
});

test('Lambda', () => {
    const app = new cdk.App();
    const stack = new SampleStack(app, 'SampleStack', { context });
    const template = Template.fromStack(stack);

    // Lambda
    template.resourceCountIs('AWS::Lambda::Function', 1);
    template.hasResourceProperties('AWS::Lambda::Function', {
        Runtime: 'nodejs18.x',
        Timeout: 300,
    });
});

test('ApiGateway', () => {
    const app = new cdk.App();
    const stack = new SampleStack(app, 'SampleStack', { context });
    const template = Template.fromStack(stack);

    // API Gateway
    template.resourceCountIs('AWS::ApiGatewayV2::Api', 1);
    template.hasResourceProperties('AWS::ApiGatewayV2::Api', {
        Name: 'SampleHttpApi',
        ProtocolType: 'HTTP',
    });

    // Route
    template.resourceCountIs('AWS::ApiGatewayV2::Route', 1);
    template.hasResourceProperties('AWS::ApiGatewayV2::Route', {
        RouteKey: 'GET /sample',
    });

    // Authorizer
    template.resourceCountIs('AWS::ApiGatewayV2::Authorizer', 1);
    template.hasResourceProperties('AWS::ApiGatewayV2::Authorizer', {
        AuthorizerType: 'JWT',
    });
});
```

</details>

## エラー
### ERESOLVE unable to resolve dependency tree
aws-cdk-libと@aws-cdk/xxxxでバージョンが異なるとnpm installで下記のエラーが発生することがあります。
インストールし直すなどバージョンを揃えることで解消できます。

```
npm ERR! code ERESOLVE
npm ERR! ERESOLVE unable to resolve dependency tree
npm ERR! 
npm ERR! While resolving: sample@0.1.0
npm ERR! Found: aws-cdk-lib@2.91.0
npm ERR! node_modules/aws-cdk-lib
npm ERR!   aws-cdk-lib@"2.91.0" from the root project
npm ERR! 
npm ERR! Could not resolve dependency:
npm ERR! peer aws-cdk-lib@"2.92.0" from @aws-cdk/aws-apigatewayv2-alpha@2.92.0-alpha.0
npm ERR! node_modules/@aws-cdk/aws-apigatewayv2-alpha
npm ERR!   @aws-cdk/aws-apigatewayv2-alpha@"*" from the root project
```

## 参考
- AWS CDK
    - [AWS CDK Intro Workshop](https://catalog.us-east-1.prod.workshops.aws/workshops/99731164-1d19-4d2e-9319-727a130e2d57/ja-JP)
    - [API Reference · AWS CDK](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-construct-library.html)