RESTEasy BeanValidationエラーメッセージのカスタマイズ

JAX-RS Specでは、BeanValidationのバリデーションエラー時には、エラー原因を返すことのみが規定されており、メッセージのフォーマット自体は実装依存です。

7.6 Validation and Error Reporting
In all cases, JAX-RS implementations SHOULD include a response entity describing the source of the error; however, the exact content and format of this entity is beyond the scope of this specification.

この記事ではRESTEasyの場合にどのようにメッセージフォーマットをカスタマイズするか言及します。

RESTEasyデフォルトの振る舞い

例として以下のバリデーションを実行を考えます。

public class Customer {
    @NotBlank(message = "Customer id is required.")
    private String id;

    @NotBlank(message = "Customer name is required.")
    private String name;

    @NotBlank(message = "Customer location is required.")
    private String location;
    ...
}

@Path("/customer")
public class CustomerResource {
    @POST
    @Produces(MediaType.APPLICATION_JSON)
    @Consumes(MediaType.APPLICATION_JSON)
    public Customer post(@Valid Customer cu) {
        ...
    }
}

WildFlyやQuarkusが内蔵するJAX-RS実装のRESTEasyの場合、バリデーションエラー時にはHTTPレスポンスヘッダにvalidation-exception:
true
を含むと共に、以下のようなフォーマットのメッセージを返します。各プロパティはバリデーションの種類を示しています。例の場合はメソッドパラメータでバリデーションしているため、parameterViolationsにメッセージが含まれています。

// 見やすいように改行を含めていますが、実際には改行は含まれません
{
   "exception":null,
   "fieldViolations":[],
   "propertyViolations":[],
   "classViolations":[],
   "parameterViolations":[
      {
         "constraintType":"PARAMETER",
         "path":"post.arg0.name",
         "message":"Customer name is required.",
         "value":""
      },
      {
         "constraintType":"PARAMETER",
         "path":"post.arg0.id",
         "message":"Customer id is required.",
         "value":""
      }
   ],
   "returnValueViolations":[]
}

エラーメッセージのカスタマイズ

もっとシンプルに以下のようなエラーメッセージのリストを返すことを考えます。

{"errors":["Customer id is required.","Customer name is required."]}

org.jboss.resteasy.api.validation.ResteasyViolationExceptionMapperクラスをオーバーライドするとメッセージフォーマットのカスタマイズができます。

コード例は以下の通りです。ResteasyViolationExceptionMapperクラスでは後で拡張できるように、いくつかのメソッドのスコープがprotectedに設定されています。WildFly17で動作します。Quarkus0.16.1では原因は詳しくデバッグしていませんが動作しません。

package sample;

import org.jboss.resteasy.api.validation.ResteasyViolationException;
import org.jboss.resteasy.api.validation.ResteasyViolationExceptionMapper;
import org.jboss.resteasy.api.validation.Validation;

import javax.json.Json;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.ResponseBuilder;
import javax.ws.rs.ext.Provider;
import java.util.List;
import java.util.stream.Collectors;

@Provider
public class ViolationExceptionMapper extends ResteasyViolationExceptionMapper {

    @Override
    protected Response buildViolationReportResponse(ResteasyViolationException exception, Response.Status status) {
        ResponseBuilder builder = Response.status(status);
        builder.header(Validation.VALIDATION_HEADER, "true");

        if (isAcceptJson(exception.getAccept())) {
            builder.type(MediaType.APPLICATION_JSON);

            List<String> errors = exception.getViolations()
                    .stream()
                    .map(violation -> violation.getMessage())
                    .collect(Collectors.toList());

            builder.entity(Json.createObjectBuilder()
                    .add("errors", Json.createArrayBuilder(errors).build())
                    .build());
            return builder.build();
        }

        // Default media type.
        builder.type(MediaType.TEXT_PLAIN);
        builder.entity(exception.toString());
        return builder.build();

    }

