Box API の Webhook を試してみる

まとめ記事ができました

このスクラップについて
Box API には Webhook 機能があり、何かイベントが発生した時に通知を受け取ることができる。
このスクラップでは Webhook を使えるようにするまでの設定手順や Webhook を受け取った時に実行されるコードの書き方をまとめていきたいと思う。

Box 関連スクラップ

Webhook を設定する前に
Webhook を受け取るエンドポイントを作る必要がある。
Spring Boot で GCP Cloud Run で良いかな。
何気に初めてなので上手くいくか不安だ。
上手くいかなかったら別のスクラップを作って問題解決の手順をまとめていこう。

起動確認
ダウンロードした zip ファイルを展開して IntelliJ IDEA で開く。
▶︎ ボタンを押すなどして起動できるかを確認する。
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.0.6)
2023-05-02T09:22:31.059+09:00 INFO 2903 --- [ main] com.example.demo.DemoApplication : Starting DemoApplication using Java 17.0.6 with PID 2903 (/Users/susukida/workspace/java/box-webhook/build/classes/java/main started by susukida in /Users/susukida/workspace/java/box-webhook)
2023-05-02T09:22:31.062+09:00 INFO 2903 --- [ main] com.example.demo.DemoApplication : No active profile set, falling back to 1 default profile: "default"
2023-05-02T09:22:31.803+09:00 INFO 2903 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8080 (http)
2023-05-02T09:22:31.814+09:00 INFO 2903 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2023-05-02T09:22:31.815+09:00 INFO 2903 --- [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/10.1.8]
2023-05-02T09:22:31.896+09:00 INFO 2903 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
2023-05-02T09:22:31.897+09:00 INFO 2903 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 793 ms
2023-05-02T09:22:32.159+09:00 INFO 2903 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
2023-05-02T09:22:32.166+09:00 INFO 2903 --- [ main] com.example.demo.DemoApplication : Started DemoApplication in 1.383 seconds (process running for 1.63)

Cloud Run ドキュメント for Java

コーディング
package com.example.demo;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
public class DemoApplication {
@Value("${NAME:World}")
String name;
@RestController
class HelloworldController {
@GetMapping("/")
String hello() {
return "Hello " + name + "!";
}
}
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}

ローカル動作確認
http://localhost:8080/ にアクセスして Hello World!
と表示されることを確認する。

環境変数
NAME 環境変数を設定したらレスポンスが変わる。
Hello Spring Boot!

環境変数でポート指定
server.port=${PORT:8080}
試してみる。
http://localhost:3000/ にアクセスして Hello Spring Boot!
と表示されることを確認する。

デプロイ前の確認
cd ~/workspace/java/box-webhook
gcloud meta list-files-for-upload
gradlew
build.gradle
gradlew.bat
settings.gradle
gradle/wrapper/gradle-wrapper.jar
gradle/wrapper/gradle-wrapper.properties
src/test/java/com/example/demo/DemoApplicationTests.java
src/main/resources/application.properties
src/main/java/com/example/demo/DemoApplication.java
gradle/wrapper/gradle-wrapper.jar って必要なのかな?
下記によると必要そうだ。

デプロイ
gcloud config configurations activate default
gcloud run deploy box-webhook \
--source . \
--platform managed \
--region asia-northeast1 \
--allow-unauthenticated

