もがき系プログラマの日常

もがき系エンジニアの勉強したこと、日常のこと、気になっている技術、備忘録などを紹介するブログです。

LaravelでJsonSchema使いたい

はじめに

こんばんは。

Laravelの開発を始めております。

APIでLaravelを使用してるのですが、Request/Responseのチェックをできないものかと考えていて、jsonSchema使えばいいじゃんと思い早速探してみました。

packagistを調べてると、 それっぽいライブラリ を発見したのですが、なんか思ってたものと違うのと、 ServiceProviderでやってたので、どちらかというと Middlewareの仕事かな?と思い、Middlewareで作ってみました。

検証環境は以下

  • php: 7.3.2
  • Laravel: 5.8.4

コード

1. JsonSchemaValidate.php(Middleware)

<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use App\Exceptions\JsonSchemaException;
use Closure;
use Illuminate\Support\Facades\Route;
use JsonSchema\Validator;

class JsonSchemaValidate
{
    const TYPE_REQUEST  = 'Request';

    const TYPE_RESPONSE = 'Response';

    /**
     * Handle an incoming request.
     *
     * @param \Illuminate\Http\Request $request
     * @param \Closure                 $next
     * @throws JsonSchemaException
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        $routeName =$this->getRouteName($request);

        if ($routeName === null) {
            return $next($request);
        }

        $validator = new Validator();

        $this->validateRequest($validator, $request, $routeName);

        $response = $next($request);

        $this->validateResponse($validator, $response, $routeName);

        return $response;
    }

    /**
     * @param Validator                $v
     * @param \Illuminate\Http\Request $request
     * @param string                   $routeName
     * @throws JsonSchemaException
     */
    public function validateRequest(Validator $v, \Illuminate\Http\Request $request, string $routeName): void
    {
        $requestSchema = $this->getSchema($routeName, self::TYPE_REQUEST);

        if ($requestSchema !== null) {
            $v->check((object)$request->all(), $requestSchema);

            if ($v->numErrors() >= 1) {
                throw new JsonSchemaException(serialize($v->getErrors()));
            }
        }
    }

    /**
     * @param Validator                 $v
     * @param \Illuminate\Http\JsonResponse $response
     * @param string                    $routeName
     * @throws JsonSchemaException
     */
    public function validateResponse(Validator $v, \Illuminate\Http\JsonResponse $response, string $routeName): void
    {
        $responseSchema = $this->getSchema($routeName, self::TYPE_RESPONSE);

        if ($responseSchema !== null) {
            $v->check($response->getData(), $responseSchema);

            if ($v->numErrors() >= 1) {
                throw new JsonSchemaException(serialize($v->getErrors()));
            }
        }
    }

    /**
     * @param string $routeName
     * @param string $type
     * @return null|array
     */
    public function getSchema(string $routeName, string $type): ?array
    {
        $className = $this->getJsonSchemaClassName($routeName, $type);
        return class_exists($className) ? $className::getSchema() : null;
    }

    /**
     * @param string $routeName
     * @param string $type
     * @return string
     */
    public function getJsonSchemaClassName(string $routeName, string $type): string
    {
        return "App\\Http\\Schema\\{$type}\\{$routeName}Schema";
    }

    /**
     * @param \Illuminate\Http\Request $request
     * @return null|string
     */
    public function getRouteName(\Illuminate\Http\Request $request): ?string
    {
        $route = Route::getRoutes()->match($request);
        return $route->getName();
    }
}

2.MemberListSchema.php(RequestパラメータのJsonSchema)

<?php

declare(strict_types=1);

namespace App\Http\Schema\Request;

class MemberListSchema
{
    /**
     * @return array
     */
    public static function getSchema(): array
    {
        return [
            '$schema'    => 'http://json-schema.org/draft-07/schema#',
            'required'   => ['page'],
            'type'       => 'object',
            'properties' => [
                'page' => ['type' => 'string'],
            ],
        ];
    }
}

3.MemberListSchema.php(ResponseデータのJsonSchema)

<?php

declare(strict_types=1);

namespace App\Http\Schema\Response;

class MemberListSchema
{
    /**
     * @return array
     */
    public static function getSchema(): array
    {
        return [
            '$schema'    => 'http://json-schema.org/draft-07/schema#',
            'required'   => ['total', 'data'],
            'type'       => 'object',
            'properties' => [
                'total' => ['type' => 'number'],
                'data'  => [
                    'type'  => 'array',
                    'items' => [
                        'required'   => [
                            'id',
                            'lastName',
                            'firstName',
                            'mailAddress',
                            'roles'
                        ],
                        'type'       => 'object',
                        'properties' => [
                            'id'          => ['type' => 'number'],
                            'lastName'    => ['type' => 'string'],
                            'firstName'   => ['type' => 'string'],
                            'mailAddress' => ['type' => 'string'],
                            'roles'       => ['type' => 'array'],
                        ],
                    ],
                ],
            ],
        ];
    }
}

4.api.php(routes)

<?php

declare(strict_types=1);

Route::group(['middleware' => ['json_schema']], function (): void {
    Route::get('/member', 'MemberController@list')->name('MemberList');

ルートの name() で判定しています。

なので、ルートの名前が設定されてないと何も出来ません...

例えばリクエストで、pageというパラメータを送らない場合、以下のようなレスポンスになります。

f:id:kojirooooocks:20190316013149p:plain

とりあえず動いているみたいです。

終わりに

最初はschemaをjsonファイルで作ってたのですが、毎回 file_get_contents()するのがダサいなと思ったので、schemeクラスにしました。

もしかしたら自分が調べきれてないだけで、Laravel自体にこういう機能ってあるのかな?

もし知ってたら誰か教えてください。

簡単になりますが、現場からは以上です。

追記

レスポンスの型チェックは、正常系のレスポンスのときでないと、別のExceptionが投げられた際も型チェックしてしまうことに気づきました...

レスポンス部分は以下のように変えたほうが良さそうです。

    public function validateResponse(Validator $v, \Illuminate\Http\JsonResponse $response, string $routeName): void
    {
        $responseSchema = $this->getSchema($routeName, self::TYPE_RESPONSE);

        if ($responseSchema !== null) {
            if ($response->isSuccessful()) {
                $v->check($response->getData(), $responseSchema);
                if ($v->numErrors() >= 1) {
                    throw new JsonSchemaException(serialize($v->getErrors()));
                }
            }
        }
    }