のんラボ

S3みたいなストレージサーバーっぽいものを自前で用意する②【ミドルウェア実装】

2022/03/26 2022/03/26 S3みたいなストレージサーバーっぽいものを自前で用意する②【ミドルウェア実装】

S3みたいなストレージサーバーっぽいものを自前で用意する②【ミドルウェア実装】

こんにちは。のんです。

前回に引き続き自前でストレージサーバーを開発していこうと思います。

GitHubプロジェクトはこちら

ミドルウェアを実装する

league/route の MiddlewareInterface

league/routePSR-15 準拠のミドルウェア実装に対応しているので、

にあるように、

The following interface MUST be implemented by compatible middleware components.

copied.namespace Psr\Http\Server;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
 * Participant in processing a server request and response.
 *
 * An HTTP middleware component participates in processing an HTTP message:
 * by acting on the request, generating the response, or forwarding the
 * request to a subsequent middleware and possibly acting on its response.
 */
interface MiddlewareInterface
{
    /**
     * Process an incoming server request.
     *
     * Processes an incoming server request in order to produce a response.
     * If unable to produce the response itself, it may delegate to the provided
     * request handler to do so.
     */
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface;
}

を実装するようにします。

process の引数である ServerRequestInterfacePSR-7RequestHandlerInterfacePSR-15 準拠です。

ServerRequestInterface

(※ 長いので省略)

RequestHandlerInterface

The following interface MUST be implemented by request handlers.

copied.namespace Psr\Http\Server;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
 * Handles a server request and produces a response.
 *
 * An HTTP request handler process an HTTP request in order to produce an
 * HTTP response.
 */
interface RequestHandlerInterface
{
    /**
     * Handles a request and produces a response.
     *
     * May call other collaborating code to generate the response.
     */
    public function handle(ServerRequestInterface $request): ResponseInterface;
}

ミドルウェアはよくドーナツ型の図で表現されます

前処理や後処理では、実際にControllersやActionに行く前にしておきたい共通処理を書くことが多いですね。

  • ログの書き込み
  • 認証チェック
  • 共通データの取得

などなど。

もちろん上図のように、ドーナツは二重三重にすることができます。

実際に作成した AuthMiddleware がこちら。

copied.<?php
declare(strict_types=1);

namespace Nonz250\Storage\App\Http\Auth;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

class AuthMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        // ミドルウェアでしたい処理。ここではDigest認証を行います。

        return $handler->handle($request);
    }
}

と言っても、まだDigest認証の実装はしていません。この実装については次回の記事で書こうと思います。

前回作成したルートにこの AuthMiddleware を設定します。

copied.$router = new League\Route\Router();
$router
    ->group('/', static function (League\Route\RouteGroup $router) {
        // 認証が必要なアクションをここに登録します。
    })
    ->middleware(new Nonz250\Storage\App\Http\Auth\AuthMiddleware);

これで $router->group() で登録したアクションを実行するときに必ず AuthMiddleware が発火します。

認証エラーの場合は例外をスローする実装にしておけばアクションまでは実行されないという寸法です。

ついでにRequestBodyをParseするMiddlewareを作成する

フルスタックなフレームワークを利用していないので、 Content-Typeapplication/json の状態でアクションからRequestBodyを取得するとStringで取得してします。

copied.<?php
declare(strict_types=1);

namespace Nonz250\Storage\App\Http\Test;

use Laminas\Diactoros\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

class TestAction
{
    /**
     * @param ServerRequestInterface $request
     * @return ResponseInterface
     */
    public function __invoke(ServerRequestInterface $request): ResponseInterface
    {
        $response = new Response();
        // ここで取得できる$contentsがjson。つまりStringのまま。
        $contents = $request->getBody()->getContents();
        $response->getBody()->write($contents);
        return $response;
    }
}

Laravelなどのフレームワークなら自動的に配列に変換されていたり、プロパティとして取得できるのですが、それができていません。これだと非常に不便なので、同じようにMiddlewareを用意してその中でRequestBodyをjsonから配列に変換する処理を行います。

