OpenAPIを利用してAPI Gatewayを作成

# モチベーション

APIGatewayをなるべく少ない労力でCD、メンテナンスしたいので、OpenAPIを使用してドキュメンテーションとAPIGatewayのデプロイを行いたい。

# 手順

## OpenAPIの作成

### 既存のAPIGatewayが存在する場合

すでに運用しているAPIが存在している場合、以下のコマンドで取得可能。

```shell
API_ID=$(aws apigateway get-rest-apis --region <REGION> --query "items[?name=='<API_NAME>'].id" --output text)
aws apigateway get-export \
  --parameters extensions='apigateway' \
  --rest-api-id <API_ID> \
  --stage-name <STAGE_NAME> \
  --export-type oas30 \
  --accept 'application/yaml' \
  --region <REGION> \
  <OUTPUT_FILE>
```

既存で運用していない場合でもOpenAPIの定義ファイルがないのなら、先に手動でAPIGatewayを作成してしまって、ファイルにエクスポートしたほうが楽かもしれません。
ただAPIGatewayに多数のメソッドが存在する場合、ファイルが巨大になり、メンテナンスが大変になるので、エクスポートした後に後述するファイル分割をしておいたほうが良いと思います。

### 新規で作成する場合

OpenAPIを新規で作成する場合、以下の様にファイル分割しておいたほうがメンテナンスしやすいのでおすすめです。
ただし、ファイルが分割されているとawsコマンドでインポートする際に一手間を加える必要があるので、その手間を省きたい場合、1ファイルにまとめておくのも悪い戦略ではありません。


エントリーとなるファイルについては以下のように各メソッドの羅列としてシンプルに定義しておきます。
```yaml:root.yaml
openapi: "3.0.1"
info:
  title: "<API_NAME>"
  description: "ここにはAPIに関する説明を記載してください"
  version: 0.1.0

servers:
  - url: "https://<AWS_API_ID>.execute-api.<AWS_REGION>.amazonaws.com/{basePath}"
    variables:
      basePath:
        default: "dev"

paths:
  /{item}:
    $ref: "./endpoints/item.yaml#/paths/~1{item}"
  /api/system/health:
    $ref: "./endpoints/system/health.yaml#/paths/~1api~1system~1health"
  /api/system/product:
    $ref: "./endpoints/system/product.yaml#/paths/~1api~1system~1product"

x-amazon-apigateway-policy:
  $ref: './apigateway-policy.yaml#/x-amazon-apigateway-policy'
```

各メソッドに関する定義については以下の通りです。

各メソッドについてはOpenAPIの拡張としてx-amazon-apigateway-integrationキーを定義します。
awsコマンドはこのキーの内容に従って、メソッドの定義を行います。

### Mockと結合する場合

例えばヘルスチェックの様に常に同じ値を応答するようなものに関しては以下の通りmockとして定義し、固定値を返すような定義をしておきます。

```yaml:endpoints/system/health.yaml
paths:
  /api/system/health:
    get:
      tags:
        - system
      summary: ヘルスチェック
      description: システムが正常に動作していることを示すためにHTTP 200を返します。
      responses:
        "200":
          description: 正常
          content: {}
      x-amazon-apigateway-integration:
        responses:
          default:
            statusCode: "200"
        requestTemplates:
          application/json: '{"statusCode":200}'
        passthroughBehavior: "never"
        type: "mock"
```

### Lambdaと結合する場合

例えばLambdaの場合、AWS Proxyとして定義し、uriに呼び出し先のLambdaのARNを指定しましょう。