ビルド失敗
少し長いけど BUILDING ログ全文は下記の通り。
===> BUILDING
[builder] === Java - Runtime (google.java.runtime@0.9.1) ===
[builder] Using latest Java 11 runtime version. You can specify a different version with GOOGLE_RUNTIME_VERSION: https://github.com/GoogleCloudPlatform/buildpacks#configuration
[builder] 2023/05/02 05:26:02 [DEBUG] GET https://dl.google.com/runtimes/ubuntu1804/openjdk/version.json
[builder] Installing v11.0.19+7.
[builder] 2023/05/02 05:26:03 [DEBUG] GET https://dl.google.com/runtimes/ubuntu1804/openjdk/openjdk-11.0.19_7.tar.gz
[builder] Warning: BOM table is deprecated in this buildpack api version, though it remains supported for backwards compatibility. Buildpack authors should write BOM information to <layer>.sbom.<ext>, launch.sbom.<ext>, or build.sbom.<ext>.
[builder] Warning: BOM table is deprecated in this buildpack api version, though it remains supported for backwards compatibility. Buildpack authors should write BOM information to <layer>.sbom.<ext>, launch.sbom.<ext>, or build.sbom.<ext>.
[builder] Warning: BOM table is deprecated in this buildpack api version, though it remains supported for backwards compatibility. Buildpack authors should write BOM information to <layer>.sbom.<ext>, launch.sbom.<ext>, or build.sbom.<ext>.
[builder] === Java - Gradle (google.java.gradle@0.9.0) ===
[builder] --------------------------------------------------------------------------------
[builder] Running "./gradlew clean assemble -x test --build-cache --quiet"
[builder] Downloading https://services.gradle.org/distributions/gradle-7.6.1-bin.zip
[builder] ...........10%............20%...........30%............40%............50%...........60%............70%............80%...........90%............100%
[builder]
[builder] FAILURE: Build failed with an exception.
[builder]
[builder] * What went wrong:
[builder] A problem occurred configuring root project 'demo'.
[builder] > Could not resolve all files for configuration ':classpath'.
[builder] > Could not resolve org.springframework.boot:spring-boot-gradle-plugin:3.0.6.
[builder] Required by:
[builder] project : > org.springframework.boot:org.springframework.boot.gradle.plugin:3.0.6
[builder] > No matching variant of org.springframework.boot:spring-boot-gradle-plugin:3.0.6 was found. The consumer was configured to find a runtime of a library compatible with Java 11, packaged as a jar, and its dependencies declared externally, as well as attribute 'org.gradle.plugin.api-version' with value '7.6.1' but:
[builder] - Variant 'apiElements' capability org.springframework.boot:spring-boot-gradle-plugin:3.0.6 declares a library, packaged as a jar, and its dependencies declared externally:
[builder] - Incompatible because this component declares an API of a component compatible with Java 17 and the consumer needed a runtime of a component compatible with Java 11
[builder] - Other compatible attribute:
[builder] - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
[builder] - Variant 'javadocElements' capability org.springframework.boot:spring-boot-gradle-plugin:3.0.6 declares a runtime of a component, and its dependencies declared externally:
[builder] - Incompatible because this component declares documentation and the consumer needed a library
[builder] - Other compatible attributes:
[builder] - Doesn't say anything about its target Java version (required compatibility with Java 11)
[builder] - Doesn't say anything about its elements (required them packaged as a jar)
[builder] - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
[builder] - Variant 'mavenOptionalApiElements' capability org.springframework.boot:spring-boot-gradle-plugin-maven-optional:3.0.6 declares a library, packaged as a jar, and its dependencies declared externally:
[builder] - Incompatible because this component declares an API of a component compatible with Java 17 and the consumer needed a runtime of a component compatible with Java 11
[builder] - Other compatible attribute:
[builder] - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
[builder] - Variant 'mavenOptionalRuntimeElements' capability org.springframework.boot:spring-boot-gradle-plugin-maven-optional:3.0.6 declares a runtime of a library, packaged as a jar, and its dependencies declared externally:
[builder] - Incompatible because this component declares a component compatible with Java 17 and the consumer needed a component compatible with Java 11
[builder] - Other compatible attribute:
[builder] - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
[builder] - Variant 'runtimeElements' capability org.springframework.boot:spring-boot-gradle-plugin:3.0.6 declares a runtime of a library, packaged as a jar, and its dependencies declared externally:
[builder] - Incompatible because this component declares a component compatible with Java 17 and the consumer needed a component compatible with Java 11
[builder] - Other compatible attribute:
[builder] - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
[builder] - Variant 'sourcesElements' capability org.springframework.boot:spring-boot-gradle-plugin:3.0.6 declares a runtime of a component, and its dependencies declared externally:
[builder] - Incompatible because this component declares documentation and the consumer needed a library
[builder] - Other compatible attributes:
[builder] - Doesn't say anything about its target Java version (required compatibility with Java 11)
[builder] - Doesn't say anything about its elements (required them packaged as a jar)
[builder] - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
[builder]
[builder] * Try:
[builder] > Run with --stacktrace option to get the stack trace.
[builder] > Run with --info or --debug option to get more log output.
[builder] > Run with --scan to get full insights.
[builder]
[builder] * Get more help at https://help.gradle.org
[builder]
[builder] BUILD FAILED in 17s
[builder] Done "./gradlew clean assemble -x test --build-cache --quiet" (18.959693404s)
[builder] Failure: (ID: 14f2a5b3) ...ringframework.boot:spring-boot-gradle-plugin:3.0.6 declares a runtime of a component, and its dependencies declared externally:
[builder] - Incompatible because this component declares documentation and the consumer needed a library
[builder] - Other compatible attributes:
[builder] - Doesn't say anything about its target Java version (required compatibility with Java 11)
[builder] - Doesn't say anything about its elements (required them packaged as a jar)
[builder] - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
[builder] - Variant 'mavenOptionalApiElements' capability org.springframework.boot:spring-boot-gradle-plugin-maven-optional:3.0.6 declares a library, packaged as a jar, and its dependencies declared externally:
[builder] - Incompatible because this component declares an API of a component compatible with Java 17 and the consumer needed a runtime of a component compatible with Java 11
[builder] - Other compatible attribute:
[builder] - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
[builder] - Variant 'mavenOptionalRuntimeElements' capability org.springframework.boot:spring-boot-gradle-plugin-maven-optional:3.0.6 declares a runtime of a library, packaged as a jar, and its dependencies declared externally:
[builder] - Incompatible because this component declares a component compatible with Java 17 and the consumer needed a component compatible with Java 11
[builder] - Other compatible attribute:
[builder] - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
[builder] - Variant 'runtimeElements' capability org.springframework.boot:spring-boot-gradle-plugin:3.0.6 declares a runtime of a library, packaged as a jar, and its dependencies declared externally:
[builder] - Incompatible because this component declares a component compatible with Java 17 and the consumer needed a component compatible with Java 11
[builder] - Other compatible attribute:
[builder] - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
[builder] - Variant 'sourcesElements' capability org.springframework.boot:spring-boot-gradle-plugin:3.0.6 declares a runtime of a component, and its dependencies declared externally:
[builder] - Incompatible because this component declares documentation and the consumer needed a library
[builder] - Other compatible attributes:
[builder] - Doesn't say anything about its target Java version (required compatibility with Java 11)
[builder] - Doesn't say anything about its elements (required them packaged as a jar)
[builder] - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
[builder]
[builder] * Try:
[builder] > Run with --stacktrace option to get the stack trace.
[builder] > Run with --info or --debug option to get more log output.
[builder] > Run with --scan to get full insights.
[builder]
[builder] * Get more help at https://help.gradle.org
[builder]
[builder] BUILD FAILED in 17s
[builder] --------------------------------------------------------------------------------
[builder] Sorry your project couldn't be built.
[builder] Our documentation explains ways to configure Buildpacks to better recognise your project:
[builder] -> https://cloud.google.com/docs/buildpacks/overview
[builder] If you think you've found an issue, please report it:
[builder] -> https://github.com/GoogleCloudPlatform/buildpacks/issues/new
[builder] --------------------------------------------------------------------------------
[builder] ERROR: failed to build: exit status 1
ERROR: failed to build: executing lifecycle. This may be the result of using an untrusted builder: failed with status code: 51
ERROR
ERROR: build step 0 "gcr.io/k8s-skaffold/pack" failed: step exited with non-zero status: 1

