API GatewayのVTLで$util.toJson()が配列要素に対して空文字列を返す

  • AWS
  • API Gateway
  • VTL
  • Firehose

この記事は、構成と文章の整理にAIを使っています。
あくまで読みやすくするための補助で、内容そのものをAIにすべて任せて書いているわけではありませんが、ところどころ相談しながら整えています。

API GatewayのAWSサービス統合(Lambdaを挟まないやつ)で、POSTされたJSON配列を1要素ずつKinesis FirehoseにPutRecordBatchする構成を組んだ。その過程で$util.toJson()がforeachループ内の要素に対して空文字列を返すという事象にハマったのでメモ。

やりたかったこと

こういうJSON配列がPOSTされてくる。

[
{ "item_id": 123, "type_id": 1 },
{ "item_id": 123, "type_id": 1 }
]

Firehose側では配列の要素ごとに1レコードとして受け取りたい。PutRecordBatchのリクエスト形式はDeliveryStreamNameとRecords(各要素はDataにBase64エンコードしたblobを持つ)なので、狙いは以下。

{
"DeliveryStreamName": "example-delivery-stream",
"Records": [
{ "Data": "Base64エンコードされたJSON" },
{ "Data": "Base64エンコードされたJSON" }
]
}

PutRecordは単数のRecord、PutRecordBatchは複数のRecordsなので、CDKのAwsIntegrationでactionをPutRecordBatchにする前提。

最初に書いたVTL

#set($inputRoot = $input.path('$'))
{
"DeliveryStreamName": "example-delivery-stream",
"Records": [
#foreach($elem in $inputRoot)
{
"Data": "$util.base64Encode($util.toJson($elem))"
}
#if($foreach.hasNext),#end
#end
]
}

$input.path('$')で配列を取得して#foreachで回すだけ。一見問題なさそうに見える。

$inputRootは配列として認識されている

デバッグのため型を出力してみる。

#set($inputRoot = $input.path('$'))
{
"Type": "$inputRoot.getClass().getName()"
}

結果、net.minidev.json.JSONArrayと返ってきた。API Gateway側のVTL実行系がJSONPath処理に使っているライブラリの内部クラス名がそのまま出てきている形で、AWSの公式ドキュメントに明記されている値ではないけど、少なくとも手元の環境では配列として正しく認識されていた。#foreach自体もちゃんと回っている。

それでも$util.toJson($elem)が空になる

最初の要素だけ取り出して確認する。

#set($firstElement = $inputRoot[0])
{
"firstElementExists": "#if($firstElement) true #else false #end",
"firstElement": "$util.toJson($firstElement)"
}

結果。

{
"firstElementExists": " true ",
"firstElement": ""
}

要素は存在するし取得もできている。なのに$util.toJson()を通すと空文字列になる。

さらに$firstElementを直接出力すると次のようになった。

{item_id=123, type_id=1}

これはJSONではなく、VTL(内部的にはJavaのMap)のtoString()表現。本来送りたい{"item_id":123,"type_id":1}とは別物になっている。

Firehose側のエラー

この状態でBase64エンコードすると、そもそもエンコード対象が空なので当然Dataが空になる。結果としてFirehose側から次のエラーが返ってきた。

ValidationException: 1 validation error detected: Value at 'record' failed to satisfy constraint: Member must not be null

一見Firehose側のリクエスト形式を疑いたくなるけど、原因はその手前のVTLでDataが正しく生成されていなかったこと。実際、Base64をデコードすると{item_id=123, type_id=1}のようなMap表現が出てきて、JSONとして不正な状態になっていた。

補足:API Gateway VTLではtoJsonが弱い

これはこの環境固有の偶然のバグというより、API Gateway VTL自体の既知の制約に近い話らしい。AppSyncのVTLでは$util.toJsonが正式にサポートされているのに対し、API Gateway側は同等のユーティリティが揃っていないという指摘が以前からある。VTLオブジェクトをJSON文字列へ変換する便利な方法自体が用意されていない、という声も見かけた(実機未確認の部分もあるので、あくまで参考情報として)。

回避方法:$input.json()でインデックス指定して取り直す

最終的にうまくいったのは、$elemをJSON化しようとするのではなく、元のリクエストボディから$input.json()でインデックス指定して直接JSON文字列を取り直す方法。

#set($statuses = $input.path('$'))
{
"DeliveryStreamName": "example-delivery-stream",
"Records": [
#foreach($elem in $statuses)
{
#set($json = $input.json("$[$foreach.index]"))
"Data": "$util.base64Encode($json)"
}
#if($foreach.hasNext),#end
#end
]
}

ポイントは$input.json("$[$foreach.index]")の部分。$foreach.indexにはループの現在位置(0始まり)が入るので、4要素なら実質的に$input.json("$[0]")〜$input.json("$[3]")を順に呼んでいることになる。

$input.path()で取得したオブジェクトを$util.toJson()で変換し直すのではなく、API Gatewayが受け取った元のJSON表現をJSONPathで直接引き直すことで、Map表現を経由せずに正しいJSON文字列が取れる。

$input.path()と$input.json()の違い

今回の切り分けで整理できたのがこの2つの使い分け。

API 用途
$input.path('$') JSONをVTLで操作できるオブジェクト(Map/Listなど)として取得
$input.json('$') 元のJSONをJSON文字列として取得

配列をループすること自体は$input.path()で問題なくできる。ただ、ループ中の要素を「JSON文字列」としてFirehoseなどの外部サービスに渡したい場合、少なくとも今回の環境では$util.toJson($elem)が期待通りに動かず、$input.json("$[インデックス]")で元のJSON文字列を直接引く方が確実だった。

flowchart TD
  A["$input.path('$')"] --> B[JSONArray]
  B --> C[foreach]
  C --> D["要素 = Map表現"]
  D --> E["$util.toJson($elem)"]
  E --> F["空文字列"]

  G["元のリクエストボディ"] --> H["$input.json($[index])"]
  H --> I[JSON文字列]
  I --> J[base64Encode]
  J --> K["Firehose Records[].Data"]

最終的なVTL

#set($statuses = $input.path('$'))
{
"DeliveryStreamName": "example-delivery-stream",
"Records": [
#foreach($elem in $statuses)
{
#set($json = $input.json("$[$foreach.index]"))
"Data": "$util.base64Encode($json)"
}
#if($foreach.hasNext),#end
#end
]
}

「$util.toJson()が常に空になる」と一般化するのは避けたほうがいい。今回確認できたのは、少なくともこのAPI Gateway/VTLの実行環境では、$input.path()で取得した配列要素に対する$util.toJson($elem)が期待通りのJSON文字列を返さなかった、という事象。同じ現象に当たった人がいたら、$input.json()でインデックス指定して取り直す方法を試してみるとよさそう。