```yaml:endpoints/system/product.yaml
paths:
  /api/system/product:
    get:
      tags:
        - system
      summary: 製品情報の取得
      description: 製品名、バージョン、ビルド日時、ビルド時のgitのshaを返します。
      responses:
        '200':
          description: 製品情報の取得に成功
          content:
            application/json:
              schema:
                type: object
                properties:
                  name:
                    type: string
                    description: 製品名
                    example: "Product"
                  version:
                    type: string
                    description: バージョン
                    example: "1.0.0"
                  buildDate:
                    type: string
                    description: ビルド日時
                    example: "2025-04-24T12:00:00Z"
                  buildHash:
                    type: string
                    description: ビルド時のgitのsha
                    example: "abc123def456"
      x-amazon-apigateway-integration:
        httpMethod: "POST"
        uri: "arn:aws:apigateway:<AWS_REGION>:lambda:path/2015-03-31/functions/arn:aws:lambda:<AWS_REGION>:<AWS_ACCOUNT_ID>:function:<AWS_LAMBDA_NAME>/invocations"
        responses:
          default:
            statusCode: "200"
        passthroughBehavior: "when_no_match"
        type: "aws_proxy"
```

注意が必要な点としてLambda側もAPIGatewayから呼び出せるように呼び出す関数の「設定 > アクセス権限 > リソースベースのポリシーステートメント」にAPIGatewayからの呼び出し許可の指定をしておく必要があります。

```json
{
  "ArnLike": {
    "AWS:SourceArn": "arn:aws:execute-api:<AWS_REGION>:<AWS_ACCOUNT_ID>:<API_ID>/*"
  }
}
```

コマンドで設定するなら以下のような感じです。

```shell
aws lambda add-permission \
    --function-name arn:aws:lambda:<REGION>:<ACCOUNT_ID>:function:<LAMBDA_NAME> \
    --statement-id apigateway-access \
    --action lambda:InvokeFunction \
    --principal apigateway.amazonaws.com \
    --source-arn arn:aws:execute-api:<REGION>:<ACCOUNT_ID>:<API_ID>/* \
    --region <REGION>
```

### S3と結合する場合

APIGatewayをS3のプロキシとして使用する場合は以下のような形になります。

```yaml:endpoints/item.yaml
paths:
  /{item}:
    get:
      tags:
        - Static
      summary: 静的コンテンツの取得
      description: S3に保存された静的コンテンツを取得します。
      parameters:
        # パスパラメーターの定義
        - name: "item"
          in: "path"
          required: true
          schema:
            type: "string"
      responses:
        "200":
          description: "200 response"
          headers:
            Content-Length:
              schema:
                type: "string"
            Timestamp:
              schema:
                type: "string"
            Content-Type:
              schema:
                type: "string"
          content: {}
      x-amazon-apigateway-integration:
        # 認証情報
        credentials: "arn:aws:iam::<AWS_ACCOUNT_ID>:role/<AWS_IAM_ROLE_NAME>"
        httpMethod: "GET"
        # S3に連携する際のパスオーバーライド
        # ${stageVariables.stage}はステージ変数として定義されている
        uri: "arn:aws:apigateway:<AWS_REGION>:s3:path/<AWS_S3_BUCKET_NAME>/<AWS_S3_UPLOAD_DIR>/${stageVariables.stage}/{object}"
        responses:
          default:
            statusCode: "200"
            # S3から応答される各種ヘッダ情報をAPIGatewayのヘッダーと統合
            responseParameters:
              method.response.header.Content-Type: "integration.response.header.Content-Type"
              method.response.header.Content-Length: "integration.response.header.Content-Length"
              method.response.header.Timestamp: "integration.response.header.Date"
        requestParameters:
          # パスパラメーターitemをパスオーバーライドの際に使用するobjectにマッピング
          integration.request.path.object: "method.request.path.item"
        passthroughBehavior: "when_no_match"
        type: "aws"
```

### APIGatewayのコンテンツ定義

社内システムなどを構築する場合、外部からアクセスされないようなリソースポリシーをx-amazon-apigateway-policyで指定が可能になります。
例えばプライベートIPからのみの接続にIP制限したい場合は以下のような記載になります。