原因はJava バージョン?
17 が必要なのに 11 になっている気がする。
Java バージョンを指定するには GOOGLE_RUNTIME_VERSION 環境変数を使えば良いらしい。
でもどうやってビルド時の環境変数を指定すれば良いんだろう?

project.toml を使えば OK
よく読んだらドキュメントにしっかり書いてあった。
You can also use a project.toml project descriptor to encode the environment variable alongside your project files. See instructions on building the application with environment variables.

project.toml の作成
touch project.toml
[[build.env]]
name = "GOOGLE_RUNTIME_VERSION"
value = "17"
下記のブログ記事も詳しい。

デプロイ再挑戦
gcloud config configurations activate default
gcloud run deploy box-webhook \
--source . \
--platform managed \
--region asia-northeast1 \
--allow-unauthenticated

もしも Spring Boot 2 系だったら
Cloud Run のドキュメント通り Spring Boot 2 系を使っていたら Java 11 でも大丈夫なのでわざわざ 17 を指定する必要はなかったのかもしれない。

ビルドが成功した
Node.js よりも早い気がする。
起動はどうだろう、Java は結構時間がかかりそうなイメージ。

デプロイも成功した
https://box-webhook-xxxx-an.a.run.app のような URL が発行されるのでアクセスして Hello World!
と表示されれば成功している。
そしてめちゃくちゃ起動が高速かもしれない、結構時間がかかりそうとか思ってごめん。

