のんラボ

S3みたいなストレージサーバーっぽいものを自前で用意する④【RFC 7807 エラーレスポンス実装】

2022/03/26 2022/04/24 S3みたいなストレージサーバーっぽいものを自前で用意する④【RFC 7807 エラーレスポンス実装】

S3みたいなストレージサーバーっぽいものを自前で用意する④【RFC 7807 エラーレスポンス実装】

こんにちは。のんです。

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

GitHubプロジェクトはこちら

APIのエラーレスポンス標準化について

APIのエラーレスポンスってサービスによって独自に決められたりしていてとても不便ですよね。

そこでHTTP APIのエラーレスポンスにも規約が定められたりしています。

それが RFC 7807 です。

日本語版はこちら

RFC 7807 の実装

では、早速この規約に従って実装しよう。と思いましたが、まぁそれも面倒です。

こういう標準化規約されているものは世界の優秀な方々がすでに作成していたりするものなのでこれに乗っかる形で実装してしまいましょう。
俗に言う standing on the shoulders of Giants ですね。


さくっと調べた結果、

Crell / ApiProblem

というものがありそうです。私も伝聞で知ったものなので信頼性などの調査は完璧ではありませんが、タダ乗りなので文句は言えないでしょう。こちらを採用します。

書き味は Example にも書いてあるようにこのような感じ。

copied.use Crell\ApiProblem\ApiProblem;

$problem = new ApiProblem("You do not have enough credit.", "http://example.com/probs/out-of-credit");
// Defined properties in the API have their own setter methods.
$problem
    ->setDetail("Your current balance is 30, but that costs 50.")
    ->setInstance("http://example.net/account/12345/msgs/abc");
// But you can also support any arbitrary extended properties!
$problem['balance'] = 30;
$problem['accounts'] = [
    "http://example.net/account/12345",
    "http://example.net/account/67890"
];

$json_string = $problem->asJson();

// Now send that JSON string as a response along with the appropriate HTTP error
// code and content type which is available via ApiProblem::CONTENT_TYPE_JSON.
// Also check out asXml() and ApiProblem::CONTENT_TYPE_XML for the angle-bracket fans in the room.

しかし、いくら便利でもこの実装を至るところに書くのは面倒ですし、設計上よくありません。

そこで、

で少し触れた、独自 HttpException が活きてきます。もちろん、設計の是非はあるとは思います。私なりの解釈を次の章で説明します。

HttpExceptionRFC 7807 のエラーレスポンスを実装する

このサービスでは HttpException にエラー時のHttpレスポンスを乗せることを想定しました。

何かしらのエラーが発生したときは最終的に HttpException をスローし、それを Controller ないしは Middleware がレスポンスとして返す。という流れです。

Exception に実装されている code Http Status Code は別物なので、レスポンスの情報を乗せられるように実装します。

まずは HttpExceptionInterface から

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

namespace Nonz250\Storage\App\Foundation\Exceptions;

use Psr\Http\Message\ResponseInterface;

interface HttpExceptionInterface
{
    public function getStatusCode(): int;

    public function getApiProblemResponse(): ResponseInterface;
}

HttpExceptionInterface を実装する HttpException は必ず HttpStatusCode を取得できるようにしていなければなりません。なので、 getStatusCode を実装するように定義します。

getApiProblemResponse でエラーレスポンスを作成し取得します。

そして肝心の HttpException の基盤例外クラスがこちら。

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

namespace Nonz250\Storage\App\Foundation\Exceptions;

use Crell\ApiProblem\ApiProblem;
use Crell\ApiProblem\HttpConverter;
use Fig\Http\Message\StatusCodeInterface;
use Laminas\Diactoros\ResponseFactory;
use Psr\Http\Message\ResponseInterface;
use RuntimeException;
use Throwable;

class HttpException extends RuntimeException implements HttpExceptionInterface
{
    private int $statusCode;
    private string $description;

    public function __construct(
        int $statusCode = StatusCodeInterface::STATUS_OK,
        $description = '',
        $message = '',
        $code = 0,
        Throwable $previous = null
    ) {
        parent::__construct($message, $code, $previous);
        $this->statusCode = $statusCode;
        $this->description = $description;
    }

    public function getStatusCode(): int
    {
        return $this->statusCode;
    }

    public function getApiProblemResponse(): ResponseInterface
    {
        $responseFactory = new ResponseFactory();
        $converter = new HttpConverter($responseFactory, true);
        $problem = new ApiProblem($this->getMessage());
        $problem
            ->setStatus($this->getStatusCode())
            ->setDetail($this->description);
        return $converter->toJsonResponse($problem);
    }
}

この例外クラスは

  • 例外の主なメッセージ
  • Http Status Code (4XX や 5XX を想定)
  • 例外の詳細文

を必要とし、これらの情報と Crell / ApiProblem を利用して ResponseInterface を実装した Httpレスポンスを返します。

copied.$responseFactory = new ResponseFactory();
$converter = new HttpConverter($responseFactory, true);