    private boolean isAcceptJson(List<MediaType> accepts) {
        return accepts.stream()
                .anyMatch(mt -> MediaType.APPLICATION_JSON_TYPE.getType().equals(mt.getType())
                        && MediaType.APPLICATION_JSON_TYPE.getSubtype().equals(mt.getSubtype()));
    }
}

org.jboss.resteasy.api.validationパッケージにクラスパスが通るように、pom.xmldependencyresteasy-jaxrsを追加します。

    <dependencies>
        <dependency>
            <groupId>org.jboss.spec</groupId>
            <artifactId>jboss-javaee-web-8.0</artifactId>
            <version>1.0.3.Final</version>
            <type>pom</type>
        </dependency>
        <dependency>
            <groupId>org.jboss.resteasy</groupId>
            <artifactId>resteasy-jaxrs</artifactId>
            <version>3.7.0.Final</version>
            <scope>provided</scope>
        </dependency>
  </dependencies>

まとめ

  • JAX-RS仕様ではBeanValidationエラー時のメッセージフォーマットは規定されておらず実装依存
  • RESTEasyの場合、エラー時にはHTTPレスポンスにvalidation-exception : trueを含み、独自フォーマットのメッセージを返す
  • メッセージフォーマットのカスタマイズには org.jboss.resteasy.api.validation.ResteasyViolationExceptionMapper の継承クラスを作る
  • 参考 RESTEasyドキュメント Chapter 55. Validation

JSF2.xでValidationグループを設定する

BeanValidation1.0では、@NotNullなどの各検証アノテーションにgroup属性を設定することができます。これは、同じドメインオブジェクトに対して、検証のルールのパターンが複数ある場合に有効です。

例えば、以下のような画面を想定してみます。

f:id:n_agetsuma:20130123205334p:plain

本の登録では、ISBNコードとタイトルの両方の入力を必須とします。検索の場合は、どちらか一方が指定されていれば良いこととします。この入力値がバインドされるドメインオブジェクトは両方とも本を示すBookクラスです。

/** 検索の場合にも両方とも必須入力となるケース */
public class Book {

    @NotNull
    private String isbn;

    @NotNull
    private String title;

   // getterとsetterは省略
}

上記のように、何もグループを指定せずに@NotNullをフィールドに付与すると、登録処理時にも検索処理時にも入力検証が動作してしまい、検証ルールを分けることができません。このような場合はValidationグループを使うと便利です。

/** 登録時のみ@NotNullを検証する */
public class Book {

    @NotNull(groups=BookEntryGroup.class)
    private String isbn;

    @NotNull(groups=BookEntryGroup.class)
    private String title;

   // getterとsetterは省略
}

/** グループを示すクラスは空のインタフェース */
public interface BookEntryGroup {}

設定したグループで検証を指示するには、JSFタグの<f:validateBean>を使います*1
validationGroup属性には、検証対象のグループを示すインタフェースを指定します。検証グループを明示的に指定することで、@NotNullにgroups属性と一致する検証のみが実行されます。groups属性が何も設定されていない場合は暗黙的にjavax.validation.groups.Defaultがグループとして設定されています。
このため、登録のXHTML部分には<f:validateBean>を設定し、検索の部分には設定しないことで検証グループを分けることができます。

<!-- ISBNコードの入力(登録時) -->
<h:inputText id="isbn" value="#{bookController.book.isbn}" label="ISBNコード">
    <f:validateBean validationGroups="validation.groups.BookEntryGroup" />
</h:inputText>

<!-- タイトルの入力(登録時) -->
<h:inputText id="title" value="#{bookController.book.title}" label="タイトル">
    <f:validateBean validationGroups="validation.groups.BookEntryGroup" />
</h:inputText>
余談: SpringMVCの場合だともっと便利

