S3みたいなストレージサーバーっぽいものを自前で用意する④【RFC 7807 エラーレスポンス実装】
こんにちは。のんです。
前回に引き続き自前でストレージサーバーを開発していこうと思います。
GitHubプロジェクトはこちら
APIのエラーレスポンス標準化について
APIのエラーレスポンスってサービスによって独自に決められたりしていてとても不便ですよね。
そこでHTTP APIのエラーレスポンスにも規約が定められたりしています。
それが RFC 7807
です。
日本語版はこちら
RFC 7807
の実装
では、早速この規約に従って実装しよう。と思いましたが、まぁそれも面倒です。
こういう標準化規約されているものは世界の優秀な方々がすでに作成していたりするものなのでこれに乗っかる形で実装してしまいましょう。
俗に言う standing on the shoulders of Giants
ですね。
さくっと調べた結果、
Crell / ApiProblem
というものがありそうです。私も伝聞で知ったものなので信頼性などの調査は完璧ではありませんが、タダ乗りなので文句は言えないでしょう。こちらを採用します。
書き味は Example
にも書いてあるようにこのような感じ。
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
が活きてきます。もちろん、設計の是非はあるとは思います。私なりの解釈を次の章で説明します。
HttpException
に RFC 7807
のエラーレスポンスを実装する
このサービスでは HttpException
にエラー時のHttpレスポンスを乗せることを想定しました。
何かしらのエラーが発生したときは最終的に HttpException
をスローし、それを Controller
ないしは Middleware
がレスポンスとして返す。という流れです。
Exception
に実装されている code
と Http Status Code
は別物なので、レスポンスの情報を乗せられるように実装します。
まずは HttpExceptionInterface
から
<?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
の基盤例外クラスがこちら。
<?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レスポンスを返します。
$responseFactory = new ResponseFactory();
$converter = new HttpConverter($responseFactory, true);
ResponseInterface
を実装した HttpResponse
を作成するために必要です。
最終的には toJsonResponse
でjson形式のレスポンスを返す関数を利用するために使います。
$problem = new ApiProblem($this->getMessage());
$problem
->setStatus($this->getStatusCode())
->setDetail($this->description);
肝心の部分、 type
は必須のため コンストラクタに 例外の主なメッセージ
を渡しておきます。
その後、必要に応じて status
や detail
を埋めるために setter
で Http 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応答:
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レスポンスに変えます。
return $converter->toJsonResponse($problem);
これが getApiProblemResponse
の全容です。
ちなみに、 HttpStatusCode
の番号をマジックナンバーにしたくないため、
のライブラリを利用しています。
PSR-7
のHTTPに関する番号や数字をまとめて定義してくれている便利なライブラリです。
独自 HttpException
クラスの使い方
何かしらの例外やエラーをキャッチしたときに、この HttpException
を継承したクラスに詰め直して、スローするだけです。
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
の実装はこちら。ただのラッパーであることがわかるはずです。
<?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
に封じ込めることができています。
実際の動作
実際に返ってくるレスポンスはこちら。
{
"title": "BadRequest.",
"type": "about:blank",
"status": 400,
"detail": "appName is required."
}
最後に
このサービスは自分だけが利用することを想定しているので、APIエラー時に参照すべきドキュメントはありません。
そのため type
は全て about:blank
ですが、外部に公開しているサービスならこの部分がドキュメントへのURLになるでしょう。とても親切な作りですね。
ドキュメントを読む限り invalid-params
を利用すれば複数のバリデーションエラーも含めることができそうなので、汎用性はとても高そう。
PHPフレームワークにも標準で実装しておいていただければ助かるものですが、まぁ、そう思うなら自分でプルリク出せという話ですね。どちらかというとプラグインとして用意してあげるのが良さそう?でもそれなら、この記事で紹介したライブラリで十分というものでしょう。
という感じで RFC 7870
を実装してみました。
次回は Repository
の実装になりそうです。
このときにDI
についても話になるかもしれません。
また記事にします。
そのときはよしなに。
.