ResponseInterface を実装した HttpResponse を作成するために必要です。
最終的には toJsonResponse でjson形式のレスポンスを返す関数を利用するために使います。

copied.$problem = new ApiProblem($this->getMessage());
$problem
    ->setStatus($this->getStatusCode())
    ->setDetail($this->description);

肝心の部分、 type は必須のため コンストラクタに 例外の主なメッセージ を渡しておきます。
その後、必要に応じて statusdetail を埋めるために setterHttp Status Code (4XX や 5XX を想定) や 例外の詳細文 を設定します。

このあたりの詳細は RFC 7807 を参照していただければと思います。

わかりやすいように引用しておくと、

o "type"(文字列)-問題のタイプを識別するURI参照 (RFC3986)。この仕様では、逆参照すると、問題の種類について人間が読める形式のドキュメントが提供されるようになります(たとえば、HTML (W3C.REC-html5-20141028)を使用)。このメンバーが存在しない場合、その値は "about:blank"であると見なされます。

o "title"(文字列)-人間が読める形式の問題タイプの概要。ローカリゼーションの目的を除いて、問題の発生ごとに変更するべきではありません(たとえば、事前対応型のコンテンツネゴシエーションを使用します。(RFC7231)、セクション3.4を参照)。

o "status"(number)-この問題の発生に対してオリジンサーバーによって生成されたHTTPステータスコード((RFC7231)、セクション6)。

o "detail"(文字列)-この問題の発生に固有の、人間が読める説明。

o 「instance」(文字列)-問題の特定の発生を識別するURI参照。逆参照すると、詳細情報が得られる場合と得られない場合があります。

エラーレスポンスの例としては

For example, an HTTP response carrying JSON problem details:

たとえば、JSON問題の詳細を含むHTTP応答:

copied.HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
Content-Language: en

{
    "type": "https://example.com/probs/out-of-credit",
    "title": "You do not have enough credit.",
    "detail": "Your current balance is 30, but that costs 50.",
    "instance": "/account/12345/msgs/abc",
    "balance": 30,
    "accounts": ["/account/12345",
                 "/account/67890"]
}

と書いてありますね。

最後に、この ApiProblem クラスを toJsonResponse メソッドを使ってHttpレスポンスに変えます。

copied.return $converter->toJsonResponse($problem);

これが getApiProblemResponse の全容です。


ちなみに、 HttpStatusCode の番号をマジックナンバーにしたくないため、

のライブラリを利用しています。

PSR-7 のHTTPに関する番号や数字をまとめて定義してくれている便利なライブラリです。

独自 HttpException クラスの使い方

何かしらの例外やエラーをキャッチしたときに、この HttpException を継承したクラスに詰め直して、スローするだけです。

copied.try {
    $digests = $request->getHeader('Authorization') ?? [];
    if (count($digests) === 0) {
        throw new HttpUnauthorizedException('Please set `Authorization` header.');
    }
} catch (HttpUnauthorizedException $e) {
    $this->logger->error($e);
    return $e->getApiProblemResponse();
}

上記の用に HttpUnauthorizedException などを用意しておけば更に使いやすいはずです。

HttpUnauthorizedException の実装はこちら。ただのラッパーであることがわかるはずです。

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

namespace Nonz250\Storage\App\Foundation\Exceptions;

use Fig\Http\Message\StatusCodeInterface;
use Throwable;

class HttpUnauthorizedException extends HttpException
{
    public function __construct($description = '', $message = 'Unauthorized.', $code = 0, Throwable $previous = null)
    {
        parent::__construct(StatusCodeInterface::STATUS_UNAUTHORIZED, $description, $message, $code, $previous);
    }
}

こうすることでとりあえずエラーが発生したときは HttpException を実装した例外クラスをスローしておけばなんとかなる。

最終的にキャッチしたところで、 return $e->getApiProblemResponse() をすれば RFC 7807 を実装したHttpレスポンスとして返すことができるという便利な設計になりました。エラーレスポンスに関する責務も HttpException に封じ込めることができています。

実際の動作

実際に返ってくるレスポンスはこちら。

copied.{
    "title": "BadRequest.",
    "type": "about:blank",
    "status": 400,
    "detail": "appName is required."
}

最後に

このサービスは自分だけが利用することを想定しているので、APIエラー時に参照すべきドキュメントはありません。

そのため type は全て about:blank ですが、外部に公開しているサービスならこの部分がドキュメントへのURLになるでしょう。とても親切な作りですね。

ドキュメントを読む限り invalid-params を利用すれば複数のバリデーションエラーも含めることができそうなので、汎用性はとても高そう。

PHPフレームワークにも標準で実装しておいていただければ助かるものですが、まぁ、そう思うなら自分でプルリク出せという話ですね。どちらかというとプラグインとして用意してあげるのが良さそう?でもそれなら、この記事で紹介したライブラリで十分というものでしょう。

という感じで RFC 7870 を実装してみました。
次回は Repository の実装になりそうです。

このときにDIについても話になるかもしれません。

また記事にします。

そのときはよしなに。

.