氷河(Glacier)に眠っている写真たちをサルベージする 〜 その3 ボールドインベントリの登録

# 目的
AWS S3 Glacier内の各ボールドのボールドインベントリをMongoDBに登録する。

# やり方
* InitiateJobCommandを実行しボールドインベントリを要求する

https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/glacier/command/InitiateJobCommand/

* DescribeJobCommandを実行し進捗状況を取得する

https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/glacier/command/DescribeJobCommand/

* ボールドインベントリの取得が可能となったら、GetJobOutputCommandを実行しボールドインベントリを取得する

https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/glacier/command/GetJobOutputCommand/

# 事前準備

ボールドインベントリの取得可能となったことを通知するために、ボールドを作成したリージョンと同一リージョンにて、Amazon SNSのトピックとサブスクリプションを作成する。

## トピックの作成
* タイプ: スタンダード
* 名前: gtool-sns

## サブスクリプションの作成
上記で作成したgtool-snsトピックを開き、サブスクリプションを作成する。
* トピックARN: arn:aws:sns:<リージョンID>:<アカウントID>:gtool-sns
* プロトコル: Eメール
* エンドポイント: 通知を受け取るメールアドレス

# ソースコード
## バックエンド
### InitiateJobCommandを実行するinitiateInventoryJob API
```javascript:src/app/api/initiateInventoryJob/route.ts
import { NextRequest, NextResponse } from "next/server";
import { GlacierClient, InitiateJobCommand } from "@aws-sdk/client-glacier";
import { MongoClient } from "mongodb";

export const corsHeaders = {
  "Access-Control-Allow-Origin": "http://localhost:3000",
  "Access-Control-Allow-Methods": "POST, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type",
};

export async function POST(request: NextRequest): Promise<NextResponse> {
  const params = await request.json();
  const vaultName = params["vaultName"];
  const client = new GlacierClient({});
  let input: {
    accountId: string;
    jobParameters: { Format: string; SNSTopic: string; Type: string };
    vaultName: string;
  } = {
    accountId: "",
    jobParameters: {
      Format: "",
      SNSTopic: "",
      Type: "",
    },
    vaultName: "",
  };
  input.accountId = "-";
  input.jobParameters.Format = "JSON";
  input["jobParameters"]["SNSTopic"] =
    "arn:aws:sns:<リージョンID>:<アカウントID>:gtool-sns";
  input["jobParameters"]["Type"] = "inventory-retrieval";
  if (vaultName) {
    input["vaultName"] = vaultName;
  }
  const command = new InitiateJobCommand(input);
  let response = await client.send(command);

  const dbclient = await MongoClient.connect(
    "mongodb://127.0.0.1/27017/glacier"
  );

  const db = dbclient.db("glacier");
  const userVaults = db.collection("userInventoryJobs");
  userVaults.insertOne({ info: response, vaultName: vaultName });

  return NextResponse.json({}, { status: 200, headers: corsHeaders });
}

export async function OPTIONS() {
  return NextResponse.json({}, { headers: corsHeaders });
}
```
### DescribeJobCommandを実行するdescribeInventoryJob API
```javascript:src/app/api/describeInventoryJob/route.ts
import { NextRequest, NextResponse } from "next/server";
import { GlacierClient, DescribeJobCommand } from "@aws-sdk/client-glacier";
import { MongoClient } from "mongodb";

export const corsHeaders = {
  "Access-Control-Allow-Origin": "http://localhost:3000",
  "Access-Control-Allow-Methods": "POST",
  "Access-Control-Allow-Headers": "Content-Type",
};

export async function POST(request: NextRequest): Promise<NextResponse> {
  const client = new GlacierClient({});
  let input: {
    accountId: string;
    jobId: string;
    vaultName: string;
  } = {
    accountId: "",
    jobId: "",
    vaultName: "",
  };

  const dbclient = await MongoClient.connect(
    "mongodb://127.0.0.1/27017/glacier"
  );

  const db = dbclient.db("glacier");
  const userArchiveJobs = db.collection("userInventoryJobs");
  const cursor = userArchiveJobs.find();

  for await (const doc of cursor) {
    input.accountId = "-";
    input["jobId"] = doc.info.jobId;
    input["vaultName"] = doc.vaultName;
    const command = new DescribeJobCommand(input);
    let response = await client.send(command);

    const userVaults = db.collection("userInventoryJobs");
    const query = { "info.jobId": response.JobId };
    const update = {
      $set: {
        info: {
          Action: response.Action,
          Completed: response.Completed,
          CreationDate: response.CreationDate,
          InventoryRetrievalParameters: response.InventoryRetrievalParameters,
          jobId: response.JobId,
          SNSTopic: response.SNSTopic,
          StatusCode: response.StatusCode,
          VaultARN: response.VaultARN,
        },
      },
    };
    userVaults.updateOne(query, update);
  }

  return NextResponse.json({}, { status: 200, headers: corsHeaders });
}
```
### GetJobOutputCommandを実行するgetInventoryJobOutput API
```javascript:src/app/api/getInventoryJobOutput/route.ts
import { NextRequest, NextResponse } from "next/server";
import { GlacierClient, GetJobOutputCommand } from "@aws-sdk/client-glacier";
import { MongoClient } from "mongodb";

export const corsHeaders = {
  "Access-Control-Allow-Origin": "http://localhost:3000",
  "Access-Control-Allow-Methods": "POST",
  "Access-Control-Allow-Headers": "Content-Type",
};

export async function POST(request: NextRequest): Promise<NextResponse> {
  const client = new GlacierClient({});
  let input: {
    accountId: string;
    jobId: string;
    range: string;
    vaultName: string;
  } = {
    accountId: "",
    jobId: "",
    range: "",
    vaultName: "",
  };

  const dbclient = await MongoClient.connect(
    "mongodb://127.0.0.1/27017/glacier"
  );

  const db = dbclient.db("glacier");
  const userArchiveJobs = db.collection("userInventoryJobs");
  const cursor = userArchiveJobs.find();

  for await (const doc of cursor) {
    input.accountId = "-";
    input.jobId = doc.info.jobId;
    input["vaultName"] = doc.vaultName;
    const command = new GetJobOutputCommand(input);
    let response = await client.send(command);
    let data;
    if (response.body) {
      data = JSON.parse(await response.body.transformToString("utf-8"));
    } else {
      console.error(`No body in response for job ${input.jobId}`);
      return NextResponse.json({}, { status: 400, headers: corsHeaders });
    }

    const userVaults = db.collection("userVaults");
    const query = { VaultARN: data.VaultARN };
    const update: any = {
      $set: {
        VaultARN: data.VaultARN,
        InventoryDate: data.InventoryDate,
        ArchiveList: data.ArchiveList,
      },
    };
    const options = { upsert: true };
    userVaults.updateOne(query, update, options);
  }

  return NextResponse.json({}, { status: 200, headers: corsHeaders });
}
```
## フロントエンド
```diff_javascript:src/app/page.tsx
"use client";
import axios from "axios";
- import { Button, Stack, Paper } from "@mui/material";
+ import { TextField, Button, Stack, Paper } from "@mui/material";
+ import { useForm, SubmitHandler } from "react-hook-form";

+ type Inputs = {
+   inventoryVaultName: string;
+ };

export default function Home() {
+   const { register, handleSubmit } = useForm<Inputs>({
+     defaultValues: { inventoryVaultName: "" },
+   });
  const listVaults = async () => {
    await axios.post("http://localhost:4000/api/listVaults");
  };
+   const initiateInventoryJob: SubmitHandler<Inputs> = async (data) => {
+     await axios.post("http://localhost:4000/api/initiateInventoryJob", {
+       vaultName: data.inventoryVaultName,
+     });
+   };
+   const describeInventoryJob = async () => {
+     await axios.post("http://localhost:4000/api/describeInventoryJob");
+   };
+   const getInventoryJobOutput = async () => {
+     await axios.post("http://localhost:4000/api/getInventoryJobOutput");
+   };
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <div className="z-10 max-w-10xl w-full items-center justify-between font-mono text-sm lg:flex">
        <Stack spacing={2}>
          <Paper>
            <Button
              variant="outlined"
              onClick={listVaults}
              style={{
                textTransform: "none",
              }}
            >
              ボールド一覧を登録する
            </Button>
          </Paper>
+           <Paper>
+             <TextField
+               {...register("inventoryVaultName")}
+               label="ボールド名"
+               variant="outlined"
+             />
+             <Button
+               variant="outlined"
+               onClick={handleSubmit(initiateInventoryJob)}
+               style={{
+                 textTransform: "none",
+               }}
+             >
+               ボールドインベントリを要求する
+             </Button>
+             <Button
+               variant="outlined"
+               onClick={describeInventoryJob}
+               style={{
+                 textTransform: "none",
+               }}
+             >
+               進捗状況を確認する
+             </Button>
+             <Button
+               variant="outlined"
+               onClick={getInventoryJobOutput}
+               style={{
+                 textTransform: "none",
+               }}
+             >
+               ボールドインベントリを登録する
+             </Button>
+           </Paper>
        </Stack>
      </div>
    </main>
  );
}
```

