PowerCMS Xプラグインの実装でinterfaceを利用してみた

公開

先日『「"品質"が高いコード」って何? by 若葉 章 | トーク | PHPカンファレンス関西2024 #phpkansai - fortee.jp』を拝見し、なるほどと思うと共に品質の話もなかなか奥が深いと考えさせられました。

ひとまず難しい話は置いておき、メンテナンスや変更がしやすいプラグインを考えました。お題は先週の「PowerCMS XとGitHub ActionsでCSS・JavaScriptのCI/CDパイプラインを試作」です。記事を書いた後、Webhookを受信して処理するクラス、ファイルを配置・削除するクラス、GitHubからのWebhookペイロードを処理するクラスに分けて書き、プラグインが仕上がっています。

ここで「GitHub以外のサービスを利用するプロジェクトがあるのでは?」と頭をよぎります。少し調べてみるとGitLabでもビルドして成果物を作成することや、Webhookを利用することができるようです。そこで後日GitLab等への対応がしやすいよう、interfaceを使って実装してみました。

GitHosting interfaceの宣言は以下のようになりました。成果物のダウンロード処理に必要だけど、ホスティング先によって実装が異なるであろうものたちです。

<?php

namespace AssetRelease\webhook;

interface GitHosting
{
    public function verifySignature(string $payloadBody): bool;
    public function getArtifacts(array $payloadJSON): int|false;
    public function getCommitHash(array $payloadJSON): string;
    public function getWorkspaceByPayload(array $payloadJSON): ?object;
    public function isTargetBranchJobCompleted(array $payloadJSON, int $workspaceId): bool;
}

例えばverifySignatureメソッドをGitHubクラスで実装すると以下のようになりました。

<?php

declare(strict_types=1);

namespace AssetRelease\webhook;

if (!defined('DS')) {
    define('DS', DIRECTORY_SEPARATOR);
}

require_once dirname(__FILE__) . DS . '..' . DS . '..' . DS . 'interfaces' . DS . 'GitHosting.php';

use CurlHandle;
use AssetRelease\webhook\GitHosting;
use Prototype;

class GitHub implements GitHosting
{
    private $app;
    private $plugin;

    public function __construct(Prototype $app)
    {
        $this->app = $app;
        $this->plugin = $app->component('AssetRelease');
    }

    public function verifySignature(string $payloadBody): bool
    {
        if (!isset($_SERVER['HTTP_X_HUB_SIGNATURE_256'])) {
            return false;
        }

        $secret_token = $this->plugin->get_config_value('assetrelease_github_secret_token');
        $hash = hash_hmac('sha256', $payloadBody, $secret_token);
        return hash_equals("sha256={$hash}", $_SERVER['HTTP_X_HUB_SIGNATURE_256']);
    }

そして、以下のように受信したWebhookの何らかの要素でホスティング先を判別し、インターフェースの実装をインスタンス化します。GitLabに対応する時は、GitHostingインターフェースに沿ってGitLabクラスを実装した後で以下のコードに追記を行います。

private function detectHosting(): ?string
{
    if (!isset($_SERVER['HTTP_USER_AGENT'])) {
        return null;
    }

    if (strpos($_SERVER['HTTP_USER_AGENT'], 'GitHub') === 0) {
        return 'GitHub';
    }

    return null;
}

private function getGitHosting(Prototype $app): ?GitHosting
{
    $hosting = $this->detectHosting();

    if ($hosting === 'GitHub') {
        return new GitHub($app);
    }

    return null;
}

Webhookを処理するメソッドでは、ホスティング先をどこかを気にせずverifySignatureメソッドやgetArtifactsメソッドなどを呼び出すことができるので楽です。

public function run()
{
    $app = new Prototype(['id' => 'Worker']);
    $app->logging = true;
    $app->init();
    $plugin = $app->component('AssetRelease');

    $payloadBody = file_get_contents("php://input");
    $this->sendResponse();
    $gitHosting = $this->getGitHosting($app);

    if (!$gitHosting) {
        return;
    }

    if (!$this->skipVerifySignature) {
        if (!$gitHosting->verifySignature($payloadBody)) {
            $app->log([
                'message'  => $plugin->translate('Webhook delivery validation failed.'),
                'category' => 'asset_release',
                'level'    => 'error',
            ]);
            return;
        }
    }

    $json = json_decode($payloadBody, true);

    $workspace = $gitHosting->getWorkspaceByPayload($json);
    if (!$workspace) {
        return;
    }

    $jobCompleted = $gitHosting->isTargetBranchJobCompleted($json, (int) $workspace->id);
    if (!$jobCompleted) {
        return;
    }

    $artifactsId = $gitHosting->getArtifacts($json);
    $commitHash = $gitHosting->getCommitHash($json);

interfaceの他に名前空間も利用しました。クラスが増えるとクラス名の衝突も考えられますが、プラグイン名で名前空間を利用しておけば安心です。

実際にプロジェクトで自作したプラグインが利用されるかどうかは別にして、自分で設定したお題を元にじっくりコードを考えてみるのは良い経験になりました。