Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JsonSchemaExceptionHandlerInterface doesn't catch JsonException in requests #199

Open
sosuke-ito opened this issue Oct 4, 2019 · 3 comments

Comments

@sosuke-ito
Copy link
Contributor

JsonSchemaExceptionHandlerInterface handles JsonExceptions on ill-formed responses but not on ill-formed requests.

Is this behavior intentional?

Example

For example, in this test case

public function testParameterException()
{
$caughtException = null;
$ro = $this->getRo(FakeUser::class);
try {
$ro->onGet(30, 'invalid gender');
} catch (JsonSchemaException $e) {
$caughtException = $e;
}
$this->assertEmpty($ro->body);
$this->assertInstanceOf(JsonSchemaException::class, $caughtException);
}

we get JsonException when we provide $gender = 'invalid gender' even if we bind a handler to JsonSchemaExceptionHandlerInterface.

I guess this is intentional, because ill-formed requests normally cannot continue and end up with abort. We might need handlers for this if we want to return some error-reason-responses or specify errors in response headers.

Proposal

If this is NOT intentional, we may want to handle JsonExceptions thrown in the request validation here:

public function invoke(MethodInvocation $invocation)
{
/** @var ReflectionMethod $method */
$method = $invocation->getMethod();
/** @var JsonSchema $jsonSchema */
$jsonSchema = $method->getAnnotation(JsonSchema::class);
if ($jsonSchema->params) {
$arguments = $this->getNamedArguments($invocation);
$this->validateRequest($jsonSchema, $arguments);
}
/** @var ResourceObject $ro */
$ro = $invocation->proceed();
if ($ro->code === 200 || $ro->code == 201) {
$this->validateResponse($ro, $jsonSchema);
}
return $ro;
}
private function validateRequest(JsonSchema $jsonSchema, array $arguments)
{
$schemaFile = $this->validateDir . '/' . $jsonSchema->params;
$this->validateFileExists($schemaFile);
$this->validate($arguments, $schemaFile);
}

Although I've got no good idea. Like this? (maybe BC-break):

diff --git a/src/JsonSchema/Interceptor/JsonSchemaInterceptor.php b/src/JsonSchema/Interceptor/JsonSchemaInterceptor.php
index 024a9e4..d3d2113 100644
--- a/src/JsonSchema/Interceptor/JsonSchemaInterceptor.php
+++ b/src/JsonSchema/Interceptor/JsonSchemaInterceptor.php
@@ -63,11 +63,11 @@ final class JsonSchemaInterceptor implements MethodInterceptor
         $method = $invocation->getMethod();
         /** @var JsonSchema $jsonSchema */
         $jsonSchema = $method->getAnnotation(JsonSchema::class);
-        if ($jsonSchema->params) {
-            $arguments = $this->getNamedArguments($invocation);
-            $this->validateRequest($jsonSchema, $arguments);
-        }
         /** @var ResourceObject $ro */
+        $ro = $this->validateRequest($jsonSchema, $invocation);
+        if ($ro->code === 400) {
+            return $ro;
+        }
         $ro = $invocation->proceed();
         if ($ro->code === 200 || $ro->code == 201) {
             $this->validateResponse($ro, $jsonSchema);
@@ -76,11 +76,23 @@ final class JsonSchemaInterceptor implements MethodInterceptor
         return $ro;
     }

-    private function validateRequest(JsonSchema $jsonSchema, array $arguments)
+    private function validateRequest(JsonSchema $jsonSchema, MethodInvocation $invocation)
     {
+        $ro = $invocation->getThis();
+        if (! $jsonSchema->params) {
+            return $ro;
+        }
         $schemaFile = $this->validateDir . '/' . $jsonSchema->params;
         $this->validateFileExists($schemaFile);
-        $this->validate($arguments, $schemaFile);
+        $arguments = $this->getNamedArguments($invocation);
+        try {
+            $this->validate($arguments, $schemaFile);
+        } catch (JsonSchemaException $e) {
+            $ro->code = 400;
+            $this->handler->handle($ro, $e, $schemaFile);
+        }
+
+        return $ro;
     }

     private function validateResponse(ResourceObject $ro, JsonSchema $jsonSchema)
@koriym
Copy link
Member

koriym commented Oct 21, 2019

@sosuke-ito Sorry for the late response.
What's your use case in practice ? I have no objection to handle bad request with JsonSchemaExceptionHandlerInterface. But I would like to dig deeper beforehand.

@koriym
Copy link
Member

koriym commented Oct 25, 2019

@sosuke-ito ping

@koriym
Copy link
Member

koriym commented Jan 20, 2025

AIの考察

要点まとめ

  • 現状の JsonSchemaExceptionHandlerInterface は、レスポンススキーマの不整合に対しては例外をハンドリングするが、リクエスト(引数側)のスキーマ違反ではハンドリングしていない
  • 「リクエストスキーマの不整合(例: genderinvalid gender になっているなど)も同じインターフェイスでハンドリングしたい」という要望
  • もともとリクエストのバリデーションエラーは 400 を投げて処理を止めるのが基本方針とも考えられる
  • もし、エラーをレスポンスとして返したい場合は、「レスポンスと同じように JsonSchemaExceptionHandlerInterface で包む」か、または別途アプリケーション固有の層でハンドリングするか、判断が分かれる

