1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Stripe / JP_StripesAdvent Calendar 2024

Day 14

StripeのWebhookイベントをSymfonyのWebhookコンポーネントで受け取る

Last updated at Posted at 2024-12-14

Stripe / JP_Stripes Advent Calendar 2024 14日目の記事です。

StripeにはWebhookが用意されており、各種イベント時にURLを指定することで自分のサービスにイベントを通知することができます。
Symfonyは、Webhookというコンポーネントが用意されており、このコンポーネントを使うことで簡単にWebhookを作ることができます。

Stripe側の準備

記事内のリンクはテスト環境へのリンクです!

Stripeのダッシュボードの『開発者』ページに、Webhook というページがあります。

image.png

ここの エンドポイントを追加 をクリックして、Webhookの設定を行います。

image.png

本番環境や、開発環境が外部に公開されている場合は、エンドポイントURL に、イベントを受け取るURLを設定することで、Stripeのイベントを受け取ることができます。受け取るイベントは りっすんするイベントの選択 から選択します。

開発環境がローカルの場合は ローカル環境でテスト をクリックします。

image.png

するとこのような画面になります。ローカル環境にStripe CLIをインストールし、そのコマンドを実行すればテストが行えます。

stripe listen --forward-to [URL]

スクリーンショット 2024-12-14 8.43.47.png

実行すると、このように画面上に secret という文字列が表示されます。この secret は開発時に利用するので、コピーしておきます。

これでStripe側の準備は完了です。

SymfonyでWebhookを作る

Symfonyのインストールは省略します。

必要なライブラリのインストール

まずは必要なライブラリをインストールしていきます。

composer require stripe/stripe-php # Stripe SDK
composer require webhook # Webhookコンポーネント
composer require maker --dev # クラスのスケルトン作成コンポーネント

クラスの作成

Webhookは1からつくるとちょっとめんどくさいので、 Maker コンポーネントを使って必要なクラスやメソッドを作ってもらいます。

bin/console make:webhook

実行すると、質問が表示されるので答えます。

 Name of the webhook to create (e.g. github, stripe, ...):
 > stripe # webhookの名前


 Add a RequestMatcher (press <return> to skip this step) [<skip>]:
  [0] <skip>
  [1] Symfony\Component\HttpFoundation\RequestMatcher\ExpressionRequestMatcher
  [2] Symfony\Component\HttpFoundation\RequestMatcher\HostRequestMatcher
  [3] Symfony\Component\HttpFoundation\RequestMatcher\IpsRequestMatcher
  [4] Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher
  [5] Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher
  [6] Symfony\Component\HttpFoundation\RequestMatcher\PathRequestMatcher
  [7] Symfony\Component\HttpFoundation\RequestMatcher\PortRequestMatcher
  [8] Symfony\Component\HttpFoundation\RequestMatcher\SchemeRequestMatcher
 > 0 # どこからアクセスされたかを調べるためのRequestMatcher。とりあえず0

 created: src/Webhook/StripeRequestParser.php
 created: src/RemoteEvent/StripeWebhookConsumer.php
 updated: config/packages/webhook.yaml

           
  Success! 
           

作成されたWebhookは {ドメイン}/webhook/{作ったWebhookの名前} でアクセスできます。上記のコマンドで作成したWebhookは、ローカル環境上だと http://localhost:8000/webhook/stripe となります。

WebhookConsumerで受け取ったイベントを処理する

WebhookConsumer はStripeから受け取ったイベントの内容を処理する部分です。ここにお好みのロジックを記述します。
上記のコマンドで作成されたプログラムは以下のようになっています。

StripeWebhookConsumer.php
<?php

namespace App\RemoteEvent;

use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer;
use Symfony\Component\RemoteEvent\Consumer\ConsumerInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;

#[AsRemoteEventConsumer('stripe')]
final class StripeWebhookConsumer implements ConsumerInterface
{
    public function __construct()
    {
    }

    public function consume(RemoteEvent $event): void
    {
        // Implement your own logic here
    }
}

