gallu’s blog

エンジニアでゲーマーで講師で占い師なおいちゃんのブログです。

Slim docsの解析; 寄り道してCookie

Slim docsには記載がないっぽいのですが。
まぁ普段「sessionとCookieはよく使うよねぇ」というあたりで、その辺を少し検証。


なおセッションはどうも、公式のSkeletonですら
・自力でsession_start()発行
・sessionっぽい字面のクラスがない
ので、「おとなしく生PHPの機能使え」って感じぽいです。
うんまぁそれはそれであり。
なお「おいちゃんSkeleton」では「セッション系記述用のファイルを1つ作ってそこで"随所に関所"する」予定。


あと、セッションについては。最近いくつかのフレームワークで見かける「flash」をどうすっかなぁ? くらい。
……どうすっかなぁ本当に。Laravelのコードとか軽く読んでみたけど、割と「がっつりラッピングして自由度削ってる」しなぁ。
うん。一端放置しよう(笑


で、一方のCookie
確認をすると
・クラスはある( ./vendor/slim/slim/Slim/Http/Cookies.php )
・使ってる箇所はないぽい
な感じ。


厳密には。
vendor/slim/slim/Slim/Http/Request.php が使ってるぽいんだけど

        $cookies = Cookies::parseHeader($headers->get('Cookie', []));

だけ。ちなみにここを見ると
vendor/slim/slim/Slim/Http/Cookies.php

    public static function parseHeader($header)
    {
        if (is_array($header) === true) {
            $header = isset($header[0]) ? $header[0] : '';
        }

        if (is_string($header) === false) {
            throw new InvalidArgumentException('Cannot parse Cookie data. Header value must be a string.');
        }

        $header = rtrim($header, "\r\n");
        $pieces = preg_split('@[;]\s*@', $header);
        $cookies = [];

        foreach ($pieces as $cookie) {
            $cookie = explode('=', $cookie, 2);

            if (count($cookie) === 2) {
                $key = urldecode($cookie[0]);
                $value = urldecode($cookie[1]);

                if (!isset($cookies[$key])) {
                    $cookies[$key] = $value;
                }
            }
        }

        return $cookies;
    }

がっつりと「自力でparseしてる」ので、幾分「ふぅん」という感じ。$_COOKIE使ってるわけじゃないんだ。


んで。
https://github.com/slimphp/Slim/issues/1310
に質問があるんだけど
・ど〜やってCookie使うのん?
に対して
「In the meantime I'd suggest using the standard PHP functions.」とか書いてあって「あぁ標準関数かぁ」とか思うわけでございます。
ちなみに「こんど準備する」って書いてありますが、肝心の https://github.com/slimphp/Slim-HttpCookies が、これを書いている限りでは「3 years ago」以降、変更がございませぬ、のと、ソースコードが一切上がっておりません。


「じゃぁsetcookieでいいぢゃん」とか思わなくもないのですが、他方で、幾分気になるロジックが。
vendor/slim/slim/Slim/Http/Cookies.php

    /**
     * Set response cookie
     *
     * @param string       $name  Cookie name
     * @param string|array $value Cookie value, or cookie properties
     */
    public function set($name, $value)
    {
        if (!is_array($value)) {
            $value = ['value' => (string)$value];
        }
        $this->responseCookies[$name] = array_replace($this->defaults, $value);
    }

と、

    /**
     * Convert to `Set-Cookie` headers
     *
     * @return string[]
     */
    public function toHeaders()
    {
        $headers = [];
        foreach ($this->responseCookies as $name => $properties) {
            $headers[] = $this->toHeader($name, $properties);
        }

        return $headers;
    }
    /**
     * Convert to `Set-Cookie` header
     *
     * @param  string $name       Cookie name
     * @param  array  $properties Cookie properties
     *
     * @return string
     */
    protected function toHeader($name, array $properties)
    {
        $result = urlencode($name) . '=' . urlencode($properties['value']);

        if (isset($properties['domain'])) {
            $result .= '; domain=' . $properties['domain'];
        }

        if (isset($properties['path'])) {
            $result .= '; path=' . $properties['path'];
        }

        if (isset($properties['expires'])) {
            if (is_string($properties['expires'])) {
                $timestamp = strtotime($properties['expires']);
            } else {
                $timestamp = (int)$properties['expires'];
            }
            if ($timestamp !== 0) {
                $result .= '; expires=' . gmdate('D, d-M-Y H:i:s e', $timestamp);
            }
        }

        if (isset($properties['secure']) && $properties['secure']) {
            $result .= '; secure';
        }

        if (isset($properties['hostonly']) && $properties['hostonly']) {
            $result .= '; HostOnly';
        }

        if (isset($properties['httponly']) && $properties['httponly']) {
            $result .= '; HttpOnly';
        }

        if (isset($properties['samesite']) && in_array(strtolower($properties['samesite']), ['lax', 'strict'], true)) {
            // While strtolower is needed for correct comparison, the RFC doesn't care about case
            $result .= '; SameSite=' . $properties['samesite'];
        }

        return $result;
    }

でございます。
つまり
・なんとなく、前提としてのメソッドは一式あるっぽい
んですなぁ。


ということは
CookieのreadはRequestから
・Cookiesのインスタンスを、container あたりに → Cookieの設定は、ここからsetメソッドで
・どこか(基本は終了する処理あたりがよいなぁ)に、「CookiesのインスタンスからtoHeaders()メソッドでresponseヘッダにSet-Cookieを追加」
とかやると、割と「美しい」んじゃなかろうか? と。
したらまぁ、最悪は「Cookiesのインスタンスのsetメソッドでsetcookie発行すればいいや」とかいう、雑なこともできそうなのでw


別解として https://github.com/dflydev/dflydev-fig-cookies ってのが割とあちこちで出てくるんだけどねぇ。
更新が大体2〜3年前、ってのもあるし、一旦は「できるだけ自力実装」したいので(「他人のを使う」のは、自力実装で状況を理解してからでもできるので)。
一旦は、自力で頑張ってみませう。


ってなわけで
・Cookiesのクラスを軽く解析しつつ
・「Cookieを発行して読み込める」ところまで一式
を、レッツらGo*1


何はともあれコンストラクタを拝見。
vendor/slim/slim/Slim/Http/Cookies.php

    public function __construct(array $cookies = [])
    {
        $this->requestCookies = $cookies;
    }

ふぅん。「requestCookies」となっていて、それを保持する、のか。
見ると

    public function get($name, $default = null)
    {
        return isset($this->requestCookies[$name]) ? $this->requestCookies[$name] : $default;
    }

ってのもあるなぁ。
ふむ……
vendor/slim/slim/Slim/Http/Request.php

    public function getCookieParam($key, $default = null)
    {
        $cookies = $this->getCookieParams();
        $result = $default;
        if (isset($cookies[$key])) {
            $result = $cookies[$key];
        }

        return $result;
    }

ってのもあるんで、幾分悩ましいんだけど。
まぁ「Requestに乗っかってくるCookieは、プログラム実行中に変更されない(不変)」のはず、なので。
この辺は「お好み」なのかも、なぁ。


んで。割と重要なのが

    /**
     * Default cookie properties
     *
     * @var array
     */
    protected $defaults = [
        'value' => '',
        'domain' => null,
        'hostonly' => null,
        'path' => null,
        'expires' => null,
        'secure' => false,
        'httponly' => false,
        'samesite' => null
    ];
    /**
     * Set default cookie properties
     *
     * @param array $settings
     */
    public function setDefaults(array $settings)
    {
        $this->defaults = array_replace($this->defaults, $settings);
    }

この辺。
httponlyと、必要に応じてpathやsecure、expires、くらいは変更したい、かもしれない。samesiteもあるので、気になる御仁もいらっしゃろうかと思うのですが如何でしょうか。


さてこのsetDefaults(っつかここで設定される $this->defaults)、割と多様されるかも。
ってのが、どうもset()、toHeaders()、toHeader()を見ていると「setのタイミングの $this->defaults が、個々のnameごとに保持されて、それをもとにSet-Cookieヘッダを作る」ぽい、から。


うん見えてきたんだけど、まずは「実際にコード書いて動きを確定」させて、そこから次の考察を重ねてみませう。
っつわけで、ざっくりと
Cookieの読み込み(まだ未設定だからnullのはずだけど)
・2種類の、パラメタの違うCookieを設定
あたりをやる一連の処理を、ざっくりとでっち上げ。
public/index.php

// Cookie用ミドルウェア
class cookieMiddleware {
    private $container;
    public function __construct($container) {
        $this->container = $container;
    }
    public function __invoke($request, $response, $next) {
        $response = $next($request, $response);
        ob_start();
        $cookie = $this->container->cookie;
        var_dump( $cookie->toHeaders() );
        $s = ob_get_clean();
        return $response->write($s);
    }
}

//
$app = new \Slim\App;

// Cookieインスタンス作成&コンテナに突っ込み
$container = $app->getContainer();
$container['cookie'] = function ($c) {
    // インスタンス作成
    $cobj = new \Slim\Http\Cookies($c->get('request')->getCookieParams());
    // XXX ここに「デフォ設定」が書いてあるつもり & 突っ込むつもり
    // $cobj->setDefaults( $c->get('settings')['cookie'] );
    //
    return $cobj;
};

// middlewareの設定
$app->add( new cookieMiddleware($app->getContainer()) );

// ルーティング
$app->get('/', function(Request $request, Response $response, array $args) {
    // Cookieの読み込みと表示
    ob_start();
    $cookie = $this->cookie;
    var_dump($cookie->get('hoge'));
    var_dump($cookie->get('foo'));
    $s = ob_get_clean();
    // Cookieの設定その1
    $cookie->set('hoge', mt_rand(0, 99));
    // Cookieの設定その2
    $cookie->setDefaults(['httponly' => true, 'expires' => date(DATE_COOKIE, time() + 3600), 'secure' => true]);
    $cookie->set('foo', mt_rand(0, 99));
    //
    return $response->write("hoge test\n" . $s);
});

$app->run();
hoge test
NULL
NULL
array(2) {
  [0]=>
  string(7) "hoge=43"
  [1]=>
  string(63) "foo=25; expires=Thu, 01-Jan-1970 01:00:00 UTC; secure; HttpOnly"
}

うん概ね予想通り。
さて、では本命の「Set-Cookie込み」。
このヘッダは「追加追加」なので、withHeader()じゃなくてwithAddedHeader()である、ってあたりに気を付けてみませう。

// Cookie用ミドルウェア
class cookieMiddleware {
    private $container;
    public function __construct($container) {
        $this->container = $container;
    }
    public function __invoke($request, $response, $next) {
        $response = $next($request, $response);
        // Cookieの設定
        foreach($this->container->cookie->toHeaders() as $cookie_string) {
            $response = $response->withAddedHeader('Set-Cookie', $cookie_string);
        }
        return $response;
    }
}

//
$app = new \Slim\App;

// Cookieインスタンス作成&コンテナに突っ込み
$container = $app->getContainer();
$container['cookie'] = function ($c) {
    // インスタンス作成
    $cobj = new \Slim\Http\Cookies($c->get('request')->getCookieParams());
    // XXX ここに「デフォ設定」が書いてあるつもり & 突っ込むつもり
    // $cobj->setDefaults( $c->get('settings')['cookie'] );
    //
    return $cobj;
};
// middlewareの設定
$app->add( new cookieMiddleware($app->getContainer()) );
// ルーティング
$app->get('/', function(Request $request, Response $response, array $args) {
    // Cookieの読み込みと表示
    ob_start();
    $cookie = $this->cookie;
    var_dump($cookie->get('hoge'));
    var_dump($cookie->get('foo'));
    $s = ob_get_clean();
    // Cookieの設定その1
    $cookie->set('hoge', mt_rand(0, 99));
    // Cookieの設定その2
    $cookie->setDefaults(['httponly' => true, 'expires' => date(DATE_COOKIE, time() + 3600) ]);
    $cookie->set('foo', mt_rand(0, 99));
    //
    return $response->write("hoge test\n" . $s);
});

$app->run();
hoge test
string(2) "48"
string(2) "46"

よし、意図通り。


多分、「比較的綺麗に」やると、こんな感じだなぁ。
………ふむ、この手の「ちょっとしたMiddleware」とか、1パッケージにまとめてみるかしらん???


パッケージにまとめるとすると、何となし、Middlewareクラス一か所に処理まとめたいなぁ。

// Cookie用ミドルウェア
class cookieMiddleware {
    private $container;
    public function __construct($container) {
        $this->container = $container;
    }
    public function __invoke($request, $response, $next) {
        // 事前処理:インスタンスの生成
        $this->container['cookie'] = function ($c) {
            // インスタンス作成
            $cobj = new \Slim\Http\Cookies($this->container->get('request')->getCookieParams());
            // XXX ここに「デフォ設定」が書いてあるつもり & 突っ込むつもり
            $settings = $this->container->get('settings');
            if (true === isset($settings['cookie'])) {
                 $cobj->setDefaults( $settings['cookie'] );
            }
            //
            return $cobj;
        };

        // 本処理
        $response = $next($request, $response);

        // 事後処理:setCookieヘッダ群の出力
        foreach($this->container->get('cookie')->toHeaders() as $cookie_string) {
            $response = $response->withAddedHeader('Set-Cookie', $cookie_string);
        }
        return $response;
    }
}

//
$app = new \Slim\App;

// middlewareの設定
$app->add( new cookieMiddleware($app->getContainer()) );

// ルーティング
$app->get('/', function(Request $request, Response $response, array $args) {
    ob_start();
    $cookie = $this->get('cookie');
    var_dump($cookie->get('hoge'));
    var_dump($cookie->get('foo'));
    $s = ob_get_clean();
    //
    $cookie->set('hoge', mt_rand(0, 99));
    //
    $cookie->setDefaults(['httponly' => true, 'expires' => date(DATE_COOKIE, time() + 3600) ]);
    $cookie->set('foo', mt_rand(0, 99));
    //
    return $response->write("hoge test\n" . $s);
});

よしまとまった。これだな。
あとは……setDefaultsのところのtypoとかが気になるなぁ。なんか、チェックロジックとか入れてみるかしらん??


もう一個。
Cookieを削除する」時は、大雑把には

    $cookie->setDefaults(['httponly' => true, 'expires' => date(DATE_COOKIE, 0) ]);
    $cookie->set('foo', '');

で消せマッスル。……これだけなんかメソッド切っておいたほうが楽なんじゃないか? って気が、せんでもない。
まぁ気をつけないと「後続のCookieが全部消える」とかありそうなので。どっちかってぇと、getDefaults()がある、とか、その辺のほうが安全なような気もするのだが。
………ラッパークラス、書こうかしらん???


最後。
Cookie自体の運用上の注意として。
defaultsの
・どこは毎回さわって
・どこは変更しない
のかを、内々に決めておかないと、危ない、かも。
この辺は「ルール決め」の範疇だねぇ。
……Cookies、継承クラスとか作ってラップメソッドとか書いたほうが、楽、かもしれない。


まぁとりあえず、一通り「Cookieのreadとwrite」が出来たので、よし、としませう。

*1:びっくりするほどに古いwww