```yaml:apigateway-policy.yaml
x-amazon-apigateway-policy:
  Version: "2012-10-17"
  Statement:
    - Effect: "Allow"
      Principal: "*"
      Action: "execute-api:Invoke"
      Resource: "arn:aws:execute-api:ap-northeast-1:*:<AWS_API_ID>/*"
      Condition:
        IpAddress:
          aws:SourceIp:
            - "10.0.0.0/8"
            - "172.16.0.0/12"
            - "192.168.0.0/16"
```

### どう設定したらいいかわからないときは

その他の設定については [API Gateway の OpenAPI 拡張機能](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/api-gateway-swagger-extensions.html) に記載があります。

ただ、調べるのも大変なので、OpenAPI上どう設定していいかわからない場合は、一度手動で作成してからエクスポートし、どういった設定がされているか確認するのが楽だと思います。

## swagger-cli による結合

awsコマンドでインポートする際には単一のファイルである必要があるので、以下のコマンドで結合を行います。

```shell
# swagger-cliのインストール
npm install -g swagger-cli
# OpenAPIファイルの結合
swagger-cli bundle -o <OUTPUT_FILE> -t yaml root.yaml
```

結合する形式にyamlを使用するかjsonを使用するかは好みの分かれるところですが、OpenAPIの場合、文章を複数行にまたいで書くこともしばしばあるので、個人的にはyamlのほうがおすすめです。

## 結合ファイルの編集

### 変数の置き換え

定義ファイルに直接アカウントIDやロール名などを書くのはセキュリティ的に望ましくないので、リポジトリにプッシュする際は上述のように<AWS_ACCOUNT_ID>のような置換しやすい文字列にしておき、Githubのリポジトリシークレットなどに登録しておいて置換するのが良いでしょう。

```shell
sed -i "s/<AWS_ACCOUNT_ID>/$ACCOUNT_ID/g" <OUTPUT_FILE>.yaml
```

### yqによる置き換え

例えばS3のプロキシを行う場合、ファイルがなかった場合、単に404に応答をするのではなく、特定のファイルを応答したい、というニーズがあったとします。

そういった場合、swagger-cliではOpenAPI以外のファイルは結合できないので、ファイルに埋め込んでおく必要があります。
ただ、ビルドの度に内容が変わるものなど、毎回反映するのが面倒なので、デプロイ時に結合するのが楽です。

yamlファイルについては以下のようにyqコマンドで編集すると楽に結合できます。

```shell
# yqのインストール
curl -L https://github.com/mikefarah/yq/releases/latest/download/yq_windows_amd64.exe -o /usr/bin/yq; \
chmod +x /usr/bin/yq; \
# /{item}の404のレスポンスにビルドされたindex.htmlを応答するように結合
export HTML_CONTENT=$(awk '{print "        "$0}' frontend/dist/index.html)
yq -i '(.paths."/{item}".get["x-amazon-apigateway-integration"].responses."404".responseTemplates."text/html") = strenv(HTML_CONTENT)' openapi-bundle.yaml
```

## デプロイ

作成したファイルを利用して以下のコマンドでデプロイできます。

```
aws apigateway put-rest-api \
    --rest-api-id <API_ID> \
    --region <REGION> \
    --mode overwrite \
    --fail-on-warnings \
    --body 'fileb://<OUTPUT_FILE>'
```

### 注意点

#### --mode overwrite

`--mode overwrite` を使用するとOpnepAPIのファイル内に定義されていないパス、メソッドは削除されます。
残しておきたい場合は `--mode merge` をっ使用しましょう

#### --body 'fileb://<OUTPUT_FILE>'

日本語を含むファイルの場合、バイナリデータとして読み込まなければ構造解析エラーとなってしまいます。
そのため、filebを指定してファイルを指定しましょう。

## まとめたシェル

上記までのシェルをまとめたシェルは以下の通りです。

```shell
API_NAME="${1}"      # シェル引数からAPI名を受け取る
REGION="${2}"        # シェル引数からリージョンを受け取る
ACCOUNT_ID="${3}"    # シェル引数からAWSアカウントIDを受け取る
BUCKET_NAME="${4}"   # シェル引数からS3バケット名を受け取る
UPLOAD_DIR="${5}"    # シェル引数からS3アップロードディレクトリを受け取る
IAM_ROLE_NAME="${6}" # シェル引数からIAMロール名を受け取る
LAMBDA_NAME="${7}"   # シェル引数からLambda関数名を受け取る

if [ -z "${API_NAME}" ] || [ -z "${REGION}" ] || [ -z "${ACCOUNT_ID}" ] || [ -z "${BUCKET_NAME}" ] || [ -z "${UPLOAD_DIR}" ] || [ -z "${IAM_ROLE_NAME}" ] || [ -z "${LAMBDA_NAME}" ]; then
    echo "使用方法: ${0} <API_NAME> <REGION> <ACCOUNT_ID> <BUCKET_NAME> <UPLOAD_DIR> <IAM_ROLE_NAME> <LAMBDA_NAME>"
    exit 1
fi

# API IDの取得または作成
API_ID=$(aws apigateway get-rest-apis --region "${REGION}" --query "items[?name=='${API_NAME}'].id" --output text)
if [ -z "${API_ID}" ]; then
    API_ID=$(aws apigateway create-rest-api --name "${API_NAME}" --description "HyperionプロジェクトのAPI Gateway" --region "${REGION}" --query 'id' --output text)
    echo "API Gatewayを作成しました: ${API_ID}"
else
    echo "既存のAPI Gatewayを使用します: ${API_ID}"
fi

# OpenAPIの仕様書の結合
swagger-cli bundle -o openapi-bundle.yaml -t yaml docs/api/root.yaml

# frontend/dist/index.htmlの内容を404テンプレートにインライン展開（yqで置換）
if [ ! -f "frontend/dist/index.html" ]; then
    echo "エラー: frontend/dist/index.html が存在しません。ビルドしてください。"
    exit 1
fi
export HTML_CONTENT=$(awk '{print "        "$0}' frontend/dist/index.html)
yq -i '(.paths."/{item}".get["x-amazon-apigateway-integration"].responses."404".responseTemplates."text/html") = strenv(HTML_CONTENT)' openapi-bundle.yaml

# <AWS_API_ID> をAPI_IDで置換
sed -i "s/<AWS_API_ID>/$API_ID/g" openapi-bundle.yaml
# <AWS_REGION> をREGIONで置換
sed -i "s/<AWS_REGION>/$REGION/g" openapi-bundle.yaml
# <AWS_ACCOUNT_ID> をACCOUNT_IDで置換
sed -i "s/<AWS_ACCOUNT_ID>/$ACCOUNT_ID/g" openapi-bundle.yaml
# <AWS_S3_BUCKET_NAME> をBUCKET_NAMEで置換
sed -i "s/<AWS_S3_BUCKET_NAME>/$BUCKET_NAME/g" openapi-bundle.yaml
# <AWS_S3_UPLOAD_DIR> をUPLOAD_DIRで置換
sed -i "s/<AWS_S3_UPLOAD_DIR>/$UPLOAD_DIR/g" openapi-bundle.yaml
# <AWS_IAM_ROLE_NAME> をIAM_ROLE_NAMEで置換
sed -i "s/<AWS_IAM_ROLE_NAME>/$IAM_ROLE_NAME/g" openapi-bundle.yaml
# <AWS_LAMBDA_NAME> をLAMBDA_NAMEで置換
sed -i "s/<AWS_LAMBDA_NAME>/$LAMBDA_NAME/g" openapi-bundle.yaml

# API Gatewayの定義を既存APIに上書き（put-rest-api）
aws apigateway put-rest-api \
    --rest-api-id "$API_ID" \
    --region "$REGION" \
    --mode overwrite \
    --fail-on-warnings \
    --body 'fileb://openapi-bundle.yaml'
```