引き続き Webhook
無事に準備が終わったので引き続き本題の Webhook をやっていこう。

Webhook バージョン
V1 と V2 の 2 つがあり、ルートフォルダーを対象にしたい場合は V1 である必要があるがそれ以外は V2 を使った方が良さそう。

今日はここまで
次回も頑張ろう。

今日はここから
Webhook V2 を作成していく。

ドキュメント
下記が参考になりそう。

試用期間が終わると Webhook を試せない?
Business プラン契約前のスクラップを振り返っていたらカスタムアプリのページに Webhook のタブが無いことに気がついた。
サーバー認証
ユーザー認証
Business プラン契約中のカスタムアプリ(サーバー認証)では Webhook タブがある。
Business プラン契約中のカスタムアプリ(サーバー認証)
5/5 (水) で無料試用期間が終わってしまうのでそれまでに Webhook のキャッチアップを終わらせよう。

Box API Android SDK
スクリーンショットで気付いたけど 2023 年 6 月 1 日でサポート終了なんだな。

カスタムアプリ作成
Hello Webhook という名前でサーバー認証カスタムアプリを作成する。
作成されたカスタムアプリ

Webhook を作成しようとしてみる
V2 Webhookを作成できるのは、[Webhookを管理する] というスコープが選択され、アプリケーションが承認されている場合のみです。
期待通り作成できない。
V2 が非活性になっている

スコープ変更
「Webhook を管理する」にチェックを入れてから保存ボタンを押す。
保存ボタンを押す前の状態

この時点で作成しようとしてみる
カスタムアプリが承認されていないので Webhook V2 を期待通り作成できない。
まだ V2 が非活性になっている

カスタムアプリ承認
管理コンソールでカスタムアプリを承認する。
その際にクライアント ID が必要になるので開発者コンソールで控えておく。
開発者コンソールのカスタムアプリ構成タブ
管理コンソールのカスタムアプリマネージャ
Webhook V2 がスコープに入っていることが確認できる

この時点で作成しようとしてみる
V2 が遂に活性化された

はじめての Webhook 作成
Webhook V2 作成フォーム
「項目を選択」を押した状態
フォルダーを選択した後
Webhook V2 を作成するには下記 3 つを設定する必要があるようだ。
- 通知先のURL
- 監視対象のファイル/フォルダー
- 監視対象のトリガー

検証用フォルダー作成
Webhook という名前でフォルダーを作成する

Webhook 作成
Webhook フォルダーの File Uploaded イベントを監視する Webhook を作成する

無事に作成できた

あまり関係ないけど
Spring Boot on Cloud Run のコールドスタートはやはり結構時間がかかった。
体感で 8 秒くらい?
高速だと思ったのは勘違いだったようだ。

Webhook HTTP メソッド
特に記載はないけど POST で良いのかな?

Spring Boot でリクエストボディ取得
どうすれば良いんだろう?
調べよう。

見よう見まねでやってみる
package com.example.demo;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
public class DemoApplication {
@Value("${NAME:World}")
String name;
record WebhookRequest(String type) {}
@RestController
class HelloworldController {
@GetMapping("/")
String hello() {
return "Hello " + name + "!";
}
@PostMapping("/")
String webhook(@RequestBody WebhookRequest request) {
System.out.println(request.type);
return "OK";
}
}
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}

動作確認
curl -X POST http://localhost:3000/ \
-H 'Content-Type: application/json' \
-d '{"type": "webhook_event"}'
IntelliJ IDEA のコンソールに下記のように出力されたら成功。
webhook_event

makefile 作成
デプロイコマンドを毎回実行するのが大変なので makefile を作成しておく。
cd ~/workspace/java/box-webhook
touch makefile
deploy:
gcloud run deploy box-webhook \
--source . \
--platform managed \
--region asia-northeast1 \
--allow-unauthenticated

デプロイ
これで簡単にデプロイできる。
make deploy

デプロイ後の動作確認
curl -X POST https://box-webhook-xxxx-an.a.run.app \
-H 'Content-Type: application/json' \
-d '{"type": "webhook_event"}'
OK
と表示されたら成功。

Cloud Run ログ確認
webhook_event
と出力されていることを確認できた。

Box ファイルアップロード
適当なファイルをアップロードする

Cloud Run ログ確認
再度ログを確認する
Cloud Run ログページ
webhook_type
と 2 回出力されていることを確認できた。

リクエストボディの JSON 文字列が欲しい
下記のようにやったら普通にできた。
@PostMapping("/")
String webhook(@RequestBody String requestBody) {
System.out.println(requestBody);
return "OK";
}

Spring Boot の JSON 変換
Jackson というライブラリが使用されているようだ。
裏は取れなかったが古いドキュメントを見つけた。

Jackson による JSON パース
ObjectMapper mapper = new ObjectMapper();
WebhookRequest request = mapper.readValue(requestBody, WebhookRequest.class);

署名の検証
今の状態だと Box 以外からのアクセスであっても Webhook が実行されてしまう。
これを防ぐためには署名の検証という手順を追加する必要がある。

署名キー生成
開発者コンソールからカスタムアプリ Webhook タブの「署名キーを管理」ボタンを押す。
プライマリキーとセカンダリキーの「キーを生成」ボタンを押す。
キーが生成されたら環境変数などに控えておく。
- BOX_PRIMARY_KEY
- BOX_SECONDARY_KEY

Box API Java SDK 追加
build.gradle を編集する。
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'com.box:box-java-sdk:4.1.0' // この行を追加しました。
}
IntelliJ IDEA の Gradle リロードボタンを押し忘れないようにする。

Spring Boot リクエストヘッダー取得
@PostMapping("/")
String webhook(
@RequestBody String requestBody,
@RequestHeader Map<String, String> headers
) throws JsonProcessingException {
System.out.println(headers.get("content-type"));
return "OK";
}

署名検証のコーディング
package com.example.demo;
import com.box.sdk.BoxWebHookSignatureVerifier;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@SpringBootApplication
public class DemoApplication {
@Value("${NAME:World}")
String name;
record WebhookRequest(String type) {
}
@RestController
class HelloworldController {
@GetMapping("/")
String hello() {
return "Hello " + name + "!";
}
@PostMapping("/")
String webhook(
@RequestBody String requestBody,
@RequestHeader Map<String, String> headers
) throws JsonProcessingException {
String primaryKey = System.getenv("BOX_PRIMARY_KEY");
String secondaryKey = System.getenv("BOX_SECONDARY_KEY");
BoxWebHookSignatureVerifier verifier = new BoxWebHookSignatureVerifier(primaryKey, secondaryKey);
String signatureVersion = headers.get("box-signature-version");
String signatureAlgorithm = headers.get("box-signature-algorithm");
String primarySignature = headers.get("box-signature-primary");
String secondarySignature = headers.get("box-signature-secondary");
String webHookPayload = requestBody;
String deliveryTimestamp = headers.get("box-signature-timestamp");
boolean isValidMessage = verifier.verify(
signatureVersion,
signatureAlgorithm,
primarySignature,
secondarySignature,
webHookPayload,
deliveryTimestamp
);
System.out.println(isValidMessage);
return "OK";
}
}
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}