consume() メソッドで渡された RemoteEvent にStripeから渡されたペイロードが配列で入っています。この値を使って、色々な処理ができます。
ここでは、ログに出力してみます。

    public function __construct(private readonly LoggerInterface $logger)
    {
    }

    public function consume(RemoteEvent $event): void
    {
        $payload = $event->getPayload();
        $message = sprintf('%d円売り上げました!', $payload['data']['object']['amount']);
    }

これで、Stripeから受け取ったイベントを処理する部分はできました!

RequestParserでどのイベントを受け取るか決める

RequestParser は、Stripeから受け取ったイベントを解析し、イベントを処理するかどうかを決めます。

StripeRequestParser.php
<?php

namespace App\Webhook;

use Symfony\Component\HttpFoundation\ChainRequestMatcher;
use Symfony\Component\HttpFoundation\Exception\JsonException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Webhook\Client\AbstractRequestParser;
use Symfony\Component\Webhook\Exception\RejectWebhookException;

final class TestRequestParser extends AbstractRequestParser
{
    protected function getRequestMatcher(): RequestMatcherInterface
    {
        return new ChainRequestMatcher([
            // Add RequestMatchers to fit your needs
        ]);
    }

    /**
     * @throws JsonException
     */
    protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?RemoteEvent
    {
        // TODO: Adapt or replace the content of this method to fit your need.

        // Validate the request against $secret.
        $authToken = $request->headers->get('X-Authentication-Token');

        if ($authToken !== $secret) {
            throw new RejectWebhookException(Response::HTTP_UNAUTHORIZED, 'Invalid authentication token.');
        }

        // Validate the request payload.
        if (!$request->getPayload()->has('name')
            || !$request->getPayload()->has('id')) {
            throw new RejectWebhookException(Response::HTTP_BAD_REQUEST, 'Request payload does not contain required fields.');
        }

        // Parse the request payload and return a RemoteEvent object.
        $payload = $request->getPayload();

        return new RemoteEvent(
            $payload->getString('name'),
            $payload->getString('id'),
            $payload->all(),
        );
    }
}

getRequestMatcher() でどのリクエストを対象とするか、 doParse() でデータの解析・検証を行い、問題なければ RemoteEvent としてイベントを発火し、先ほどの WebhookConsumer を実行します。

doParse() で解析と検証を行いますが、基本的には

    /**
     * @throws JsonException
     */
    protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?RemoteEvent
    {
        $payload = $request->getPayload();

        return new RemoteEvent(
            'stripe',
            $payload->getString('id'),
            $payload->all(),
        );
    }

このようにすればOKです。RemoteEventの1つ目の引数はWebhookのイベント名なので、ここでは stripe を指定します。2つ目の引数にはpayloadのidを、3つ目にはpayload自体を渡します。

扨。
このままだと、 /webhook/stripe にアクセスすれば、いくらでもイベントを発火できます。そこで、 getRequestMatcher() を使って、アクセス制限をかけます。

    protected function getRequestMatcher(): RequestMatcherInterface
    {
        return new ChainRequestMatcher([
            new IsJsonRequestMatcher(),
            new MethodRequestMatcher('POST'),
        ]);
    }

IsJsonRequestMatcher はリクエストボディがJSONである場合、 MethodRequestMatcher は引数のメソッドである場合という意味で、 この条件に一致した場合のみ doParse() を実行することができます。

では実行してみましょう!
Stripe設定時に実行した stripe listen とは別のターミナルを用意します。

php -S localhost:8000 -t public # ローカルウェブサーバ起動
stripe trigger charge.succeeded

1つ目はローカルウェブサーバの起動、2つ目はイベント発火です。上記コマンドを実行すると、イベントが発火されたこととなり、リッスン中のターミナルに結果が出力されます。今回は charge.succeeded を発火してみました。

ではイベント発火した結果リッスン側のターミナルはどうなったでしょうか。

2024-12-14 17:56:31   --> charge.succeeded [evt_3QVr9GIRhJ0AtimP1L7ESype]
2024-12-14 17:56:31  <--  [202] POST https://localhost:8000/webhook/stripe [evt_3QVr9GIRhJ0AtimP1L7ESype]
2024-12-14 17:56:31   --> payment_intent.succeeded [evt_3QVr9GIRhJ0AtimP1Dih2i9e]
2024-12-14 17:56:31  <--  [202] POST https://localhost:8000/webhook/stripe [evt_3QVr9GIRhJ0AtimP1Dih2i9e]
2024-12-14 17:56:31   --> payment_intent.created [evt_3QVr9GIRhJ0AtimP1ksMVZxN]
2024-12-14 17:56:31  <--  [202] POST https://localhost:8000/webhook/stripe [evt_3QVr9GIRhJ0AtimP1ksMVZxN]

