HHVM/Hack Nazgフレームワーク Validationの巻

HHVM/Hack向けに作ったオレオレマイクロフレームワークにおける
HTTPリクエストのバリデーション実装方法を紹介したいと思います!
Hackならではの機能を使ってバリデーションの仕組みを用意しています。

ytake.hateblo.jp

残念ながらLaravelのような細かいバリデーションルール指定方法などは用意していません
アプリケーションに合わせてひたすら実装するべし!
HackではPHPのような動的なメソッドコールはstrictで利用するとtypecheckerに怒られます
回避方法はありますが、実装していくとstrictにしたくなるものです・・

それはさておき

このフレームワークでは、バリデーションはこうしてください、というルールは特に持っていませんが、
専用に用意したAttribute を記述することで
Laravelのフォームリクエスト のような挙動で、
バリデーションを実行することができます。
*Annotationだと思ってください

Validation対象のActionクラス

routeを '/contents/{content}' として、このエンドポイントにアクセスした時にバリデーションを実行するようにします。
下記のようなクラスを作成します

ここに記述されているZend\Diactoros\Response\TextResponseクラスを利用してレスポンスを返却していますが、 Psr\Http\Message\ResponseInterface を実装していればなんでも構いません。

<?hh // strict

namespace App\Action\Document;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Zend\Diactoros\Response\TextResponse;

final class ReadAction implements MiddlewareInterface {

  public function process(
    ServerRequestInterface $request,
    RequestHandlerInterface $handler,
  ): ResponseInterface {
    return new TextResponse('Hello world!');
  }
}

route登録は、 config/routes.global.php ファイルに記述します。

<?hh 

return [
  \Nazg\Foundation\Service::ROUTES => ImmMap {
    \Nazg\Http\HttpMethod::GET => ImmMap {
      '/contents/{content}' => ImmVector {
        App\Action\Document\ReadAction::class
      },
    },
  },
];

忘れずにServiceModuleクラスにインスタンス生成方法を記述しましょう。

<?hh // strict

namespace App\Module;

use App\Action;
use Ytake\HHContainer\Scope;
use Ytake\HHContainer\ServiceModule;
use Ytake\HHContainer\FactoryContainer;

final class ActionServiceModule extends ServiceModule {
  <<__Override>>
  public function provide(FactoryContainer $container): void {
    $container->set(
      Action\Document\ReadAction::class,
      $container ==> new Action\Document\ReadAction(),
      Scope::PROTOTYPE,
    );
  }
}

これでrouteの準備ができました。

http://お好きなdomain/contents/aaaaa などでアクセスできます。

Validationクラスを作る

ここではstringの値が送られているかどうか、というバリデーションを例にしますが、
せっかくなのでHackのshapeを利用して型チェックバリデーションとして実装します。

type-assert install

まずは hhvm/type-assert をインストールします。

github.com

composer require hhvm/type-assert

PHPもインストールされている環境で、
上記コマンドでうまくインストールできない方は下記のようにするといいかもしれません

$ hhvm -d xdebug.enable=0 -d hhvm.jit=0 -d hhvm.hack.lang.auto_typecheck=0 $(which composer) require hhvm/type-assert

バリデーションクラスを、 App\Validation\ContentRequestValidator クラスとして作成します。
Nazg\Foundation\Validation\Validator クラスを継承して実装します。

<?hh // strict

namespace App\Validation;

use Facebook\TypeAssert;
use Nazg\Foundation\Validation\Validator;
use Psr\Http\Message\ServerRequestInterface;

final class ContentRequestValidator extends Validator {
  
  const type ContentRequestShape = shape(
    'content' => string,
  );
  
  protected bool $shouldThrowException = true;
  
  protected Vector<string> $errors = Vector{};

  <<__Override>>
  protected function assertStructure(): void {
    try {
      TypeAssert\matches_type_structure(
        type_structure(self::class, 'ContentRequestShape'),
        $this->request?->getAttributes(),
      );
    } catch (TypeAssert\IncorrectTypeException $e) {
      $this->errors->add("type error");
    }
  }

  protected function assertValidateResult(): Vector<string> {
    return $this->errors;
  }
}

ContentRequestShape

ContentRequestShapeは、リクエストで受け取る値をshapeを使って型を記述しています。
shapeはGoのstructのようなものだと思っておくと理解しやすいかもしれません

shouldThrowException property

フレームワークで、バリデーションエラー時はExceptionを投げないようになっています。
trueにすることでExceptionHandlerクラスで自由にレスポンスを操作することができます。
Laravel/LumenのExceptionHandlerクラスの使い方とほぼ同じです。