JSF2.0の場合、XHTML側でグループ指定を行いますが、SpringMVCではコントローラクラスにアノテーションで実装できます。入力検証の内容がXHTMLとサーバ側のバッキングBeanに散らばると、正直わかりにくいと思う。JSFでもアノテーションでできるようになると嬉しいです。

@RequestMapping("/entry")
public String entryBook( @Validated(BookEntry.class) Book book) { ... }

*1:参考 : Java Server Faces Specification Version2.1 10.4.1.4 <f:validateBean>

BeanValidationで日本語メッセージを出力する

BeanValidation1.0の参照実装であるHibernate Validatorのデフォルトメッセージは英語です。

例えば@NotNullでは「may not be null」、@AssertTrueでは「must be true」といったメッセージが出力されます。通常、JSF2.0を組み合わせて使うときには『"名前"が入力されていません』のように、日本語でかつ入力箇所をメッセージとして出力したいかと思います。

具体例を紹介すると、以下のようなコードを書いた場合のメッセージ表示についてです。

こんな入力フォーム(facelets/XHTML)を作って

<h:inputText id="isbn" label="ISBNコード" value="#{controller.book.isbn}" />
<br/>
<h:message for="isbn" errorStyle="color:red"/>

こんな風にBean Validationを使うと

@Named
public class Book {
    @Id @NotBlank private String isbn;

デフォルトではメッセージも英語で、入力コンポーネント名も出力されません。
f:id:n_agetsuma:20121017215133p:plain


ここでは、日本語メッセージと入力コンポーネントの表示方法についてまとめます。

1. Bean Validationのメッセージファイルを作る

Bean Validationの日本語メッセージは参照実装であるHibernate Validator4.3.1にはバンドルされていないため、ユーザにて作成します。

ValidationMessesages_jp_JP.properties

javax.faces.validator.BeanValidator.MESSAGE={1} {0}
org.hibernate.validator.constraints.NotBlank.message=は必須入力です。

まずファイル名ですが、この名前はHibernate Validatorで決められているデフォルト名*1です。
また、格納フォルダのデフォルトではクラスパスのルートと決められています。そのため、自分は以下のようにsrc/main/resourcesに置いてみました。

f:id:n_agetsuma:20121017221744p:plain

次に内容についてです。
1行目の「javax.faces.validator.BeanValidator.MESSAGE={1} {0}」ですが、JSF2.0がライフサイクルの中でBeanValidationを実行した時、このキーのメッセージをFacesContextに登録しているようです(mojjaraのコード確認してないので、少し自信なし)。{0}にはBeanValidationのメッセージが代入されます。{1}には入力コンポーネントのlabel属性の設定値が代入されます。

この設定については、JSFの仕様書*2においても紹介されています。

このキーのメッセージはデフォルトでは「javax.faces.validator.BeanValidator.MESSAGE={0}」と定義されています。このため、最初に紹介したエラーメッセージ画像では、入力コンポーネントのlabel属性が表示されていません。

2行目は実際に画面に出力したい日本語のメッセージです。上記のようにキー値をBeanValidationのデフォルトと合わせておくと、アノテーション側でキーを明示的に指定せずに済みます。

例えば、以下のメッセージプロパティとした場合

notblank=は必須入力です。

@NotBlankのmessage属性を定義する必要があります。

@Id @NotBlank(message="{notblank}") private String isbn;

HibernateValidator4.3.1の場合、キー値の命名規則はアノテーションFQDN+.messageとなっています。


2. faces-config.xmlに登録する

作成したファイルをJSFの設定として登録します。

BeanValidationの設定としては、classpathのルートにValidationMessages_XX.xmlを置くと自動的に読み込んでくれますが、JSFとしての設定をしないと入力コンポーネントが表示されずに「は必須です」としか出力されません。

faces-config.xmlの追記を忘れた場合
f:id:n_agetsuma:20121017225839p:plain

faces-config.xmlには以下の設定を追記します。

<application>
  <message-bundle>ValidationMessages</message-bundle>
</application>

これで設定は終わりです。
設定がうまくいっていれば、以下のように入力コンポーネント名と日本語メッセージが出力されるようになると思います。

f:id:n_agetsuma:20121017230120p:plain

(余談:日本語がなくても、実は他の組み込み言語はたくさん入っている)
Hibernate Validator4.3.1のjarに含まれるValidationMessages_xx.propertiesを確認すると、チェコ語(cs)、ドイツ語(de)、スペイン語(es)、アジア圏では中国語(zh_CN)など、計10種類のプロパティファイルが含まれていましたが、日本語(ja_JP)はありませんでした。やはり提案やパッチ提供などの貢献が少ないと、対応が後回しにされてしまうのかな。

*1:参考1 Hibernate Validator Reference Guide 2.2.4. Message interpolation http://docs.jboss.org/hibernate/validator/4.3/reference/en-US/html_single/

*2:参考2 JavaServer Faces Specification Version2.1 2 3.5.6.3 Localization of Bean Validation Messages

@NotNull/@NotEmpty/@NotBlankの違い

JavaEE6から新しい仕様BeanValidation(JSR303)が導入されています。

BeanValidationではアノテーションでユーザ入力チェックを定義することができます。Struts1.xではvalidation.xmlの記述量が多く、度重なるタイプミスとランタイムエラーに苦しめられてきましたが、アノテーションのタイプミスはコンパイル時にチェックされるので、苦しみが軽減されています。

JSF2.0と組み合わせて使用するとき、「必須入力チェック」について同じような意味を持つアノテーションがいくつかあったので、違いを以下にまとめておきます。

以下のようなPersonクラスを定義し、各アノテーションの挙動の違いを確認します。

import javax.validation.constraints.NotNull;
import org.hibernate.validator.constraints.NotBlank;
import org.hibernate.validator.constraints.NotEmpty;

public class Person {
	@NotNull private String firstName;
	@NotEmpty private String middleName;
	@NotBlank private String lastName;
        // getterとsetterは省略
}

このPersonクラスに対して、以下のようなユニットテストを書いてみます。
このテストコードの結果は興味深いことに"グリーン(成功)"です。

public class PersonValidatorTest {
	private static Validator validator;
	