動作確認
さすがに curl ではきついので TypeScript でテストコードを書こう。
mkdir ~/workspace/js/box-webhook-test
cd ~/workspace/js/box-webhook-test
npm init -y
npm install --save node-fetch@2 dotenv
npm install --save-dev @types/node @types/node-fetch ts-node
touch main.ts .env

テストコード
import { createHmac } from "crypto";
import fetch from "node-fetch";
async function main() {
const primaryKey = process.env.BOX_PRIMARY_KEY!;
const secondaryKey = process.env.BOX_SECONDARY_KEY!;
const messagePayload = JSON.stringify({ type: "webhook_event" });
const deliveryId = "f96bb54b-ee16-4fc5-aa65-8c2d9e5b546f";
const deliveryTimestamp = new Date().toISOString();
const signatureAlgorithm = "HmacSHA256";
const signaturePrimary = createHmac("sha256", primaryKey)
.update(messagePayload)
.update(deliveryTimestamp)
.digest("base64");
const signatureSecondary = createHmac("sha256", secondaryKey)
.update(messagePayload)
.update(deliveryTimestamp)
.digest("base64");
const signatureVersion = "1";
const fetchUrl = "http://localhost:3000/";
const fetchResponse = await fetch(fetchUrl, {
method: "POST",
headers: {
"box-delivery-id": deliveryId,
"box-delivery-timestamp": deliveryTimestamp,
"box-signature-algorithm": signatureAlgorithm,
"box-signature-primary": signaturePrimary,
"box-signature-secondary": signatureSecondary,
"box-signature-version": signatureVersion,
"content-type": "application/json",
},
body: messagePayload,
});
console.log(fetchResponse.status);
}
main().catch((err) => console.error(err));
BOX_PRIMARY_KEY="xxxx"
BOX_SECONDARY_KEY="yyyy"

テストコード実行
npx ts-node -r dotenv/config main.ts
500
IntelliJ IDEA のコンソール出力は下記の通り。
java.lang.NullPointerException: Cannot invoke "java.lang.CharSequence.length()" because "csq" is null

気合いでデバッグ
System.out.println(primaryKey);
System.out.println(secondaryKey);
System.out.println(signatureVersion);
System.out.println(signatureAlgorithm);
System.out.println(primarySignature);
System.out.println(secondarySignature);
System.out.println(webHookPayload);
System.out.println(deliveryTimestamp);
xxxx
yyyy
1
HmacSHA256
LcbTtjXVP5wI+Cc6ffOCl8AHg4hABg2Qp8MMQYSwoR0=
oh8wOblMc3nl5VHaZ+H0RJG9Iw3XEqdiEoVYwWbvAIA=
{"type":"webhook_event"}
null
deliveryTimestamp
が空のようだ。

ヘッダー修正
String deliveryTimestamp = headers.get("box-signature-timestamp");
String deliveryTimestamp = headers.get("box-delivery-timestamp");

動作確認
npx ts-node -r dotenv/config main.ts
200
true
成功している様子だ。

