🌐

Laravel で HTTP Request をコンソールコマンドからディスパッチする

2022/03/07に公開
1

これは何?

app/Http/Controllers/* に置いたコントローラについて,以下の課題を考える。

実行の起点 PHP_SAPI の値 どのように実行されるか
HTTP リクエスト mod_php (Apache)
fpm-fcgi (php-fpm)
public/index.php に書かれたフレームワーク起動〜終了までの処理による
コマンドライン php-cli

通常 HTTP コントローラをその本来の用途以外で使うことは避けるべきであるが, Webhook 用のコントローラの動作確認のために使用するなどの場合には有用であると考えた。

実装

.
└── app/
    └── Console/
        └── Commands/
            └── Playground/
                ├── CreatesIncomingRequestHandler.php
                └── IncomingRequestsHandler.php
<?php

declare(strict_types=1);

namespace App\Console\Commands\Playground;

use Illuminate\Contracts\Foundation\Application;
use Symfony\Component\Console\Output\OutputInterface;

trait HandlesIncomingRequests
{
    /**
     * @return Application
     * @noinspection PhpMissingReturnTypeInspection
     */
    abstract public function getLaravel();

    /**
     * @return OutputInterface
     * @noinspection PhpMissingReturnTypeInspection
     */
    abstract public function getOutput();

    /**
     * @noinspection PhpUnhandledExceptionInspection
     */
    public function createIncomingRequestHandler(): IncomingRequestHandler
    {
        return $this
            ->getLaravel()
            ->make(IncomingRequestHandler::class, ['output' => $this->getOutput()]);
    }
}
<?php

declare(strict_types=1);

namespace App\Console\Commands\Playground;

use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\Console\Output\OutputInterface;

class IncomingRequestHandler
{
    public function __construct(
        private Kernel $kernel,
        private OutputInterface $output,
    ) {
    }

    /**
     * Content-Type: application/x-www-form-urlencoded のリクエストをハンドル
     * (GET や HEAD の場合には,クエリストリングを付加した場合と同等)
     *
     * @phpstan-param 'GET'|'HEAD'|'POST'|'PUT'|'PATCH'|'DELETE' $method
     */
    public function handleQueryRequest(string $method, string $uri, string $query): void
    {
        parse_str($query, $parsed);

        $this->handle(Request::create(
            uri: $uri,
            method: $method,
            parameters: $parsed,
            server: [
                'HTTP_ACCEPT' => 'application/json',
            ],
        ));
    }

    /**
     * Content-Type: application/json のリクエストをハンドル
     *
     * @phpstan-param 'POST'|'PUT'|'PATCH'|'DELETE' $method
     */
    public function handleJsonRequest(string $method, string $uri, string $json): void
    {
        $this->handle(Request::create(
            uri: $uri,
            method: $method,
            server: [
                'CONTENT_TYPE' => 'application/json',
                'HTTP_ACCEPT' => 'application/json',
            ],
            content: $json,
        ));
    }

    private function handle(Request $request): void
    {
        // カーネルでハンドル
        $response = $this->kernel->handle($request);

        // 中身を取り出す
        $content = $response instanceof JsonResponse
            ? $response->getData()
            : $response->getContent();

        // デバッグ用にコンソール出力
        $this->output->writeln((string)json_encode(
            $content,
            flags: JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
        ));

        // 登録された終了時のコールバックを実行
        $this->kernel->terminate($request, $response);
    }
}

使い方

Slack から Webhook としてリクエストされる Interactive Messages の疑似リクエストを取り扱う例

.
└── app/
    └── Console/
        └── Commands/
            └── Playground/
                ├── Slack/
                │   └── Interactions/
                │       └── DispatchCommand.php
                ├── CreatesIncomingRequestHandler.php
                └── IncomingRequestsHandler.php
<?php

declare(strict_types=1);

namespace App\Console\Commands\Playground\Slack\Interactions;

use App\Console\Commands\Playground\HandlesIncomingRequests;
use Illuminate\Console\Command;

class DispatchCommand extends Command
{
    use CreatesIncomingRequestHandler;

    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'playground:slack:interactions:dispatch
                            {json : JSON ペイロード引数}';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = '[動作確認] Slack の Webhook インタラクションを発生させる';

    public function handle(): void
    {
        $json = $this->argument('json');
        assert(is_string($json));

        $this
            ->createIncomingRequestHandler()
            ->handleJsonRequest('POST', '/slack/interactions', $json);
    }
}
php artisan playground:slack:interactions:dispatch '{
  "token": "Nj2rfC2hU8mAfgaJLemZgO7H",
  "callback_id": "chirp_message",
  "type": "message_action",
  "trigger_id": "13345224609.8534564800.6f8ab1f53e13d0cd15f96106292d5536",
  "response_url": "https://hooks.slack.com/app-actions/T0MJR11A4/21974584944/yk1S9ndf35Q1flupVG5JbpM6",
  "team": {
    "id": "T0MJRM1A7",
    "domain": "pandamonium"
  },
  "channel": {
    "id": "D0LFFBKLZ",
    "name": "cats"
  },
  "user": {
    "id": "U0D15K92L",
    "name": "dr_maomao"
  },
  "message": {
    "type": "message",
    "user": "U0MJRG1AL",
    "ts": "1516229207.000133",
    "text": "Smallest big cat! <https://youtube.com/watch?v=W86cTIoMv2U>"
  }
}'
GitHubで編集を提案

Discussion

mpywmpyw

おまけ

まだ必要無さそうだけど,パラメータビルダーっぽいもの一応作ったので供養

<?php

declare(strict_types=1);

namespace App\Console\Commands\Playground;

use Illuminate\Support\Arr;

class ParameterBuilder
{
    private array $params = [];

    public function withRawParameter(string|int $key, mixed $value): self
    {
        return $this->withRawParameters([$key => $value]);
    }

    public function withRawParameters(array $params): self
    {
        $self = clone $this;

        foreach ($params as $key => $value) {
            $self->params[$key] = $value;
        }

        return $self;
    }

    public function withParameter(string|int $key, mixed $value): self
    {
        return $this->withParameters([$key => $value]);
    }

    public function withParameters(array $params): self
    {
        $self = clone $this;

        foreach ($params as $key => $value) {
            Arr::set($this->params, $key, $value);
        }

        return $self;
    }

    public function withPairs(array $pairs): self
    {
        $params = [];

        foreach ($pairs as $pair) {
            [$key, $value] = explode('=', $pair, 2) + [1 => ''];
            $params[$key] = $value;
        }

        return $this->withParameters($params);
    }

    public function withJson(string $json): self
    {
        return $this->withRawParameters((array)json_decode($json));
    }

    public function withQuery(string $query): self
    {
        parse_str($query, $parameters);

        return $this->withRawParameters($parameters);
    }

    public function asJson(): string
    {
        return (string)json_encode($this->params);
    }

    public function asQuery(): string
    {
        return http_build_query($this->params);
    }
}