[AWS CDK] L2 Constructから直接アクセスできないプロパティにアクセスする

<!-- [AWS CDK] L2 Constructから直接アクセスできないプロパティにアクセスする -->

[AWS CDK Tipsシリーズ](https://tmokmss.hatenablog.com/entry/aws_cdk_tips)の記事です。

## はじめに
AWS CDKのL2 Constructは、CloudFormationのリソースを抽象化した概念です。
複雑なインフラを簡単に定義できるため非常に便利な存在ですが、時に抽象化が原因でリソースを扱いづらくなる場合があります。
例えば、L2 Constructの `props` では設定できないCloudFormationの設定値が存在するような場合です。

本記事では、上記のような場合に、CDKの機能を用いてそれらの設定値を変更する方法を紹介します。

## 問題となるケース
例として、 `aws-apigatewayv2.WebSocketApi` というL2 Constructを使い、Lambda Authorizerが設定されたWebSocket APIを定義する事を考えます。

`WebSocketApi` Constructは、現状 `props` など直接的なインターフェースからAuthorizerを設定することができません。 [Issue #13869](https://github.com/aws/aws-cdk/issues/13869)
CloudFormationで設定できることは[確認できています](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigatewayv2-authorizer.html)。
これを解決する最高の方法は、上記のIssueを解決するPull Requestを送る方法ですが、それほど時間もないので簡単な方法で一旦間に合わせたいです。
このような時、どうすれば良いでしょうか？

上記はかなり特有の状況ですが、CDKを使っていると似たような状況に出くわした方も多いのではと思います。
一般化すると、**CDKのL2 Constructから直接設定できないがL1 Constructでは設定できる値をどう設定するか？** という問題になります。

以下では、そのようなケースに普遍的に適用可能な方法を紹介します。

## 対処方法
CDKでは、`findChild` などのメソッドを使うことで、L2 Constructの内部で定義されているL1 Constructにアクセスできます。
下記に例を示します。

```ts
import * as cdk from "@aws-cdk/core";
import * as agw from "@aws-cdk/aws-apigatewayv2";

export class ExampleStack extends cdk.Stack {
    constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);

        // WebSocketApiの定義(通常通り)
        const api = new agw.WebSocketApi(this, `WebSocket`);

        // Construct treeからCfnRoute (L1 Construct)を取得
        const connectRoute = api.node.findChild("$connect-Route").node.defaultChild as agw.CfnRoute;
        // 取得したCfnRouteの設定値を編集
        connectRoute.authorizationType = "CUSTOM";
        connectRoute.authorizerId = createAuthorizer().ref;
    }

    // Authorizerを返す関数 (省略)
    createAuthorizer(): agw.CfnAuthorizer {
        ...
    }
}
```

これにより、本来 `WebSocketApi` からは設定できなかった `$connect` ルートの `authorizationType` や `authorizerId` を設定しています。

## 解説
上記のコードで何をやっているのかを解説します。

### Constructツリー
CDKでは、Constructは[ツリーを形成します](https://docs.aws.amazon.com/cdk/latest/guide/constructs.html#constructs_tree)。
Constructを定義する際は、下記のように 第1引数で `scope` を指定します。
この `scope` で指定したConstructを親となり、定義したConstructが子としてツリーに追加されていきます。

```ts:Constructクラスのコンストラクタ
class Construct {
    constructor(scope: Construct, id: string);
}
```

CDKのアプリケーションで定義されるConstructは、全てこのツリーの中に入っています。
rootノードは `App` (よく `new cdk.App()` と書くやつです)で、そこから `Stack` があり、その中の `Construct` と続きます。

L2 ConstructはL1 Constructを束ねるツリーとみなせます。
このため、あるL2 Constructの子ノードを見れば、その中で定義されているL1 Constructが必ず存在するわけです。

ツリーのノードとしてのインターフェースは、Constructの [`node` プロパティ](https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_core.Construct.html#node) で取得できます。

下記のコードでは、`findChild` 関数によりこのツリーを辿って所望のL1 Constructを取得しています。

```ts:上記コード例の抜粋
const connectRoute = api.node.findChild("$connect-Route").node.defaultChild as agw.CfnRoute;
```

[`defaultChild` プロパティ](https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_core.ConstructNode.html#defaultchild)は、あるConstructに最大で1つ存在する特別なノードです。そのノードの `id` が `Resource` または `Default` のノードが `defaultChild` で取得されます。`id` については次の節でまとめます。L2 Constructに直に対応するL1 Constructが割り当てられる場合が多いようです。(`WebSocketRoute` → `CfnRoute`、 `Bucket` → `CfnBucket` など)

`defaultChild` プロパティから取得できるという意味では特別ですが、その他の扱いは通常のノードと変わりません。

### ツリー内ノードのID
上記で `findChild` 関数や `defaultChild` プロパティの意味を説明しました。
それでは、 `findChild` 関数の引数に渡している `$connect-Route` とは何でしょうか？

これは、Constructを定義する際に渡している、第2引数 `id` です。

```ts:Constructクラスのコンストラクタ
class Construct {
    constructor(scope: Construct, id: string);
}
```

このIDはツリーのあるノードの直接の子の間でユニークなため、あるConstructに対して `id` を指定して `findChild` すると、一意のConstructを取得することができます。

所望のConstructに設定されている `id` を知るためには、コードを読むのが正攻法です。
(例えば、上記WebSocketの例だと [この辺りを読むとわかります](https://github.com/aws/aws-cdk/blob/5378a7770d5897737ecf4da25d47747c2bbddd94/packages/%40aws-cdk/aws-apigatewayv2/lib/websocket/api.ts#L124)。)

より簡単な方法として、実際に生成されたテンプレートのMetadataを見るという方法もあります。

```yml:生成されたテンプレート
  websocketapiconnectRoute50EA91CF:
    Type: AWS::ApiGatewayV2::Route
    Properties:
    ...
    Metadata:
      aws:cdk:path: ExampleStack/WebSocket/$connect-Route/Resource
```

この場合、下記のように対応します

* `ExampleStack`: Stackの `id`
* `WebSocket`: `WebSocketApi` Constructの `id`
* `$connect-Route`: `WebSocketApi` Constructの中の `WebSocketRoute` Constructの `id`
* `Resource`: `WebSocketRoute` Constructの中の `CfnRoute` Constructの `id`

### ツリーと実際のリソースの対応
先程でてきたコードを上の対応に沿って書き直すと、下記のようになります。

```ts:上記コード例の抜粋
const api = new agw.WebSocketApi(this, `WebSocket`);
const connectRouteL2 = api.node.findChild("$connect-Route") as agw.WebSocketRoute;
const connectRoute = connectRouteL2.node.defaultChild as agw.CfnRoute;
```

`defaultChild` は `id` が `Resource` か `Default` のノードになることに注意してください。

取り出してきた`child`の型は `IConstruct` になるため、 `as` で必要な型にキャストすると良いでしょう。

### `addOverride` メソッド
今回は以下のコードでL1 Constructのプロパティを編集しましたが、まれにこれでは変更が反映されない場合があります。L2 Construct内で、この変更の後に更にプロパティが上書きされる場合があるためです。

```ts
   connectRoute.authorizationType = "CUSTOM";
   connectRoute.authorizerId = createAuthorizer().ref;
```

そのような場合は、 [`addOverride` 系メソッド](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.CfnResource.html#addwbroverridepath-value) を利用しましょう。このメソッドを使うと、CloudFormationのプロパティ名を指定して任意のプロパティを上書きできます。上記の方法とは異なりTypeScriptの補完は効かないため、プロパティ名を間違えないように注意しましょう。

```ts
   connectRoute.addOverride("Properties.AuthorizationType", "CUSTOM");
   // Properties用のバージョンもある
   connectRoute.addPropertyOverride("AuthorizationType", "CUSTOM");
```



## まとめ
上記の方法で、任意のL2 Constructから、所望のConstructを取り出すことができます。
これにより、L2 Constructの抽象性とL1 Constructの柔軟性、どちらのメリットも享受することが可能です。
抽象化を恐れることなくCDKを使っていきましょう！