	@BeforeClass
	public static void init() {
		ValidatorFactory factory 
                    = Validation.buildDefaultValidatorFactory();
		validator = factory.getValidator();
	}
	
	@Test
	public void nameIsRequired() {
		Person person = new Person();
		person.setFirstName("");   // @NotNullは空文字エラーにする?
		person.setMiddleName(" "); // @NotEmptyは空白のみをエラーにする?
		person.setLastName(" ");   // @NotBlankは空白のみをエラーにする?
		
		Set<ConstraintViolation<Person>> constraintValidation 
                    = validator.validate(person);

                 // エラーは1件だけ(@NotBlankの部分)
		assertThat(constraintValidation.size(), is(1));
	}
}


挙動をまとめると、以下のようになります。×はチェックエラー、○は許可を表します。

Null ""(空文字) 空白のみ
@NotNull ×
@NotEmpty × ×
@NotBlank × × ×


JSF2.0と組み合わせて使う場合に注意したいのは、「必須入力チェック」に対してJavaEE標準で用意されている@NotNullを使うと、デフォルトではフォームに何も入力されていない場合(空文字となる)、Validationをパスしてしまうことです。

以下の設定をweb.xmlに加えると、@NotNullでも必須入力チェックが可能です。
フォームに何も入力されていない場合、空文字ではなくnullと見なされます。

<context-param>
  <param-name>
    javax.faces.INTERPRET_EMPTY_STRING_SUBMITTED_VALUES_AS_NULL
  </param-name>
  <param-value>true</param-value>
</context-param>

HibernateValidatorの独自実装である@NotBlankを使わなくて済むので便利な設定です。