# 実行結果
## フロントエンド
ブラウザを開き、`http://localhost:3000` にアクセスする。
![Screenshot from 2024-03-27 11-37-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748326/3e8ba6a1-5ce0-ba01-c6f5-c31c0977b483.png)

### ボールドインベントリの要求
ボールドインベントリを要求するボールド名を入力後、[ボールドインベントリを要求する]ボタンを押すと、ボールドインベントリを取得するジョブの情報がMongoDBに登録される。
![Screenshot from 2024-03-27 11-40-39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748326/dc7df959-2e1e-e35f-17f9-2f39a999820d.png)


### 進捗状況の取得
[進捗状況を確認する]ボタンを押すと、ボールドインベントリを取得するジョブの情報に進捗情報が追加される。
![Screenshot from 2024-03-27 11-43-18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748326/68567345-f75f-e5e8-66aa-718f04076bce.png)

### ボールドインベントリの登録
ボールドインベントリの要求後数時間待つと、ボールドインベントリ取得の準備が整ったことを通知するメールが送信される。メールを受信後、［ボールドインベントリを登録する］ボタンを押して数分待つと、ボールド一覧のボールド情報に、ボールドインベントリが追加される。
（ボールドインベントリのダウンロードと情報の反映のため数分待つ必要がある。進捗状況を示す等のフロントエンドの改善は今後検討する。）
![Screenshot from 2024-03-27 11-52-32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748326/41c90a15-b562-3cf3-9c1e-13dcb3d2e825.png)

その4に続く。

https://qiita.com/sat0sh1kawada/items/4a18225eb9aa3aaa94a0