Featured image of post 【bindFromRequestData】playFramework2.7で配列をマッピングする方法【BeanValidator】

【bindFromRequestData】playFramework2.7で配列をマッピングする方法【BeanValidator】

2003文字

配列クエリをFormモデルにバインドしようとしたら一癖あった話

PlayFrameworkを使ってAPI開発をしている際に、配列を想定しているクエリパラメーターに対してもBeanバリデーションを行いたかったため、Formモデルを使ってマッピングしようとしたところ、最初の要素しかListモデルにマッピング出来ませんでした。

今回はその原因と実際に試してみた対処方法についてご紹介しようと思います。

手順

前提

以下のようなidを複数指定して返却するようなAPIがあったとします。

OpenAPI
openapi: 3.0.0
info:
  title: サンプル
  version: 1.0.0
paths:
  /v1/accounts:
    get:
      parameters:
        - name: ids
          in: query
          required: false
          schema:
            type: array
            items:
              type: integer
              format: int32
      responses:
        200:
          description: 成功時のレスポンス
          content:
            'application/json':
              schema:
                type: object
                properties:
                  accounts:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          format: int32
                          example: 1001
                        name:
                          type: string
                          example: 山田太郎
                        age:
                          type: integer
                          format: int32
                          example: 26
                  total:
                    type: integer
                    format: int32
                    example: 1

まずはModelにマッピングせずに直接取得する方法

まずはController側で直接配列のクエリパラメーターを受け取ってみましょう。

[adsense-responsive]

以下のようなroutesの記述とControllerがあったとします。

conf/routes
# Routes
# This file defines all application routes (Higher priority routes first)
# ~~~~

GET     /v1/accounts                controllers.account.AccountController.listingBy(ids: java.util.List[Integer])
...
(略)
...

constrollers.account.AccountController.java
package controllers.account;

import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import models.account.AccountListElementModel;
import models.account.AccountListModel;
import play.mvc.Controller;
import play.mvc.Result;

