Closed72

Box API の Webhook を試してみる

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

このスクラップについて

Box API には Webhook 機能があり、何かイベントが発生した時に通知を受け取ることができる。

https://ja.developer.box.com/guides/webhooks/

このスクラップでは Webhook を使えるようにするまでの設定手順や Webhook を受け取った時に実行されるコードの書き方をまとめていきたいと思う。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Webhook を設定する前に

Webhook を受け取るエンドポイントを作る必要がある。

Spring Boot で GCP Cloud Run で良いかな。

何気に初めてなので上手くいくか不安だ。

上手くいかなかったら別のスクラップを作って問題解決の手順をまとめていこう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

起動確認

ダウンロードした 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)
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

コーディング

src/main/java/com/example/demo/DemoApplication.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);
    }
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

デプロイ前の確認

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 って必要なのかな?

下記によると必要そうだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

デプロイ

コマンド
gcloud config configurations activate default
gcloud run deploy box-webhook \
  --source . \
  --platform managed \
  --region asia-northeast1 \
  --allow-unauthenticated
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ビルド失敗

少し長いけど 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
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

デプロイ再挑戦

コマンド
gcloud config configurations activate default
gcloud run deploy box-webhook \
  --source . \
  --platform managed \
  --region asia-northeast1 \
  --allow-unauthenticated
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

もしも Spring Boot 2 系だったら

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

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Webhook バージョン

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

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

試用期間が終わると Webhook を試せない?

Business プラン契約前のスクラップを振り返っていたらカスタムアプリのページに Webhook のタブが無いことに気がついた。

https://zenn.dev/tatsuyasusukida/scraps/0293205105d96c


サーバー認証


ユーザー認証

Business プラン契約中のカスタムアプリ(サーバー認証)では Webhook タブがある。


Business プラン契約中のカスタムアプリ(サーバー認証)

5/5 (水) で無料試用期間が終わってしまうのでそれまでに Webhook のキャッチアップを終わらせよう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Webhook を作成しようとしてみる

V2 Webhookを作成できるのは、[Webhookを管理する] というスコープが選択され、アプリケーションが承認されている場合のみです。

期待通り作成できない。


V2 が非活性になっている

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

この時点で作成しようとしてみる

カスタムアプリが承認されていないので Webhook V2 を期待通り作成できない。


まだ V2 が非活性になっている

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

カスタムアプリ承認

管理コンソールでカスタムアプリを承認する。

その際にクライアント ID が必要になるので開発者コンソールで控えておく。


開発者コンソールのカスタムアプリ構成タブ


管理コンソールのカスタムアプリマネージャ


Webhook V2 がスコープに入っていることが確認できる

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

はじめての Webhook 作成


Webhook V2 作成フォーム


「項目を選択」を押した状態


フォルダーを選択した後

Webhook V2 を作成するには下記 3 つを設定する必要があるようだ。

  • 通知先のURL
  • 監視対象のファイル/フォルダー
  • 監視対象のトリガー
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

あまり関係ないけど

Spring Boot on Cloud Run のコールドスタートはやはり結構時間がかかった。

体感で 8 秒くらい?

高速だと思ったのは勘違いだったようだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

見よう見まねでやってみる

src/main/java/com/example/demo/DemoApplication.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.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);
    }
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

動作確認

コマンド
curl -X POST http://localhost:3000/ \
  -H 'Content-Type: application/json' \
  -d '{"type": "webhook_event"}'

IntelliJ IDEA のコンソールに下記のように出力されたら成功。

コンソール出力
webhook_event
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

makefile 作成

デプロイコマンドを毎回実行するのが大変なので makefile を作成しておく。

コマンド
cd ~/workspace/java/box-webhook
touch makefile
makefile
deploy:
	gcloud run deploy box-webhook \
		--source . \
		--platform managed \
		--region asia-northeast1 \
		--allow-unauthenticated
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

デプロイ後の動作確認

コマンド
curl -X POST https://box-webhook-xxxx-an.a.run.app \
  -H 'Content-Type: application/json' \
  -d '{"type": "webhook_event"}'

OK と表示されたら成功。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

リクエストボディの JSON 文字列が欲しい

下記のようにやったら普通にできた。

src/main/java/com/example/demo/DemoApplication.java
        @PostMapping("/")
        String webhook(@RequestBody String requestBody) {
            System.out.println(requestBody);
            return "OK";
        }
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

署名キー生成

開発者コンソールからカスタムアプリ Webhook タブの「署名キーを管理」ボタンを押す。

プライマリキーとセカンダリキーの「キーを生成」ボタンを押す。

キーが生成されたら環境変数などに控えておく。

  • BOX_PRIMARY_KEY
  • BOX_SECONDARY_KEY
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Box API Java SDK 追加

build.gradle を編集する。

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 リロードボタンを押し忘れないようにする。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Spring Boot リクエストヘッダー取得

コード例
        @PostMapping("/")
        String webhook(
                @RequestBody String requestBody,
                @RequestHeader Map<String, String> headers
        ) throws JsonProcessingException {
            System.out.println(headers.get("content-type"));
            return "OK";
        }
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

署名検証のコーディング

src/main/java/com/example/demo/DemoApplication.java
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);
    }
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

動作確認

さすがに 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
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

テストコード

main.ts
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));
.env
BOX_PRIMARY_KEY="xxxx"
BOX_SECONDARY_KEY="yyyy"
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

テストコード実行

コマンド
npx ts-node -r dotenv/config main.ts
実行結果
500

IntelliJ IDEA のコンソール出力は下記の通り。

java.lang.NullPointerException: Cannot invoke "java.lang.CharSequence.length()" because "csq" is null

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

気合いでデバッグ

デバッグコード
            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 が空のようだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

動作確認

コマンド
npx ts-node -r dotenv/config main.ts
コンソール出力(テストコード)
200
コンソール出力(IntelliJ IDEA)
true

成功している様子だ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

最終的なソースコード

署名の検証に成功した場合にリクエストボディをコンソール出力するようにした。

src/main/java/com/example/demo/DemoApplication.java
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);
    }
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

まとめ

  • 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 では署名検証用のクラスが提供されている。
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

おわりに

無事に Webhook をできることを検証できて良かった。

ファイルが更新された通知を受け取るにはどうすれば良いんだろう、FILE.UPLOAD で良いのかな?

あと Webhook リクエストボディ JSON ペイロードの record クラスが欲しい、SDK を探せばあるのかな?

まだまだ調べたいことがたくさんある。

Box にも Dropbox のようにローカルのクライアントがあると思う。

Webhook と組み合わせることでユーザーがマスタデータの Excel ファイルを編集した時にマスタデータを読み込むみたいな使い方ができそうだ。

このスクラップは2023/05/04にクローズされました