AWS APIGateway × OpenAPI (2. Proxy編)

2022-07-26

thumbnail

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

背景

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

RestApiとの互換性はどうやって実現するか?

  • RestApiのサブクラスやラッパーを書くとしたら、RestApiがサブ要素(ResourceMethod)としてインスタンス化するものを直接操作することはできません。 どうやってサブ要素を拡張したら良いでしょうか?
  • RestApiを再利用しないとしたら、互換性を実現するのは本当に大変なことになるでしょう。

RestApiを再利用しないのは大変すぎるので、サブクラスを書くことにします。

インターフェイスの定義

AWS Cloud Development Kit (CDK)のAPIを拡張する前に、最低限のインターフェイスを定義しましょう。 以下のセクションに現れるインターフェイスやコードはシンプルにするために私のライブラリから部分的に抽出し改変したものなのでご注意ください。 完全な定義はGitHubレポジトリにあります。

IRestApiWithSpec

IRestApiWithSpecIRestApiの拡張です。

interface IRestApiWithSpec extends IRestApi {
  readonly root: IResourceWithSpec;
  // ... 他のプロパティ
}

IResourceWithSpec

IResourceWithSpecResourceの拡張*です。

interface IResourceWithSpec extends Resource {
  addResource(pathPart: string, options?: ResourceOptionsWithSpec): IResourceWithSpec;
  addMethod(httpMethod: string, target?: Integration, options?: MethodOptionsWithSpec): Method;
  // ... 他のプロパティ
}

* IResource.addResourceを正しく拡張するには、IResourceWithSpecIResourceではなくResourceを実装していなくてはなりません。 一方で、IRestApiWithSpec.rootResourceではなくIResourceを実装しなければならないので、IRestApiWithSpecroot: IResourceWithSpec定義は限定的すぎます。 しかし、Resourceの公開インターフェイスとIResourceは私の知る限り同じなので問題にはならないはずです。

ResourceOptionsWithSpec

ResourceOptionsWithSpecResourceOptionsの拡張です。

interface ResourceOptionsWithSpec extends ResourceOptions {
  defaultMethodOptions?: MethodOptionsWithSpec;
}

MethodOptionsWithSpec

MethodOptionsWithSpecMethodOptionsの拡張です。

interface MethodOptionsWithSpec extends MethodOptions {
  requestParameterSchemas?: { [key: string]: BaseParameterObject };
  // ... 他のプロパティ
}

BasePrameterObjectは外部ライブラリOpenApi3-TS[1]から借りてきました。

当初はMethodOptions.requestParametersの定義を以下のようにオーバーライドするつもりだったのですが(前のブログ投稿の例参照)・・・

requestParameters?: { [key: string]: boolean | BaseParameterObject };

MethodOptionsWithSpecMethodOptionsに代入できなくしてしまうのでこの試みは失敗しました。 ということで回避策としてrequestParameterSchemasという新しいプロパティを導入しました。

RestApiを拡張する

RestApiを拡張するのは単純です。 言語機能(extends)を使いRestApiのプロパティをオーバーライドするだけです。

class RestApiWithSpec extends RestApi implements IRestApiWithSpec {
  readonly root: IResourceWithSpec;

  constructor(scope: Construct, id: string, props: RestApiWithSpecProps) {
    super(scope, id, props);
    // `root`をどう実装しましょうか?
  }
}

単純化のためRestApiWithSpecPropsの詳細は省きます。 ご興味がありましたらGitHubレポジトリをご参照ください。

ここで、疑問はどのようにrootを実装するかです。 親クラス(RestApi)のrootをラップするのが良さそうです。 ではどうやってラップしましょうか? ほとんどのリクエストをRestApirootに転送するだけのラッパークラスを書きましょうか? 大量のBoilerplate(定型的なコード)が発生しそうです。 それならProxyがニーズにもっとマッチしそうです。

rootにProxyをかませる

root用のProxyを書くのはとても簡単です。 ハンドラオブジェクトのget関数を実装するだけです。

// root: IResource
const rootWithSpec: IResourceWithSpec = new Proxy(root, {
  get: (target, prop, receiver) => {
    switch (prop) {
      case 'addResource':
        return (pathPart, options) => {
          // ... 新しいリソースを作成して再びラップする
        };
      case 'addMethod':
        return (httpMethod, target, options) => {
          // ... optionsを処理する
        };
      default:
        return Reflect.get(target, prop, receiver);
          // `Reflect.get(...arguments)`とするとTypeScriptが文句を言いました
    }
  }
});

残念ながら、TypeScriptは上記コードでProxyをかませたオブジェクトをIResourceWithSpecだと認識せずエラーになります。 ProxyコンストラクタのデフォルトのTypeScript定義が以下のようになっているからです。

declare global {
  interface ProxyConstructor {
    new <T extends object>(target: T, handler: ProxyHandler<T>): T; // TのProxyはやっぱりT
  }
}

この問題に対処するため、Proxyコンストラクタがtargetとは違う型を生成することができるように定義を拡張できます。 StackOverflowのこちらの投稿[2]がまさに答えです。 ということで以下の宣言を追加するとTypeScriptエラーが解決します。

declare global {
  interface ProxyConstructor {
    new <T extends object, U extends object>(target: T, handler: ProxyHandler<T>): U; // TのProxyはUでもよい
  }
}

Resourceを拡張する

Resourceを生成するたびに、節「rootにProxyをかませる」で記述したようにProxyでラップする必要があります。 ということで既存のどんなIResourceでもIResourceWithSpecの機能でラップできるファクトリーメソッドaugmentResourceを導入しました。 このファクトリーメソッドはrootにも適用可能です。

class ResourceWithSpec {
  static augmentResource(restApi: IRestApiWithSpec, resource: IResource, parent?: IResourceWithSpec): IResourceWithSpec {
    const resourceWithSpec: IResourceWithSpec = new Proxy(resource, {
      get: (target, prop, receiver) => {
        switch (prop) {
          case 'addResource':
            return (pathPart, options) => {
              return augmentResource(
                restApi,
                resource.addResource(pathPart, options),
                resourceWithSpec
              );
            };
          // ... 他のプロパティを処理
        }
      }
    });
    return resourceWithSpec;
  }
}

上記のコードはシンプルにしてあり、実際の定義はGitHubレポジトリにあります。

まとめ

このブログ投稿では、CDK APIの拡張を簡単にするためにProxyを導入しました。 Proxyに関するTypeScriptのエラーを回避するトリックも紹介しました。 次のブログ投稿では、いつOpenAPI定義ファイルを出力するかという課題に挑む予定です。

Reference

  1. OpenApi3-TS

    OpenAPI 3の仕様そのものに対するTypeScriptバインディング。

  2. How to use Proxy with a different type than T as argument? - Answer