最終的なソースコード
署名の検証に成功した場合にリクエストボディをコンソール出力するようにした。
package com.example.demo;
import com.box.sdk.BoxWebHookSignatureVerifier;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@SpringBootApplication
public class DemoApplication {
@Value("${NAME:World}")
String name;
record WebhookRequest(String type) {
}
@RestController
class HelloworldController {
@GetMapping("/")
String hello() {
return "Hello " + name + "!";
}
@PostMapping("/")
ResponseEntity<String> webhook(
@RequestBody String requestBody,
@RequestHeader Map<String, String> headers
) throws JsonProcessingException {
String primaryKey = System.getenv("BOX_PRIMARY_KEY");
String secondaryKey = System.getenv("BOX_SECONDARY_KEY");
BoxWebHookSignatureVerifier verifier = new BoxWebHookSignatureVerifier(primaryKey, secondaryKey);
String signatureVersion = headers.get("box-signature-version");
String signatureAlgorithm = headers.get("box-signature-algorithm");
String primarySignature = headers.get("box-signature-primary");
String secondarySignature = headers.get("box-signature-secondary");
String webHookPayload = requestBody;
String deliveryTimestamp = headers.get("box-delivery-timestamp");
boolean isValidMessage = verifier.verify(
signatureVersion,
signatureAlgorithm,
primarySignature,
secondarySignature,
webHookPayload,
deliveryTimestamp
);
if (!isValidMessage) {
System.out.println("!isValidMessage");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Error: Invalid Message");
}
System.out.println(requestBody);
return ResponseEntity.status(HttpStatus.OK).body("Success");
}
}
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}

デプロイ
make deploy

動作確認
Box Web UI で適当なファイルをアップロードする。

Cloud Run ログ確認
エラーが表示されてしまった。
もう少しで終わりなんだけどなあ。

あ!
環境変数の設定を忘れている。

成功した
もう一度適当なファイルをアップロードしてから Cloud Run ログを見たら Webhook の JSON が記録されていた。
形式もドキュメントと同じなので大丈夫な様子だ。

まとめ
- Spring Boot で環境変数を読み込むには
@Value("${NAME:World}")
のように書く。 - 起動ポート番号を指定するには application.properties を使用する。
-
server.port=${PORT:8080}
のように書く。 - gradle/wrapper/gradle-wrapper.jar はバージョン管理に含めた方が良い。
- Spring Boot 3 を Cloud Run にデプロイするには Java 17 を明示的に指定する必要がある。
- Java バージョン指定には project.toml ファイルと GOOGLE_RUNTIME_VERSION 環境変数を使う。
- Box Webhook には V1 と V2 の 2 つがあり、V2 の使用が推奨される。
- Webhook V1 はルートフォルダーを指定する必要がある場合以外は使わなくても大丈夫。
- Box Individual プランなど無料バージョンでは開発者コンソールに Webhook タブがない。
- Webhook V2 を作成できる条件はスコープに Webhook 管理が含まれ、管理者によって承認されていることの 2 つ。
- Webhook V2 の作成時は通知先 URL、監視対象のファイル/フォルダー、監視対象のトリガーの 3 点を指定する。
- Webhook の HTTP メソッドには POST が使用される。
- Spring Boot でリクエストボディを受け取るには
record
クラスと@RequestBody
アノテーションを使用する。 - リクエストボディの JSON 文字列を取得するには
record
クラスの代わりに String クラスを使用する。 - Spring Boot では JSON 変換に Jackson が使用されている。
- Jackson による JSON パースでは ObjectMapper クラスを使用する。
- 他のWebhook と同様になりすましを防ぐために HMAC(署名の検証)を使用する。
- 署名の検証に使用する共通鍵は開発者コンソールで生成できる。
- 鍵ローテーションのために署名の検証にはプライマリとセカンダリの 2 つの鍵が使用される。
- Spring Boot で HTTP ヘッダーを取得するためには
@RequestHeader
アノテーションを使用する。 - Box API Java SDK では署名検証用のクラスが提供されている。

おわりに
無事に Webhook をできることを検証できて良かった。
ファイルが更新された通知を受け取るにはどうすれば良いんだろう、FILE.UPLOAD で良いのかな?
あと Webhook リクエストボディ JSON ペイロードの record クラスが欲しい、SDK を探せばあるのかな?
まだまだ調べたいことがたくさんある。
Box にも Dropbox のようにローカルのクライアントがあると思う。
Webhook と組み合わせることでユーザーがマスタデータの Excel ファイルを編集した時にマスタデータを読み込むみたいな使い方ができそうだ。