public class AccountController extends Controller {
  public Result listingBy(List<Integer> ids) {
...
()
...

確認

この場合idsの値は、http://localhost:9000/v1/accounts?ids=1&ids=4&ids=6の場合はids146http://localhost:9000/v1/accounts?ids=1の場合は1http://localhost:9000/v1/accountsの場合はidsempty(nullではない)となります。

しっかりマッピング出来てますね♪

Formモデルで取得

しかし、routesControllerの引数に直接追加すると、クエリパラメーターが増えたりした際の修正範囲が増えてしまいます。

PlayFrameworkのFormはPOSTなどのリクエストボディのマッピングで主に使うようですがGETのクエリパラメーターに対してもモデルにマッピングし、さらにBeanバリデーションを活用したいので試してみましょう。

[adsense-responsive]

以下の検索モデルを用意します。

models.account.AccountListCriteriaModel.java
package models.account;

import java.util.List;

import javax.validation.constraints.Size;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@Builder
@Data
public class AccountListCriteriaModel {
  @Size(max = 3)
  public List<Integer> ids;
  public AccountListCriteriaModel(){} // LombokのNoArgsConstructorが認識されないので明示的に定義
}

今回はとりあえずなにかしらのBeanバリデーションをかけたいので、@Sizeを利用し要素が4件以上指定された場合にバリデーションエラーを発生させるようにしてみます。

routesControllerは以下のように修正します。

conf/routes
# Routes
# This file defines all application routes (Higher priority routes first)
# ~~~~

GET     /v1/accounts     controllers.account.AccountController.listingBy(request: Request)

...
(略)
...

確認

では、実際にリクエストしてみましょう。

まず、http://localhost:9000/v1/accountsの場合はids=nullとなります。この場合はControllerの引数で直接取得する場合との差異が出てますね。

次にhttp://localhost:9000/v1/accounts?ids=1の場合。

こちらは正しくListの要素として4要素がマッピングされていますね。

最後に複数クエリパラメーターが指定されたhttp://localhost:9000/v1/accounts?ids=1&ids=4&ids=8&ids=10の場合です。

あら、一つ目の要素しかマッピングされていませんし、Beanバリデーターも機能していませんね。。。

試しにCriteriaモデルのidsの型をListから配列にしてもダメでした。orz

配列をマッピングしたい場合は[]を付けるのがplayのお作法

実は、PlayFrameworkでは配列パラメーターをマッピングしたい場合は[]を付けるのが基本となっているようです。

試しに、http://localhost:9000/v1/accounts?ids[]=1&ids[]=4&ids[]=8&ids[]=10でリクエストしてみましょう。

正しくids4要素マッピングされ、Beanバリデーションも実行されていますね!

とはいえ、[]を付けるのはちょっと気持ち悪い。。。

しかし、クエリパラメーターに[]を付けるのはちょっと一般的ではない気がするのと、呼び出し側も修正しないといけないのでちょっと採用したくない案ですよね。。。

そこで、クエリには[]をつけずにFormでバインドしてBeanバリデーションを実行する方法を以下のようにして実現してみました!(もっとスマートな方法があるかもしれません。orz)

配列を[]を使わずにFormモデルにbind&Beanバリデーションを実行する方法

routesの修正

まず、routesの設定として配列になりうる要素のみControllerの引数で取得出来るようにします。

[adsense-responsive]

conf/routes
# Routes
# This file defines all application routes (Higher priority routes first)
# ~~~~

GET     /v1/accounts                controllers.account.AccountController.listingBy(request: Request,ids: java.util.List[String])

...
(略)
...

この際に、Stringで定義しておくことがポイントです。

ControllerでFormにbindする部分を修正

次に、Controllerの引数で受け取った配列を利用して意図的にバインドする処理を追加します。

controllers.account.AccountController.java
package controllers.account;

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import javax.inject.Inject;
import javax.inject.Singleton;

import models.account.AccountListCriteriaModel;
import models.account.AccountListElementModel;
import models.account.AccountListModel;
import play.data.Form;
import play.data.FormFactory;
import play.i18n.MessagesApi;
import play.mvc.Controller;
import play.mvc.Http;
import play.mvc.Result;

@Singleton
public class AccountController extends Controller {

  private final MessagesApi messagesApi;
  private final Form<AccountListCriteriaModel> accountListCriteria;
  private final String CODES_QUERY_KEY = "ids"; // 配列クエリのキーを定義

  @Inject
  public AccountController(FormFactory formFactory, MessagesApi messagesApi) {
    this.messagesApi = messagesApi;
    this.accountListCriteria = formFactory.form(AccountListCriteriaModel.class);
  }

  public Result listingBy(Http.Request request, List<String> ids) {
    // クエリに含まれている配列があり得るキーの値を一旦除去
    Map<String, String[]> ignoreArrayQueryStringMap =
        request.queryString().entrySet().stream()
            .filter(entry -> !Arrays.asList(CODES_QUERY_KEY).contains(entry.getKey()))
            .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

    // Listを配列に変換
    String[] convertCodes = new String[ids.size()];
    ids.toArray(convertCodes);

    // 受け取った配列をMapに追加
    ignoreArrayQueryStringMap.put(String.format("%s%s", CODES_QUERY_KEY, "[]"), convertCodes);

    // 処理を加えたMapを使ってbind
    final Form<AccountListCriteriaModel> bindForm =
        accountListCriteria.bindFromRequestData(
            this.messagesApi.preferred(request).lang(), request.attrs(), ignoreArrayQueryStringMap);

...
()
...

確認

では、http://localhost:9000/v1/accounts?ids=1&ids=4&ids=8&ids=10にアクセスして確認してみましょう。

これで一応期待通りの動きになりましたね!

終わりに

痒いところに手が届かない部分を自前でカスタマイズしてみました。

他にも良い対応策があると思いますが、同じようにモヤモヤしている方は参考にしてみていただければなと思います。