完了した申請書をPDFとしてDropboxに保管してみた with AWS CDK

[コラボフロー Advent Calendar 2022](https://qiita.com/advent-calendar/2022/collaboflow) 5日目の記事です！

[以前の記事](https://qiita.com/bugtrap/items/eedcee1e9b9c8a304835) で悩んでいた缶つまウォーマー買いました。Amazon ブラックフライデー狙いも安くならなかったー。ドンマイ。
Qiita では食レポできないので Twitter あたりでつぶやこうかしら。

---

さて今回は、現在β版として公開されている「[帳票出力API](http://docs.collaboflow.com/api-docs/#/Document/outputPrint)」を使ってみました！

コラボフローで申請が完了したら、それを PDF ドキュメントとしてクラウドストレージ「Dropbox」に文書番号で整理して保管する実装例を取り上げます。

サポートサイトの Tips に Zapier を使った「[決裁後に帳票を添付したメール送信の例](https://collaboflow.zendesk.com/hc/ja/articles/5427833596943)」があるのであわせて参考になりそうです。

Tips はローコードの事例なので、今回は開発向けにがっつり AWS にサーバーレスでインフラを組み上げてみました。

今回はハンズオンというより、実装したソースの解説になります。

# 動かしてみる

アニメーションでは手前のウィンドウにて最後の承認者が承認するところです。
数秒すると、後ろのウィンドウに表示している Dropbox フォルダに PDF として保存されたのが見えます。

![record.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3934/33fb81b9-e88e-5319-dc63-c3b8fa5fa9a8.gif)

# バックエンドの構成

受取先となるバックエンド部分は以下のように AWS で作ります。

- API Gateway で Webhook POST の受け口を用意 → Lambda に繋ぐ。
- Lambda がコラボフローから帳票を取得して、Dropboxにアップロードする。
    - 接続先・認証情報は Systems Manager のパラメータストアに格納・読み取る
- CloudWatch Logs にアクセスログを保管する。

これを AWS CDK で構築します。

# 設定

## コラボフロー

### 経路の Webhook

バックエンドデプロイ後、以下のように [経路設定 Webhook](https://collaboflow.zendesk.com/hc/ja/articles/360001531035) で「経路終了時」イベントが発生したら Webhook の URL で受け取るようにします。

<img alt="" width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3934/fd77fc03-e096-8fc9-a2ac-d4bf6f5cea7e.jpeg">

## Dropbox

[アプリコンソール](https://www.dropbox.com/developers/apps) でアプリを作ります。

パーミッションのタイプを「App Folder」にすると「/アプリ/アプリ名」配下にしか保存できないアプリを作れます。
アプリごとに分離されていて安心できます。他のフォルダは見れません。要件によっては全フォルダアクセス権を検討です。

忘れずに、Permission タブでアプリに対してファイルへのアクセスを許可しておきます。

<img alt="" width="40%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3934/151ee7f3-73f2-91cd-652d-e191c128d029.jpeg">

<img alt="" width="40%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3934/c57da92f-4e37-1663-9759-4dd55d4a1c1c.jpeg">

# ソースコード

全ソースは GitHub で公開しています。記事では主要どころに絞って解説します。

https://github.com/bugtrap/collaboflow-document-archiver

## パラメータストア

シークレット情報を保存する場所と Systems Manager のパラメータストアを使いました。

| パス | 保存する文字列 | 説明 |
|:--|:--|:--|
| /document-archiver/collaboflow-config | {"endpoint":"https://cloud.collaboflow.com/example/api/index.cfm", "userId":"admin", "apiKey":"xxxxxxxxx"} | コラボフロー環境設定 REST API の設定 |
| /document-archiver/dropbox-config | {"accessToken":"sl.xxxxxxxxxx"} | Dropbox のアプリコンソールでテスト生成したアクセストークン |

## CDK スタック

`NodejsFunction` で Lambda を作りつつ、認証情報が入っているパラメータストアにアクセスできるよう IAM ロールを作っています。後半の `buildApiGateway()` で API Gateway 本体＋ Webhook のパスを準備して Lambda に割り当てています。

```cdk/lib/document-archiver-stack.ts
export class DocumentArchiverStack extends Stack {
    private restApi: RestApi;
    private webhookLambda: NodejsFunction;

    constructor(scope: Construct, id: string, props: StackProps) {
        super(scope, id, props);
        this.buildWebhookIncomingLambda();
        this.buildApiGateway();

        // エンドポイントを出力
        new CfnOutput(this, "WebhookURL", {
            value: `${this.restApi.url}/webhook-event`,
        });
    }

    /**
     * コラボフローからの Webhook イベント受信・処理 Lambda の構築
     */
    private buildWebhookIncomingLambda() {
        const role = new Role(this, "WebhookLambdaRole", {
            assumedBy: new ServicePrincipal("lambda.amazonaws.com"),
            managedPolicies: [
                ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
                ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMReadOnlyAccess"),
            ],
        });

        this.webhookLambda = new NodejsFunction(this, "WebhookLambda", {
            runtime: Runtime.NODEJS_16_X,
            entry: join(__dirname, "lambda/process-webhook.ts"),
            handler: "handler",
            timeout: Duration.seconds(25),
            role,
            logRetention: RetentionDays.ONE_WEEK,
            environment: {
                SSM_PATH_COLLABOFLOW: "/document-archiver/collaboflow-config",
                SSM_PATH_DROPBOX: "/document-archiver/dropbox-config",
            },
        });
    }

    /**
     * API Gateway HTTP API エンドポイントの構築
     */
    private buildApiGateway() {
        const accessLog = new LogGroup(this, "ApiAccessLog", {
            logGroupName: `${this.stackName}/ApiAccessLog`,
            retention: RetentionDays.ONE_WEEK,
        });

        const accessLogFormat = {
            // 省略
        };

        this.restApi = new RestApi(this, "Api", {
            endpointTypes: [EndpointType.REGIONAL],
            cloudWatchRole: true,
            deployOptions: {
                accessLogDestination: new LogGroupLogDestination(accessLog),
                accessLogFormat: AccessLogFormat.custom(JSON.stringify(accessLogFormat)),
                loggingLevel: MethodLoggingLevel.INFO,
                dataTraceEnabled: true,
            },
        });

        // POST /webhook-event で受け付ける
        const webhookEvent = this.restApi.root.addResource("webhook-event");
        webhookEvent.addMethod(
            "POST",
            new LambdaIntegration(this.webhookLambda, {
                proxy: true,
                timeout: this.webhookLambda.timeout,
            })
        );
    }
}
```

## Lambda

### 処理部

```cdk/lib/lambda/process-webhook.ts
export const handler: APIGatewayProxyHandlerV2 = async (event: APIGatewayProxyEventV2) => {
    const payload = parseBody<CollaboflowWebhookPayload>(event);

    // 省略

    const filePath = `/${payload.document_number}.pdf`;

    // ★１ コラボフローから PDF をバイナリデータとして取得（メモリ上のバッファに溜める）
    console.info("Fetch PDF", { documentId: payload.document_id });
    const printId = 1;
    const pdfData = await fetchPdfDocument(payload.app_cd, payload.document_id, printId);

    // ★２ Dropbox 保存呼出し
    console.info("Upload Dropbox");
    const fileId = await uploadDropbox(filePath, pdfData);

    console.info("Upload Success", { filePath, fileId });

    return {
        statusCode: 200,
        body: JSON.stringify({
            success: true,
        }),
    };
};
```

★１→★２と 2 ステップでデータ本体 pdfData を橋渡ししています。実際の処理は別ファイルに分けています。

[帳票出力API（β版）](http://docs.collaboflow.com/api-docs/#/Document/outputPrint) の仕様を見つつ。POST ですがボディは無いので `null` を `axios.post()` に渡します。

```cdk/utils/fetchPdfDocument.ts
export const fetchPdfDocument = async (
    appCd: number,
    documentId: number,
    printId: number
): Promise<ArrayBuffer> => {
    const config = await getCollaboflowConfig();

    // PDF のバイナリーデータが返る
    const response = await axios.post<ArrayBuffer>(
        `${config.endpoint}/v1/documents/${documentId}/prints/${printId}?app_cd=${appCd}`,
        null,
        {
            headers: {
                Accept: "application/pdf",
                ...makeAuthHeader(config),
            },
            responseType: "arraybuffer",
        }
    );

    return response.data;
};
```

続いて Dropbox へのアップロード部分。公式ライブラリがあるので、認証の挟み込んで呼び出すくらいです。

```cdk/utils/uploadDropbox.ts
export const uploadDropbox = async (path: string, contents: ArrayBuffer): Promise<string> => {
    const config = await getDropboxConfig();
    const dbx = new Dropbox({ accessToken: config.accessToken });
    const response = await dbx.filesUpload({
        path,
        contents,
    });
    return response.result.id;
};
```

### 補足

Dropbox 側の認証はサンプルとして短期間有効なアクセストークンを直接指定しました。なので、暫くするとトークンの期限切れで動かなくなります。継続実行するにはこのあたりを OAuth に組み替える必要がありそうです。短期間しか使えない事を実装をほぼ終えてから気づきました・・・。

# おわりに

ハンズオン的にソースコードをコピペして動かしながら見る記事だと出来る事がどうしても小さくなりがちなので、帳票出力APIのポテンシャルを伝えれないと思いました。

そこで、コードは思い切ってリポジトリ参照の形をとって、記事はそれを補完するための要所解説にしました。いかがだったでしょうか。コードの断片を眺めながら、そういうカラクリなのかと見てもらえたら嬉しいです。

---

明日は @nana_csx さんです！
プログラムではなく、開発部のいつもは見えないところが語られるかも！？

それでは～ ('ω')ノシ