AWS APIGateway × OpenAPI (3. 出力編)

2022-08-02

thumbnail

OpenAPI定義をCDKのREST API定義に統合するライブラリを開発しています。 これはライブラリの開発過程を紹介するシリーズのブログ投稿第3弾です。

背景

本シリーズ最初のブログ投稿で、以下の課題を残しました。

いつ実際にOpenAPI定義ファイルを出力すべきか?

  • 保存のための関数をユーザーが明示的に呼び出さなければならないのでしょうか?
  • それともCDKがCloudFormationテンプレートに対してやるようにOpenAPIの定義ファイルもいつのまにやら保存されようにできるでしょうか?

このブログ投稿では、後者のケース(ユーザの明示的な呼び出しなしにライブラリにOpenAPI定義を出力させる)に挑みます。

CDKはいつCloudFormationテンプレートを出力するのか?

AWS Cloud Development Kit (CDK)がCloudFormationテンプレートを出力するタイミングをトラップできれば、ユーザの明示的な呼び出しなしにOpenAPI定義を出力できそうです。 ということでOpenAPI定義を出力するのに応用できそうなフックを探してCDKのソースコード*を眺めていくことにします。 まずはcdkコマンドを見てみましょう。 長いコードレビューをスキップしたい場合は、節「フック」まで読み飛ばしてもよいです。

* 当時最新のCDKバージョン2.34.2を分析しました。 他のCDKバージョンでは状況は違っているかもしれません。

cdkコマンドはどこに定義されているのか?

cdkコマンドはCDKレポジトリのpackages/aws-cdkフォルダに含まれています。 CDKのレポジトリは巨大なので、cdkコマンドがどこで定義されているかというのをよくわかっていませんでした。 このブログ投稿では、packages/aws-cdkフォルダをaws-cdkと省略します。

synthとdeployサブコマンドは何をするのか?

CloudFormationテンプレートを生成するという点では、以下の2つのcdkサブコマンドが関心の対象です。

cdkコマンドのコマンドラインインターフェイスはaws-cdk/lib/cli.tsに定義されています。 yargsの設定を行う長い行の中に、synthdeploy サブコマンドの定義があります。

cdkのサブコマンドは結果的にaws-cdk/lib/cdk-toolkit.tsに定義されるCdkToolkitの対応するメソッドを呼び出すことになります。

CdkToolkit#synth

定義: aws-cdk/lib/cdk-toolkit.ts#L517-L545

aws-cdk/lib/cdk-toolkit.ts#L518CdkToolkit#selectStacksForDiffを呼び出します。

    const stacks = await this.selectStacksForDiff(stackNames, exclusively, autoValidate);

メソッド名からは明白ではありませんが、CdkToolkit#selectionStacksForDiffはCloudFormationテンプレートの生成において重要な働きをします。 aws-cdk/lib/cdk-toolkit.ts#L621にてCdkToolkit#assemblyを呼び出すことになります。

    const assembly = await this.assembly();
CdkToolkit#deploy

定義: aws-cdk/lib/cdk-toolkit.ts#L126-L282

aws-cdk/lib/cdk-toolkit.ts#L140CdkToolkit#selectStacksForDeployを呼び出します。

    const stacks = await this.selectStacksForDeploy(options.selector, options.exclusively, options.cacheCloudAssembly);

CdkToolkit#synthと同様、CdkToolkit#selectStacksForDeployはCloudFormationテンプレートの生成において重要な働きをします。 aws-cdk/lib/cdk-toolkit.ts#L608にて同じくCdkToolkit#assemblyの呼び出しとなります。

    const assembly = await this.assembly(cacheCloudAssembly);
CdkToolkit#assembly

定義: aws-cdk/lib/cdk-toolkit.ts#L690-L692

  private assembly(cacheCloudAssembly?: boolean): Promise<CloudAssembly> {
    return this.props.cloudExecutable.synthesize(cacheCloudAssembly);
  }

CloudExecutable#synthesizeの呼び出しと等価です。 CloudExecutableaws-cdk/lib/api/cxapp/cloud-executableに定義されています。

CloudExecutable#synthesize

定義: aws-cdk/lib/api/cxapp/cloud-executable.ts#L63-L68

aws-cdk/lib/api/cxapp/cloud-executable.ts#L65にてCloudExecutable#doSynthesizeを呼び出します。

      this._cloudAssembly = await this.doSynthesize();
CloudExecutable#doSynthesize

定義: aws-cdk/lib/api/cxapp/cloud-executable.ts#L70-L124

実際の合成はaws-cdk/lib/api/cxapp/cloud-executable.ts#L79で行われます。

      const assembly = await this.props.synthesizer(this.props.sdkProvider, this.props.configuration);

this.props.synthesizeraws-cdk/lib/api/cxapp/cloud-executable.ts#L14に定義されるSynthesizerです。

type Synthesizer = (aws: SdkProvider, config: Configuration) => Promise<cxapi.CloudAssembly>;

aws-cdk/lib/cli.tsのコンテキストでは、aws-cdk/lib/cli.ts#L305-L309で初期化されているようにthis.props.synthesizerは常にexecProgramです。

  const cloudExecutable = new CloudExecutable({
    configuration,
    sdkProvider,
    synthesizer: execProgram,
  });

