DynamoDBの低レベルAPIをNode.jsから叩いてみた

# tl;dr

こちらをcloneして、READMEの通りにやってみてください！
DynamoDB、API Gatewayのサンプルコードがありますが、
他のAWSサービスにも応用はしやすくなっています。

https://github.com/the-red/dynamodb-low-level-api

# 背景

DynamoDBにデータを入出力するときにはAWS SDKを使う場合がほとんどだと思いますが、SDKの内部ではWeb APIを実行していて、SDKを使わずに生のAPIをそのまま叩くこともできます。公式ドキュメントには「低レベルAPI」として紹介されています。

https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Programming.LowLevelAPI.html

低レベルAPIを実行するには、認証のため「APIリクエストの署名」というのが必要です。通常はSDK側で吸収してくれてる部分ですね。
署名ヘッダを使うサンプルは、検索すると色々と出てきます。たとえばクラスメソッドさんのこの記事は、AWS SDK v3にも対応していて分かりやすかった。

https://dev.classmethod.jp/articles/api-gateway-iam-authentication-sigv4-aws-sdk-v3/

しかしAPI Gateway宛にリクエストするサンプルばかりで、DynamoDBに対してそのまま使えるサンプルコードがすぐには見つけられませんでした。
ということで、作ってみました！

# 解説

## DynamoDBテーブルの作成
[公式ドキュメント](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Programming.LowLevelAPI.html#Programming.LowLevelAPI.RequestFormat)に書いてあるとおりの構造でテーブルを作って、サンプルデータを入れておきます。

* テーブル名: `Pets`
* パーティションキー: `AnimalType`
* ソートキー: `Name`
* サンプルデータ: `{ "AnimalType": "Dog", "Name": "Fido" }`

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/cf8a75b2-03d1-8b83-19d3-27a9f5bdf4e6.png)

## SigV4署名ヘッダを使ったリクエスト

クラスメソッドさんの記事のコードをベースに、
汎用的に使えるようにモジュールにしました。

API GatewayでもDynamoDBでも、その他のサービスでも、
この`post`関数を使って署名ヘッダを使ったAPIリクエストができます。

```ts:awsLowLevelApi.ts
import dotenv from 'dotenv'
import { HttpRequest } from '@aws-sdk/protocol-http'
import { SignatureV4 } from '@aws-sdk/signature-v4'
import { Sha256 } from '@aws-crypto/sha256-universal'
import { defaultProvider } from '@aws-sdk/credential-provider-node'
import { request } from 'undici'
dotenv.config()

export const post = async ({
  serviceName,
  region,
  url,
  headers,
  body,
}: {
  serviceName: string
  region: string
  url: string
  headers: Record<string, string>
  body: any
}) => {
  const apiUrl = new URL(url)
  const signatureV4 = new SignatureV4({
    service: serviceName,
    region: region,
    credentials: defaultProvider(),
    sha256: Sha256,
  })
  const httpRequest = new HttpRequest({
    headers: {
      ...headers,
      host: apiUrl.hostname,
    },
    hostname: apiUrl.hostname,
    method: 'POST',
    path: apiUrl.pathname,
    body: JSON.stringify(body),
  })

  const signedRequest = await signatureV4.sign(httpRequest)

  return request(signedRequest)
}
```

## DynamoDBの低レベルAPIを叩く

DynamoDBの場合、
リクエスト先URLはリージョンごとに共通、
リクエストヘッダの`X-Amz-Target`でオペレーション名を指定、
というのが特徴ですね。
コンテンツタイプの`application/x-amz-json-1.0`というのは初めて見た。

`post`関数内ではヘッダ＆ボディの両方を使って署名ヘッダが作られ、その署名をさらにヘッダに追加して、AWSにリクエストしています。
リクエストボディが変わると署名は変わるし、署名の有効期限も短いので、「事前に署名を作っておいて何度も使い回す」ということは基本はできません。毎回新しく署名することで、APIトークンやパスワードよりも安全に通信できるわけですね。

```ts:execDynamoDb.ts
import { inspect } from 'util'
import { post } from './awsLowLevelApi'
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb'

post({
  serviceName: 'dynamodb',
  region: 'ap-northeast-1',
  url: 'https://dynamodb.ap-northeast-1.amazonaws.com',
  headers: {
    'Accept-Encoding': 'identity',
    'Content-Type': 'application/x-amz-json-1.0',
    'X-Amz-Target': 'DynamoDB_20120810.GetItem',
  },
  body: {
    TableName: 'Pets',
    Key: marshall({
      AnimalType: 'Dog',
      Name: 'Fido',
    }),
  },
}).then(async (res) => {
  const body = await res.body.json()
  let unmarshalled
  try {
    unmarshalled = unmarshall(body?.Item)
  } catch {
    unmarshalled = {}
  }
  console.log(
    inspect(
      {
        statusCode: res.statusCode,
        data: body,
        unmarshalled,
      },
      { colors: true, depth: Infinity }
    )
  )
})
```

実行結果はコチラ

```js
{
  statusCode: 200,
  data: { Item: { AnimalType: { S: 'Dog' }, Name: { S: 'Fido' } } },
  unmarshalled: { AnimalType: 'Dog', Name: 'Fido' }
}
```

## 参考: marshallとunmarshall

DynamoDBで使うJSONのフォーマットは、本来こんな形をしています。

```json
{
  "AnimalType": { "S": "Dog" },
  "Name": { "S": "Fido" }
}
```

このままでは人間が読み書きしづらいので、
普通のJSON形式と相互に変換する機能がSDKに用意されています。

```json
{
  "AnimalType": "Dog",
  "Name": "Fido"
}
```

普通のJSON → DynamoDB JSONへの変換を`marshall`
DynamoDB JSON → 普通のJSONへの変換を`unmarshall`
と呼びます。
marshallって単語は「軍隊の整列」みたいな意味らしい。

今回SDKを使わないのはあくまで「低レベルAPIを直に叩く」部分なので、
通信に関係ないmarshall, unmarshallは遠慮なくSDKを使いましたｗ

```ts
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb'

// DynamoDB JSON形式に変換
marshall({
  AnimalType: 'Dog',
  Name: 'Fido',
})

// 普通のJSON形式に戻す
unmarshall({
  AnimalType: { S: 'Dog' },
  Name: { S: 'Fido' },
})
```

これは覚えておくと、いろんな場面で便利ですよ〜。

# おわりに

SDKの裏側で何が行われているのか興味があって実際に動かしてみましたが、
少なくともDynamoDBをNode.jsで使う限りは、おとなしくSDKを使ったほうが絶対良いですｗ

この記事を応用して、SDKがない別の言語でAPIリクエストするとか、
Node.jsでAWSの他のサービスにAPIリクエストするとか、
そういう用途に役立てばよいなーと思っとります。

ではまた！