ServerRequestInterface には withParsedBodygetParsedBody という関数が実装されていますので、これを利用します。

実際に作成した ParseRequestMiddleware はこちら

copied.<?php
declare(strict_types=1);

namespace Nonz250\Storage\App\Http\ParseRequest;

use JsonException;
use Nonz250\Storage\App\Foundation\Exceptions\HttpBadRequestException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

class ParseRequestMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        try {
            try {
                $contentTypes = $request->getHeader('Content-Type');
                if (count($contentTypes) === 0) {
                    throw new EmptyContentTypeException();
                }

                $contentType = $contentTypes[0];
                if ($contentType !== 'application/json') {
                    throw new InvalidContentTypeException();
                }

                $contents = $request->getBody()->getContents();
                $parsedBody = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
            } catch (EmptyContentTypeException|InvalidContentTypeException $e) {
                // TODO: ログ記録
                throw new HttpBadRequestException($e->getMessage());
            } catch (JsonException $e) {
                // TODO: ログ記録
                throw new HttpBadRequestException('Json syntax error.');
            }
        } catch (HttpBadRequestException $e) {
            return $e->getApiProblemResponse();
        }

        $request = $request->withParsedBody($parsedBody);

        return $handler->handle($request);
    }
}

一つずつ説明していきます。

copied.$contentTypes = $request->getHeader('Content-Type');
if (count($contentTypes) === 0) {
    throw new EmptyContentTypeException();
}

このコードでRequestHeaderから Content-Type の値を取得します。

取得できない場合は Content-Type が無い旨を示す例外をスローします。

独自に作成した HttpExceptionHttpExceptionInterface を継承した例外です。

copied.return $e->getApiProblemResponse();

にある RFC 7807 に従ったエラーレスポンスを返す関数を用意しています。

実は league/route にも HttpException は実装されていますが、上記のような独自処理を実装したかったし、 league に依存しすぎるのもどうかと思ったので自前で用意したという流れです。

この件についてもいずれ記事にしたいと思います。

次のコードです。

copied.$contentType = $contentTypes[0];
if ($contentType !== 'application/json') {
    throw new InvalidContentTypeException();
}

このコードは Content-Type の内容が取得できたらその値が application/json かどうかを確認します。

違ったら不適切な Content-Type であることを示す例外をスローします。

copied.$contents = $request->getBody()->getContents();
$parsedBody = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);

バリデーションが終了したら実際にRequestBodyを取得します。
json_encode を利用して連想配列へ変換します。このとき、Parseでエラーが発生したら JsonException をスローするように設定しているので、それを catch したらその旨を HttpBadRequestException として投げ直しています。

無事、 $parsedBody を取得できたら、 withParsedBody でRequestクラスに保存しておきます。

copied.$request = $request->withParsedBody($parsedBody);

こうすることで、

copied.$contents = $request->getParsedBody();

で連想配列形式で取得できるようになります。

このMiddlewareをRouterに登録しておきます。

copied.$router = new League\Route\Router();
// AuthMiddlewareと違い、全てのルートでONにしておく
$router->middleware(new Nonz250\Storage\App\Http\ParseRequest\ParseRequestMiddleware);
$router
    ->group('/', static function (League\Route\RouteGroup $router) {
        // 認証が必要なアクションをここに登録します。
    })
    ->middleware(new Nonz250\Storage\App\Http\Auth\AuthMiddleware);

最後に

今回はMiddleware、特に前処理を利用して、認証やRequestBodyの加工の実装を行いました。

連想配列を返すっていうのもまた微妙に使いづらいですが、ひとまずここで良しとしておきましょう。
必要がある場合はまた自前で拡張してプロパティから取得できるように実装すればいいでしょう。...が、その場合は素直にフレームワークを利用したほうが安心できるかもしれませんね。

次回はDigest認証の実装について記事を書こうと思います。

その後に RFC 7807 について記事にする予定です。

そのときはよしなに。

.