execProgramaws-cdk/lib/api/cxapp/exec.ts#L12-L136に定義されています。 以下の行から分かるように、cdkコマンドのappオプションに指定したコマンドを実行します(それぞれaws-cdk/lib/api/cxapp/exec.ts#L54, aws-cdk/lib/api/cxapp/exec.ts#L65, aws-cdk/lib/api/cxapp/exec.ts#L86)。

  const app = config.settings.get(['app']);
  const commandLine = await guessExecutable(appToArray(app));
  await exec(commandLine.join(' '));

cdkコマンドにappオプションなんて指定したことがないと思われるかもしれません。 CDKプロジェクトをcdk initコマンドで初期化すると、cdk.jsonファイルが作成され、そこにappオプションのデフォルト値が保存されます。 cdk.jsonファイルを覗くと、以下のような行が見つかります。

  "app": "npx ts-node --prefer-ts-exts bin/cdk.ts",

このコマンド(npx ts-node --prefer-ts-exts bin/cdk.ts)がexecProgramの実行しているものです。

appオプションに渡されたコマンドを実行した後、execProgramcdkコマンドのoutputオプションに指定した出力フォルダから生成物を読み込みます(それぞれaws-cdk/lib/api/cxapp/exec.ts#L67, aws-cdk/lib/api/cxapp/exec.ts#L78, aws-cdk/lib/api/cxapp/exec.ts#L88)。

  const outdir = config.settings.get(['output']);
  env[cxapi.OUTDIR_ENV] = outdir;
  return createAssembly(outdir);

outputオプションはデフォルトで"cdk.out"です(aws-cdk/lib/settings.ts#L75-L79)。

  public readonly defaultConfig = new Settings({
    versionReporting: true,
    pathMetadata: true,
    output: 'cdk.out',
  });

ということで、bin/cdk.tsが何を行っているかが次の焦点です。

AppはいつCloudFormationテンプレートを出力しているのか?

bin/cdk.tsでは、通常Appのインスタンスを作成します。 ではAppはいつCloudFormationテンプレートを出力しているのでしょうか? Appのソースコードを見てみましょう。

Appcore/lib/app.ts#L94-L155に定義されています。 このブログ投稿では、packages/@aws-cdk/coreフォルダをcoreと省略します。

Appのコンストラクタには、core/lib/app.ts#L131に興味深い行があります。

      process.once('beforeExit', () => this.synth());

これはAppを実行するプロセスが終了時にApp#synthを呼ぶようにします。 どうやらApp#synthに答えがありそうです。 AppStageを継承しているので、App#synthStage#synthと等価です。

Stage#synth

定義: core/lib/stage.ts#L174-L183

  public synth(options: StageSynthesisOptions = { }): cxapi.CloudAssembly {
    if (!this.assembly || options.force) {
      this.assembly = synthesize(this, {
        skipValidation: options.skipValidation,
        validateOnSynthesis: options.validateOnSynthesis,
      });
    }

    return this.assembly;
  }

core/lib/private/synthesis.ts#L23-L50に定義されているsynthesizeを呼び出します。

synthesizeの以下の2行が使えそうな2つのフックにつながることがわかりました。

validateTree

定義: core/lib/private/synthesis.ts#L201-L214

function validateTree(root: IConstruct) {
  const errors = new Array<ValidationError>();

  visit(root, 'pre', construct => {
    for (const message of construct.node.validate()) {
      errors.push({ message, source: construct });
    }
  });

  if (errors.length > 0) {
    const errorList = errors.map(e => `[${e.source.node.path}] ${e.message}`).join('\n  ');
    throw new Error(`Validation failed with the following errors:\n  ${errorList}`);
  }
}

validateTreerootから始まるConstructツリーのすべてのノードを辿って(visit)各ノードにNode#validateを適用します。 Node#validateNode#addValidationでノードに追加したIValidationIValidation#validateを呼び出します。

ということで IValidationはOpenAPI定義を出力するためのフック になりそうです。 使い方については「Validatorフック」を参照ください。

synthesizeTree

定義: core/lib/private/synthesis.ts#L174-L191

function synthesizeTree(root: IConstruct, builder: cxapi.CloudAssemblyBuilder, validateOnSynth: boolean = false) {
  visit(root, 'post', construct => {
    const session = {
      outdir: builder.outdir,
      assembly: builder,
      validateOnSynth,
    };


    if (Stack.isStack(construct)) {
      construct.synthesizer.synthesize(session);
    } else if (construct instanceof TreeMetadata) {
      construct._synthesizeTree(session);
    } else {
      const custom = getCustomSynthesis(construct);
      custom?.onSynthesize(session);
    }
  });
}

synthesizeTreerootから始まるConstructツリーのすべてのノードを辿って(visit)各Constructを処理します。 重要な行はcore/lib/private/synthesis.ts#L183にあります。

      construct.synthesizer.synthesize(session);

ここでconstructStackのインスタンスであり、Stack#synthesizerはデフォルトでDefaultStackSynthesizerです。 なので上記の行は通常DefaultStackSynthesizer#synthesizeの呼び出しとなります。

DefaultStackSynthesizer#synthesize

定義: core/lib/stack-synthesizers/default-synthesizer.ts#L387-L424

DefaultStackSynthesizer#synthesizecore/lib/stack-synthesizers/default-synthesizer.ts#L400DefaultStackSynthesizer#synthesizeStackTemplateを呼び出します。

    this.synthesizeStackTemplate(this.stack, session);

DefaultStackSynthesizer#synthesizeStackTemplateStack#_synthesizeTemplateの呼び出しと等価です(core/lib/stack-synthesizers/default-synthesizer.ts#L380-L382)。

  protected synthesizeStackTemplate(stack: Stack, session: ISynthesisSession) {
    stack._synthesizeTemplate(session, this.lookupRoleArn);
  }

Stack#_synthesizeTemplate

定義: core/lib/stack.ts#L770-L804

このメソッドはcore/lib/stack.ts#L779Stack#_toCloudFormationを呼び出します。

    const template = this._toCloudFormation();

Stack#_toCloudFormation

定義: core/lib/stack.ts#L1007-L1045

以下の2行に注目してください(core/lib/stack.ts#L1031-L1032)。

    const elements = cfnElements(this);
    const fragments = elements.map(e => this.resolve(e._toCloudFormation()));

このメソッドはcfnElementsですべての子CfnElementを集め、CfnElement#_toCloudFormationをそれぞれに適用します。 CfnElementはすべてのL1 Constructのベースクラスで、CfnElement#_toCloudFormationcore/lib/cfn-element.ts#L161に定義される内部メソッドです。

ということで CfnElement#_toCloudFormationもOpenAPI定義を出力するためのもう1つのフック になりそうです。 使い方については「_toCloudFormationフック」を参照ください。

フック

上記の分析により、OpenAPI定義の出力に応用できるフックは2つありそうです。

Validatorフック

「Validatorフック」はNode#addValidationNodeに追加することのできるIValidationを応用します。 細かい分析については節「validateTree」を参照ください。

とりあえずシンプルさを優先して私のライブラリを実装するのには このフックを選択 しました。 以下のコードはRestApiWithSpecのコンストラクタから抽出したものです。 完全な定義はGitHubレポジトリにあります。

  constructor(scope: Construct, id: string, readonly props: RestApiWithSpecProps) {
    // ... 他の初期化ステップ
    Node.of(this).addValidation({
      validate: () => this.synthesizeOpenApi(), // synthesizeOpenApiがOpenAPI定義を書き込む
    });
  }

弱点はバリデーションが無効だと機能しないことです。

_toCloudFormationフック

「_toCloudFormationフック」は内部メソッドのCfnElement#_toCloudFormationをオーバーライドして応用します。 細かい分析については節「synthesizeTree」を参照ください。

もし私のライブラリでこのフックを使うとしたら、RestApiWithSpecのコンストラクタは以下のようなものになりそうです。

  constructor(scope: Construct, id: string, readonly props: RestApiWithSpecProps) {
    // ... 他の初期化ステップ
    class ToCloudFormationHook extends CfnElement {
      constructor(private scope: RestApiWithSpec, id: string) {
        super(scope, id);
      }

      _toCloudFormation() {
        this.scope.synthesizeOpenApi(); // synthesizeOpenApiがOpenAPI定義を書き込む
        return {}; // 実際にはCloudFormationリソースは追加されない
      }
    }
    new ToCloudFormationHook(this, 'ToCloudFormationHook');
  }

このフックには欠点がいくつかあります。

  • CfnElement#_toCloudFormationは内部メソッドであり変更されるかもしれない。
  • 私の実験では、ToCloudFormationHook#_toCloudFormationが2回呼ばれた(何故かは不明)。

結論

このブログ投稿では、CDKのソースコードを眺めました。 OpenAPI定義に使える以下の2つのフックを見つけました。

私のライブラリではシンプルさを優先して「Validatorフック」を選択しました。

補足

この節ではCDKで使われているユーティリティ関数をいくつか紹介します。

visit

定義: core/lib/private/synthesis.ts#L219-L232

function visit(root: IConstruct, order: 'pre' | 'post', cb: (x: IConstruct) => void) {
  if (order === 'pre') {
    cb(root);
  }

  for (const child of root.node.children) {
    if (Stage.isStage(child)) { continue; }
    visit(child, order, cb);
  }

  if (order === 'post') {
    cb(root);
  }
}

この関数はrootから始まるConstructツリーのすべてのノードを辿って、cbに指定される関数をそれぞれに適用します。

cfnElements

定義: core/lib/stack.ts#L1279-L1292

function cfnElements(node: IConstruct, into: CfnElement[] = []): CfnElement[] {
  if (CfnElement.isCfnElement(node)) {
    into.push(node);
  }

  for (const child of Node.of(node).children) {
    // Don't recurse into a substack
    if (Stack.isStack(child)) { continue; }


    cfnElements(child, into);
  }

  return into;
}

この関数はnodeから始まるConstructツリーのすべてのCfnElementを再起的に集めます。