おっと。イベントが3つ発火されており、その全てを受け取って処理しようとしています。ログも3行出力されています。

100円売り上げました!
100円売り上げました!
100円売り上げました!

charge.succeeded を発火しましたが、その他 payment_intent.created, payment_inten.succeeded も発火しています。この時はRemoteEventの作成してWebhookConsumerの実行をして欲しくありません。
そこで doParse() に一手間加えます。

実行して良いものだけ選りすぐる

doParse() 内で、実行したイベントのタイプを判断してRemoteEventを作成しないようにします。
ここで今回役立つのが引数の2つめにある $secret です。 ここには config/packages/webhook.yaml に設定した secret の値が注入されます。先ほどリッスンコマンドを実行した際に控えた値を、このYAMLに記述します。

webhook.yaml
framework:
  webhook:
    routing:
      stripe:
        service: App\Webhook\StripeRequestParser
        secret: {ここに控えたsecretの値を記述}

この値を元に、Stripeからアクセスされたかをチェックし、合わせてイベントのタイプも確認します。

StripeRequestParser.php
    protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?RemoteEvent
    {
        $payload = $request->getPayload();

+       try {
+           $signature = $request->headers->get('stripe-signature'); // HTTP_STRIPE_SIGNATUREの値
+           $stripeEvent = Webhook::constructEvent($request->getContent(), $signature, $this->secret);
+           if ($stripeEvent->type !== 'charge.succeeded') {
+               throw new RejectWebhookException(Response::HTTP_BAD_REQUEST, 'Request type is not matched.');
+           }
+       } catch (UnexpectedValueException|SignatureVerificationException) {
+           throw new RejectWebhookException(Response::HTTP_BAD_REQUEST, 'Request secret is invalid.');
+       }

        return new RemoteEvent(
            'stripe',
            $payload->getString('id'),
            $payload->all(),
        );
    }

Webhook::constructEvent() は、Stripe SDKのWebhookEventのオブジェクトを作成します。引数は以下のとおりです。

引数
1つめ ペイロードの文字列
2つめ HTTP_STRIPE_SIGNATUREの値
3つめ secret

Symfonyの Request オブジェクトの getPayload() は配列になっているので、そのままの値を渡すために getContent() を使います。

WebhookEventはタイプを持っているため、その値とイベント発火したいタイプが一致しているか確認します。
WebhookEventが作れなかったり、タイプが一致しない時は RejectWebhookException をスローします。引数に Response::HTTP_BAD_REQUEST を渡すと、ステータスコード400を返します。

こうすることで、Stripeから指定のタイプのイベントにのみ反応してWebhookの処理を実行できます。

2024-12-14 21:08:44   --> payment_intent.succeeded [evt_3QVu9GIRhJ0AtimP0MJPxxiN]
2024-12-14 21:08:44   --> payment_intent.created [evt_3QVu9GIRhJ0AtimP0kldm8zQ]
2024-12-14 21:08:44   --> charge.succeeded [evt_3QVu9GIRhJ0AtimP0aDcpFV3]
2024-12-14 21:08:44  <--  [400] POST https://localhost:8000/webhook/stripe [evt_3QVu9GIRhJ0AtimP0MJPxxiN]
2024-12-14 21:08:45  <--  [400] POST https://localhost:8000/webhook/stripe [evt_3QVu9GIRhJ0AtimP0kldm8zQ]
2024-12-14 21:08:45  <--  [202] POST https://localhost:8000/webhook/stripe [evt_3QVu9GIRhJ0AtimP0aDcpFV3]

また、実行イベントはStripe上のイベントリストでも確認できます。

image.png

まとめ

このようにSymfonyでは、簡単にStripe用のWebhookを作成することができます。通知や集計などいろいろなことに利用できます!

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?