BEAR.Resource 側の意図と現在の実装

JsonSchemaInterceptor は元々 レスポンス (返却するリソースオブジェクト) の JSON スキーマ検証を行い、そこに不備があったら例外を投げ、JsonSchemaExceptionHandlerInterface で受け止めて擬似的にエラーリソースへ書き換えたりします。一方、リクエスト の方のスキーマ検証が失敗した場合は、そのまま JsonSchemaException をスローして終わりになっています。

このように「レスポンス検証だけはエラーハンドラで包むが、リクエスト検証はそうしない」構造になっている背景としては、

  1. リソース内部の処理は開始する前 にリクエストスキーマが不正な場合は、リソースメソッドを呼び出しても整合性が取れず危険である
  2. したがって「(悪意ある/誤った)クライアント入力を受理しない」= 400 Bad Request を投げて終わりにするほうが筋が通っている
  3. レスポンススキーマ違反は "プログラマのミス" (想定外の戻り値) なので、それはフレームワークの仕掛けでモックレスポンスに差し替えるなどの仕組みを用意している

という使い分けが想定されるからです。
(もちろん、アプリケーションとして「リクエストを検証して独自エラーボディを返したい」という場合はあり得ます。しかし、「そもそも入力がおかしいなら一律 400 を投げるだけ」というのがシンプルなので、そう設計してあるという面が大きいです。)


Issue として挙がっている提案

If this is NOT intentional, we may want to handle JsonExceptions thrown in the request validation

という形で、「もしこれが意図的でないなら、上記の差分パッチのようにリクエスト不正時にもハンドラーを呼べるようにしませんか?」という提案が挙がっています。

--- a/src/JsonSchema/Interceptor/JsonSchemaInterceptor.php
+++ b/src/JsonSchema/Interceptor/JsonSchemaInterceptor.php
@@ -63,11 +63,11 @@ final class JsonSchemaInterceptor implements MethodInterceptor
         $method = $invocation->getMethod();
         /** @var JsonSchema $jsonSchema */
         $jsonSchema = $method->getAnnotation(JsonSchema::class);
-        if ($jsonSchema->params) {
-            $arguments = $this->getNamedArguments($invocation);
-            $this->validateRequest($jsonSchema, $arguments);
-        }
         /** @var ResourceObject $ro */
+        $ro = $this->validateRequest($jsonSchema, $invocation);
+        if ($ro->code === 400) {
+            return $ro;
+        }
         $ro = $invocation->proceed();
         ...

このパッチでは、リクエストが不整合のとき JsonSchemaException をキャッチして $ro->code = 400; とした上で、$this->handler->handle($ro, $e, $schemaFile); を呼ぶ、という形になっています。
これによりレスポンス検証と同じように JsonSchemaExceptionHandlerInterface で「不正入力の場合の出力」も生成できるようになるわけです。

メリット

  • アプリケーション都合で「不正入力」に対しても JSON のエラーレスポンスを返したい、あるいはモックレスポンスを返したい場合に対応できる

デメリット

  • 破壊的変更 (BCブレイク) の可能性
    • 以前は「不正なリクエストならすぐ例外が飛ぶ」挙動だったものが、「内部で 400 をセットしてレスポンスを返す」挙動に変わる
    • 既にアプリケーションで独自に例外キャッチして 400 処理をしていたなら衝突するかもしれない
  • 「そもそも不正リクエストは即座に落として良いのでは?」というシンプルな考え方と相反する

結論・コメント

  • 現状の実装は「意図的にレスポンススキーマエラーのみハンドラーを介している」 と考えられます。
    リクエスト不正時には「そもそも継続して実行するのが危険なので即例外を投げる → 400 でアプリケーションを強制終了」する、というのが基本方針です。
  • もし「リクエスト不整合でもエラーレスポンスを生成したい」場合はアプリケーションコードで包むか、もしくは独自に JsonSchemaInterceptor を差し替えて対応する ことになります。
    • たとえば自前のモジュールか AOP Interceptor を差し替える形でパッチのような挙動を上書きすることは可能です。
    • あるいは JsonSchemaInterceptor を拡張して「リクエスト時に例外が投げられたらキャッチし、400 のエラーボディをセットして返す」という挙動を実装してもよいでしょう。
  • 「これは公式が積極的に導入するかどうか」は、Issue で議論しながら検討されることになると思われます。
    • 既存ユーザーが「リクエスト不正時に勝手にエラーボディを生成されても困る」ケースもあるかもしれず、BCブレイク の懸念があるため、すぐにはマージされない可能性が高いです。

もし「アプリケーション全体で統一的に JsonSchemaException を 400 レスポンスでハンドリングしたい」というユースケースなら、

  1. まずは自前の AOP Interceptor などで実装してみる
  2. そのパターンが広く必要とされるようなら、将来的にオプションとして(BCブレイクにならない方法で)ライブラリ側に取り込む

という流れが自然かと思います。

まとめとしては:

これは意図的挙動とみなして良い。もし「リクエストのスキーマ不正でもレスポンスを返したい」なら、JsonSchemaInterceptor や周辺インターセプターを差し替えて実装するのが現実的。ただし既存利用者との互換性を考えると、現段階ではライブラリに組み込まれていない。

という回答になります。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants