CodeCommitにサーバーレス(Lambda)の定期実行でPushする

## はじめに
CodeCommitで管理しているリポジトリにサーバーレスの定期実行でPushする、ということをやってみたのでそのやり方を残したいと思います。
本記事では、試しに[YAHOO!ニュース(ITカテゴリ)のRSS](https://news.yahoo.co.jp/rss/categories/it.xml "news.yahoo.co.jp/rss/categories/it.xml")を取得して、JSON形式で保存する。というユースケースでやり方の紹介をします。

# 目次
<!-- タイトルとアンカー名を編集 -->
1. [はじめに](#はじめに)
2. [前提](#前提)
3. [準備](#準備)
4. [権限の設定](#権限の設定)
5. [Lambda関数作成](#lambda関数作成)
5. [Lambda関数の登録](#lambda関数の登録)
6. [おわりに](#おわりに)

## 前提
1\. 以下のツールがインストールされていること。
```
node18
yarn
aws-cli
```
2\. Push先のリポジトリの作成と、initial commitを済ませておく。
## 準備
今回Lambda使用するのはNode.js 18.xです。
yarnでプロジェクトを管理します。
```
yarn init
```
パッケージのインストールをします。
```
yarn add @aws-sdk/client-codecommit axios typescript xml2js
yarn add -D @types/aws-lambda @types/node @types/xml2js esbuild
```
今回はTypescriptで型定義をしつつコードを書いていきます。
RSSはxmlで取得されるので、xmlをjsonに変換するためにxml2jsを使用します。
esbuildはTypeScriptのコードをJavaScriptにトランスパイルするために使用します。

## 権限の設定
### ロール
iamフォルダを作成し、その中にrole.jsonを作成します。
```iam/role.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
```
ロールを作成します。
```
aws iam create-role --role-name lambda-codecommit --assume-role-policy-document file://iam/role.json
```
### ポリシー
iamフォルダ下にpolicy.jsonを作成します。
```iam/policy.json 
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "codecommit:ListRepositories",
        "codecommit:ListBranches",
        "codecommit:GetBranch",
        "codecommit:GetCommit",
        "codecommit:PutFile"
      ],
      "Resource": "<リポジトリのARN>"
    }
  ]
}
```
ポリシーを作成します。
```
aws iam create-policy --policy-name put-file-to-codecommit --policy-document file://iam/policy.json
```
ロールにアタッチします。
```
aws iam attach-role-policy --role-name lambda-codecommit --policy-arn <先ほど作成したポリシーのARN>
```
Amazon CloudWatch Logsへの書き込み等を許可するポリシーをアタッチします。
```
aws iam attach-role-policy --role-name lambda-codecommit --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
```

## Lambda関数作成
### 各種設定
Typescriptを使用しているので、tsconfig.jsonを作成します。
```tsconfig.json
{
  "compilerOptions": {
    "target": "es2020",
    "module": "es2020",
    "moduleResolution": "node",
    "strict": true,
    "incremental": true,
    "outDir": "./.ts-cache/"
  }
}
```
簡潔に上記の設定だけ記述しました。

次に、esbuildの実行ファイルを作成します。
```esbuild.config.mjs
import esbuild from 'esbuild';

esbuild.build({
    entryPoints: [
        './index.ts',
    ],
    outdir: 'dist',
    bundle: true,
    minify: true,
    platform: 'node',
    sourcemap: false,
    target: ['es2020'],
}).catch(() => process.exit(1));
```
CLIからパラメータを指定すれば実行できますが、今回はファイルで管理してみました。

次にpackage.jsonのscriptsセクションを定義します。
```package.json
  "scripts": {
    "prebuild": "rm -rf dist",
    "build": "node esbuild.config.mjs",
    "postbuild": "cd dist && zip -r index.zip index.js*"
  }
```
esbuildコマンドの定義をしています。

### Lambda関数
今回は役割ごとに複数ファイルに分けてみました。
index.tsがエントリーポイントになります。
```index.ts
import { Handler, APIGatewayProxyResult } from 'aws-lambda';
import { putFile } from "./lib/codeCommitUtil";
import { CodeCommitClient } from '@aws-sdk/client-codecommit';

export const handler: Handler = async (): Promise<APIGatewayProxyResult> => {
    try {
        const REGION = process.env['REGION'];
        if (!REGION) throw new Error("REGION is null.");

        const client = new CodeCommitClient({ region: REGION });

        const result: string = await putFile(client);
        return {
            statusCode: 200,
            body: result
        };
    } catch (e) {
        console.log(e)
        if (e instanceof Error) {
            return {
                statusCode: 500,
                body: e.message
            };
        } else {
            return {
                statusCode: 500,
                body: 'An unknown error occurred'
            };
        }
    }
};
```
次にCodeCommitに関するコードを記述したファイルです。(libフォルダ下に作っています。)
```lib/codeCommitUtil.ts
import { getData } from "./fetchDataUtil";
import {
    CodeCommitClient,
    GetBranchCommand,
    GetBranchCommandOutput,
    PutFileCommand,
    PutFileCommandOutput,
    SameFileContentException,
    GetBranchCommandInput,
    PutFileCommandInput
} from "@aws-sdk/client-codecommit";

export const putFile = async (client: CodeCommitClient): Promise<string> => {
    try {
        const REPOSITORY = process.env['REPOSITORY'];
        if (!REPOSITORY) throw new Error("REPOSITORY is null.");
        const BRANCH = process.env['BRANCH'];
        if (!BRANCH) throw new Error("BRANCH is null.");

        const data: Uint8Array = await getData();

        const latestCommitId: string = await getLatestCommitId(REPOSITORY, BRANCH, client);

        const params: PutFileCommandInput = {
            repositoryName: REPOSITORY,
            branchName: BRANCH,
            fileContent: data,
            filePath: 'test.json',
            commitMessage: 'Update file from Lambda function',
            parentCommitId: latestCommitId
        };
        const command: PutFileCommand = new PutFileCommand(params);

        const response: PutFileCommandOutput = await client.send(command);

        return 'Successfully put file. commitId = "' + response.commitId + '"';

    } catch (e) {
        if (e instanceof SameFileContentException) {
            return 'No changes in the data file';
        }
        throw e;
    }
};

const getLatestCommitId = async (repositoryName: string, branchName: string, client: CodeCommitClient): Promise<string> => {
    const params: GetBranchCommandInput = {
        repositoryName: repositoryName,
        branchName: branchName,
    };

    const command: GetBranchCommand = new GetBranchCommand(params);
    const response: GetBranchCommandOutput = await client.send(command);

    if (response.branch && typeof response.branch.commitId === 'string') {
        return response.branch.commitId;
    } else {
        throw new Error(`Failed to retrieve commit id.`);
    }
};
```
基本的に[ドキュメント](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-codecommit/ "@aws-sdk/client-codecommit")を参考にパラメータ等を作っていきます。
今回は最小限必須パラメータのみで構成していますが、他にもパラメータは存在するので参照してみてください。

:::note warn
注意点：
- PutFileCommandInputのparentCommitIdは、必須パラメータではありませんが、リポジトリが空ではない場合は必須になります。
- fileContentの型は`undefined | Uint8Array`なので、パラメータはUint8Arrayに変換しておく必要があります。（下記ファイル参照）
:::

次にデータを取得するファイルです。
```lib/fetchDataUtil.ts
import axios, { AxiosResponse, AxiosError } from "axios";
import { parseStringPromise } from "xml2js";

export const getData = async (): Promise<Uint8Array> => {
    const response = await fetchRSSData();
    const xmlData = response.data;
    const jsonData = await xmlToJson(xmlData);
    const extractedJsonData = JSON.stringify(jsonData, null, 2);
    return new TextEncoder().encode(extractedJsonData);
};

async function fetchRSSData(): Promise<AxiosResponse<string>> {
    const DATA_URL = process.env['DATA_URL'];
    if (!DATA_URL) throw new Error("DATA_URL is null.");
    try {
        const response = await axios.get<string>(DATA_URL);
        return response;
    } catch (error) {
        console.log(error);
        throw Error(handleApiError(error as AxiosError));
    }
}

function handleApiError(error: AxiosError): string {
    if (error.response) {
        return `API Error: ${error.response.status} - ${error.response.statusText}`;
    } else if (error.request) {
        return 'Request failed. No response received.';
    } else {
        return 'An error occurred while processing the request.';
    }
}

async function xmlToJson(xmlData: string) {
    try {
        const jsonData = await parseStringPromise(xmlData, { trim: true });
        return jsonData;
    } catch (error) {
        console.log(error);
        throw Error('An error occurred while converting xml to json.');
    }
}
```
axiosでRSSを取得し、xml2jsでxmlをjsonに変換し、Uint8Arrayに変換したものを返しています。

## Lambda関数の登録
コードが完成したので、Lambda関数をデプロイしていきます。

まず、`yarn build`でTypescriptで書いたコードをJavascriptにトランスパイルし、zipに圧縮します。
distフォルダ下にトランスパイルされたjsファイルとzipファイルが生成されます。

下記CLIコマンドでLambda関数の登録を行います。
```
aws lambda create-function --function-name putFileToCodecommit --role <作成したロールのARN> --region ap-northeast-1 --zip-file fileb://dist/index.zip --runtime nodejs18.x --handler index.handler --timeout 10
```
次に環境変数を登録します。
極力CLIで済ませたいので、環境変数もファイルで定義しアップロードするようにします。
```config/env.json
{
  "Variables":{
    "REGION": "ap-northeast-1",
    "REPOSITORY": "fetch-by-lambda",
    "BRANCH": "main",
    "DATA_URL": "https://news.yahoo.co.jp/rss/categories/it.xml"
  }
}
```
下記コマンドで環境変数を登録します。
```
aws lambda create-function-configuration --function-name putFileToCodecommit --environment file://config/env.json
```

下記コマンドでLambda関数のテストができます。
```
aws lambda invoke --function-name putFileToCodecommit out
```

EventBridge スケジュールを設定すれば、簡単にLambda関数を定期実行することができます。
## おわりに
以上でCodeCommitで管理しているリポジトリにサーバーレスの定期実行でPushすることができました。
静的コンテンツをAmplify等でデプロイしていて、定期的にリポジトリを更新したい際に、Lambdaを使用すれば全てサーバーレスで完結できます。
Lambdaを使用した各種AWSサービスの操作はとても便利なので、ぜひ使いこなせるようになりたいです。