assertStructure、assertValidateResultメソッド

フレームワークで用意しているバリデーションで、
型チェックと値自体のバリデーションの両方を実装することができるようになっています。

型チェックが先に実行され、assertValidateResultがバリデーション実行後の結果を返却します。
細かいバリデーションは、クラス内にそれぞれのバリデーションを行いたいメソッドを記述し、
assertValidateResultでそれらを実行し、結果を Vector<string> に詰める、という具合です。

<?hh 
    try {
      TypeAssert\matches_type_structure(
        type_structure(self::class, 'ContentRequestShape'),
        $this->request?->getAttributes(),
      );
    } catch (TypeAssert\IncorrectTypeException $e) {
      $this->errors->add("type error");
    }

Facebook\TypeAssert\matches_type_structure でHTTPリクエストの値が期待している型かどうかをチェックし、
期待していない型の場合は、 Facebook\TypeAssert\IncorrectTypeException がスローされるため、
Vectorに失敗したことを示す文字列を追加しています。

実装後このバリデーションクラスをServiceModuleクラスで登録します。

<?hh // strict

namespace App\Module;

use App\Validation;
use Ytake\HHContainer\Scope;
use Ytake\HHContainer\ServiceModule;
use Ytake\HHContainer\FactoryContainer;

final class ValidationServiceModule extends ServiceModule {
  <<__Override>>
  public function provide(FactoryContainer $container): void {
    $container->set(
      Validation\ContentRequestValidator::class,
      $container ==> new Validation\ContentRequestValidator(),
      Scope::SINGLETON,
    );
  }
}

ServiceModuleクラスはなんでも構いませんが、新たに作った場合はかならず config/modules.global.php に記述してください。

<?hh

return [
  \Nazg\Foundation\Service::MODULES => ImmVector {
    \App\Module\ActionServiceModule::class,
    \App\Module\ExceptionServiceModule::class,
    \App\Module\MiddlewareServiceModule::class,
    \App\Module\LoggerServiceModule::class,
    \App\Module\ValidationServiceModule::class,
  },
];

これでバリデーションの準備が整いました。

バリデーション実行

バリデーションを実行したいクラスのメソッドに Attribute を記述します。

先ほど作成した App\Action\Document\ReadAction クラスで実行するようにするには次の通りです。

<?hh

namespace App\Action\Document;

use App\Validation\ContentRequestValidator;
use App\Responder\IndexResponder;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Zend\Diactoros\Response\TextResponse;

final class ReadAction implements MiddlewareInterface {

  <<RequestValidation(ContentRequestValidator::class)>>
  public function process(
    ServerRequestInterface $request,
    RequestHandlerInterface $handler,
  ): ResponseInterface {
    return new TextResponse('Hello world!');
  }
}

<<RequestValidation(ContentRequestValidator::class)>> この部分がバリデーション指示になります。
指定方法は <<RequestValidation(実行したいバリデーションクラス)>> となります。

最後にExceptionHandlerクラスで任意のレスポンスを返却するように記述すればOKです。
フレームワークのskeletonに継承した App\Exception\AppExceptionHandler クラスが含まれていますので、
そのクラスを利用します。

<?hh

namespace App\Exception;

use Nazg\Http\StatusCode;
use Nazg\Foundation\Validation\ValidationException;
use Nazg\Types\ExceptionImmMap;
use Nazg\Foundation\Exception\ExceptionHandler;
use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\Response\JsonResponse;

class AppExceptionHandler extends ExceptionHandler {
  <<__Override>>
  protected function render(
    ExceptionImmMap $em,
    \Exception $e
  ): ResponseInterface {
    $message = $em->toArray();
    if($e instanceof ValidationException) {
      $message = $e->errors();
    }
    return new JsonResponse(
      $message,
      StatusCode::StatusInternalServerError,
    );
  }
}

Nazg\Foundation\Validation\ValidationException クラスがスローされた場合に返却されるレスポンスを変更しました。

誤った型が送信されるとjson[type error!] と返却されます。
バリデーションの実装は以上になりますが、
実は hack-routerで受け取ったリクエストの値は全てstringになるため、通常はこのバリデーションは絶対にエラーになりませんが、
アプリケーションで利用するものと異なるメソッドなどを指定した場合に発生しますので、
実装時に簡単なエラーなどを見つけることができるようになりますので、
色々チャレンジしてみてください。

以上、簡単なようでちょっと面倒臭いバリデーションの実装方法でした。