かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その22 )( 気になった点を修正2 )

概要

Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その21 )( 検索画面 ( Spring Data JPA 版 ) 作成 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • 登録画面で既に code に入力された文字列をキーに持つデータが登録されている場合にはエラーにします。

ソフトウェア一覧

参考にしたサイト

  1. Java, Spring: creating error messages in Spring - reject() vs rejectValue()
    http://krangsquared.blogspot.jp/2013/04/java-spring-creating-error-messages-in.html

    • field のエラーを生成するのは rejectValue メソッドで、global errors を生成するのは reject メソッドであるという記述を参照しました。
  2. thymeleaf - Tutorial: Thymeleaf + Spring - 8.3 Global errors
    http://www.thymeleaf.org/doc/tutorials/2.1/thymeleafspring.html#global-errors

    • global errors を表示する方法を参照しました。
  3. Java Stream
    http://www.ne.jp/asahi/hishidama/home/tech/java/stream.html

    • Stream API で実装する際に参考にしました。
  4. きしだのはてな - Java8のStreamを使いこなす
    http://d.hatena.ne.jp/nowokay/20130504

    • Stream API で実装する際に参考にしました。

手順

登録画面で既に code に入力された文字列をキーに持つデータが登録されている場合にはエラーにする

  1. IntelliJ IDEA 上で 1.0.x-errorduplicate ブランチを作成します。

  2. 登録画面上部に共通エラーメッセージ表示エリアを追加します。src/main/resources/templates/country の input.html を リンク先の内容 に変更します。

  3. エラーメッセージを追加します。src/main/resources の下の messages_ja_JP.properties を リンク先の内容 に変更します。

  4. src/main/java/ksbysample/webapp/basic/web の下の CountryController.javaリンク先の内容 に変更します。

  5. 動作確認します。bootRun タスクを実行して Tomcat を起動します。

  6. http://localhost:8080/country/input にアクセスして登録画面を表示し、以下の画像の値を入力して「確認」ボタンをクリックします。

    f:id:ksby:20150323014220p:plain

  7. 画面上部の共通エラーメッセージ表示エリアにエラーメッセージが表示されます。Run View で Ctrl+F2 を押して Tomcat を停止します。

    f:id:ksby:20150323014745p:plain

  8. テストクラスを作成します。まずは GlobalErrors をチェックするための ResultMatchers クラスを作成したいので、既存の FieldErrorsMatchers クラスをリファクタリングで ErrorsResultMatchers クラスにリネームします ( クラス名に "Result" が抜けていたので修正します ) 。src/test/java/ksbysample/webapp/basic/test の下の FieldErrorsMatchers.java を選択後、コンテキストメニューを表示して「Refactor」-「Rename...」を選択します。

    f:id:ksby:20150324053528p:plain

  9. 「Rename」ダイアログが表示されますので、クラス名を "ErrorsResultMatchers" に変更した後「Refactor」ボタンをクリックします。

    f:id:ksby:20150324054039p:plain

  10. 次にメソッド名を public static ErrorsResultMatchers fieldErrors() { ... }public static ErrorsResultMatchers errors() { ... } へ変更します。メソッド名の fieldErrors にカーソルを移動後、Shift+F6 を2回押下します。「Rename」ダイアログが表示されますので、メソッド名を "errors" に変更した後「Refactor」ボタンをクリックします。

    f:id:ksby:20150324055146p:plain

  11. ErrorsResultMatchers クラスに GlobalErrors をチェックするためのメソッドを追加します。src/test/java/ksbysample/webapp/basic/test の下の ErrorsResultMatchers.javaリンク先の内容 に変更します。

  12. テストデータを用意します。src/test/resources/ksbysample/webapp/basic/web の下に countryForm_duplicate.yaml を新規作成します。作成後、リンク先の内容 に変更します。

  13. テストメソッドを追加します。src/test/java/ksbysample/webapp/basic/web の下の CountryControllerTest.javaリンク先の内容 に変更します。

  14. 「Run 'Tests in 'ksbysample...' with Coverage」を実行して、テストが全て成功することを確認します。

  15. commit の前に build タスクを実行し、BUILD SUCCESSFUL が表示されることを確認します。

  16. commit、GitHub へ Push、1.0.x-errorduplicate -> 1.0.x へ Pull Request、1.0.x でマージ、1.0.x-errorduplicate ブランチを削除、をします。

ソースコード

input.html

    <div class="container">

        <div class="row">
            <div class="col-sm-push-2 col-sm-10"><h1>Countryデータ登録</h1></div>
        </div>

        <div class="row">
            <div class="col-xs-12">
                <form id="countryForm" method="post" action="/country/confirm" th:action="@{/country/confirm}" th:object="${countryForm}" class="form-horizontal cst-form-inputform">
                    <div class="alert alert-danger" th:if="${#fields.hasGlobalErrors()}">
                        <p th:each="err : ${#fields.globalErrors()}" th:text="${err}">共通エラーメッセージ表示エリア</p>
                    </div>

  • <form id="countryForm" ... の下に <div class="alert alert-danger" th:if="${#fields.hasGlobalErrors()}"> ... </div> を追加します。
  • 最初以下のように form タグの外に書いたのですが NullPointerException が発生しました。th:if="${#fields.hasGlobalErrors()}"th:object="${countryForm}" が付加された form タグの中に書かないとエラーになるようです。
    <div class="container">

        <div class="row">
            <div class="col-sm-push-2 col-sm-10"><h1>Countryデータ登録</h1></div>
        </div>

        <div class="alert alert-danger" th:if="${#fields.hasGlobalErrors()}">
            <p th:each="err : ${#fields.globalErrors()}" th:text="${err}">共通エラーメッセージ表示エリア</p>
        </div>

        <div class="row">
            <div class="col-xs-12">
                <form id="countryForm" method="post" action="/country/confirm" th:action="@{/country/confirm}" th:object="${countryForm}" class="form-horizontal cst-form-inputform">

messages_ja_JP.properties

AbstractUserDetailsAuthenticationProvider.locked=入力された ID はロックされています
AbstractUserDetailsAuthenticationProvider.disabled=入力された ID は使用できません
AbstractUserDetailsAuthenticationProvider.expired=入力された ID の有効期限が切れています
AbstractUserDetailsAuthenticationProvider.credentialsExpired=入力された ID のパスワードの有効期限が切れています
AbstractUserDetailsAuthenticationProvider.badCredentials=入力された ID あるいはパスワードが正しくありません

typeMismatch.java.math.BigDecimal=数値を入力して下さい。
typeMismatch.java.lang.Long=数値を入力して下さい。

common.invalidRequestException.message = 本来発生し得ない Validation エラーが発生しました。不正にアクセスされた可能性があります。{0}

countryForm.global.duplicate = Code に入力されたキーのデータは既に登録されています。
countryForm.code2.equalCode = Code2 には Code と異なる文字列を入力して下さい。
countryForm.continent.notAsia = Name に "Japan" あるいは "日本" を入力している場合、Continent は "Asia" を選択して下さい。
countryForm.region.notAsiaPattern = Continent に "Asia" を選択している場合、Region には "Eastern Asia", "Middle East", "Southeast Asia", "Southern and Central Asia" のいずれかの文字列を入力して下さい。
  • countryForm.global.duplicate を追加します。

CountryController.java

package ksbysample.webapp.basic.web;

import ksbysample.webapp.basic.config.Constant;
import ksbysample.webapp.basic.domain.Country;
import ksbysample.webapp.basic.exception.InvalidRequestException;
import ksbysample.webapp.basic.service.CountryRepository;
import ksbysample.webapp.basic.service.CountryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Locale;

@Controller
@RequestMapping("/country")
public class CountryController {

    @Autowired
    private Constant constant;

    @Autowired
    private CountryFormValidator countryFormValidator;

    @Autowired
    private CountryService countryService;

    @Autowired
    private CountryRepository countryRepository;

    @Autowired
    private MessageSource messageSource;

    @InitBinder
    public void initBinder(WebDataBinder binder) {
        binder.addValidators(countryFormValidator);
    }

    @RequestMapping("/input")
    public String input(CountryForm countryForm
            , Model model) {

        model.addAttribute("continentList", constant.CONTINENT_LIST);
        return "country/input";
    }

    @RequestMapping("/input/back")
    public String inputBack(CountryForm countryForm
            , RedirectAttributes redirectAttributes) {

        redirectAttributes.addFlashAttribute("countryForm", countryForm);
        return "redirect:/country/input";
    }

    @RequestMapping("/confirm")
    public String confirm(@Validated CountryForm countryForm
            , BindingResult bindingResult
            , Model model) {

        if (bindingResult.hasErrors()) {
            model.addAttribute("continentList", constant.CONTINENT_LIST);
            return "country/input";
        }

        // code に入力された文字列をキーに持つデータが登録されている場合にはエラーにする
        Country country = countryRepository.findOne(countryForm.getCode());
        if (country != null) {
            bindingResult.reject("countryForm.global.duplicate");
            model.addAttribute("continentList", constant.CONTINENT_LIST);
            return "country/input";
        }

        return "country/confirm";
    }

    @RequestMapping("/update")
    public String update(@Validated CountryForm countryForm
            , BindingResult bindingResult
            , Locale locale
            , Model model
            , HttpServletResponse response) throws IOException, InvalidRequestException {

        if (bindingResult.hasErrors()) {
            throw new InvalidRequestException(messageSource.getMessage("common.invalidRequestException.message", new Object[]{bindingResult.toString()}, locale));
        }

        countryService.save(countryForm);
        return "redirect:/country/complete";
    }

    @RequestMapping("/complete")
    public String complete() {
        return "country/complete";
    }

}
  • private CountryRepository countryRepository; を追加します。
  • confirm メソッド内に code に入力された文字列をキーに持つデータが登録されているかチェックする処理を追加します。

ErrorsResultMatchers.java

package ksbysample.webapp.basic.test;

import org.springframework.test.web.servlet.ResultMatcher;
import org.springframework.test.web.servlet.result.ModelResultMatchers;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.web.servlet.ModelAndView;

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

import static org.hamcrest.Matchers.hasItem;
import static org.junit.Assert.assertThat;
import static org.springframework.test.util.AssertionErrors.assertTrue;

public class ErrorsResultMatchers extends ModelResultMatchers {

    private ErrorsResultMatchers() {
    }

    public static ErrorsResultMatchers errors() {
        return new ErrorsResultMatchers();
    }

    public ResultMatcher hasGlobalError(String name, String error) {
        return mvcResult -> {
            BindingResult bindingResult = getBindingResult(mvcResult.getModelAndView(), name);
            List<ObjectError> objectErrorList = bindingResult.getGlobalErrors();
            List<String> objectErrorListAsCode
                    = objectErrorList.stream().map(ObjectError::getCode).collect(Collectors.toList());
            assertThat("Expected error code '" + error + "'", objectErrorListAsCode, hasItem(error));
        };
    }

    public ResultMatcher hasFieldError(String name, String fieldName, String error) {
        return mvcResult -> {
            BindingResult bindingResult = getBindingResult(mvcResult.getModelAndView(), name);
            List<FieldError> fieldErrorList = bindingResult.getFieldErrors(fieldName);
            List<String> fieldErrorListAsCode
                    = fieldErrorList.stream().map(FieldError::getCode).collect(Collectors.toList());
            assertThat("Expected error code '" + error + "'", fieldErrorListAsCode, hasItem(error));
        };
    }

    private BindingResult getBindingResult(ModelAndView mav, String name) {
        BindingResult bindingResult = (BindingResult) mav.getModel().get(BindingResult.MODEL_KEY_PREFIX + name);
        assertTrue("No BindingResult for attribute: " + name, bindingResult != null);
        return bindingResult;
    }

}
  • hasGlobalError メソッドを追加します。List<String> objectErrorListAsCode にデータをセットする処理で Stream API を使用してみました。
  • hasFieldError メソッド内の List<String> fieldErrorListAsCode のデータをセットする処理も Stream API に変更します。
  • Stream API はサンプルを見ている感じでは便利そうです。どんどん書いて早めに慣れていきたいですね。

countryForm_duplicate.yaml

!!ksbysample.webapp.basic.web.CountryForm
code: JPN
name: Japan
continent: Asia
region: Eastern Asia
surfaceArea: 1.00
population: 2
localName: Nippon
governmentForm: test
code2: JP

CountryControllerTest.java

        public static class 入力画面のテスト {

            @RunWith(SpringJUnit4ClassRunner.class)
            @SpringApplicationConfiguration(classes = Application.class)
            @WebAppConfiguration
            public static class DBを使用しない処理のテスト {

                @Rule
                @Autowired
                public SecurityMockMvcResource secmvc;

                // テストデータ
                private CountryForm countryFormEmpty
                        = (CountryForm) new Yaml().load(getClass().getResourceAsStream("countryForm_empty.yaml"));
                private CountryForm countryFormSizeDigitsCheck
                        = (CountryForm) new Yaml().load(getClass().getResourceAsStream("countryForm_sizedigitscheck.yaml"));
                private CountryForm countryFormValidateError1
                        = (CountryForm) new Yaml().load(getClass().getResourceAsStream("countryForm_validateerror1.yaml"));
                private CountryForm countryFormValidateError2
                        = (CountryForm) new Yaml().load(getClass().getResourceAsStream("countryForm_validateerror2.yaml"));
                private CountryForm countryFormDuplicate
                        = (CountryForm) new Yaml().load(getClass().getResourceAsStream("countryForm_duplicate.yaml"));

                @Test
                public void 入力画面を表示する() throws Exception {
                    // 認証時は登録画面(入力)が表示されることを確認する
                    secmvc.auth.perform(get("/country/input"))
                            .andExpect(status().isOk())
                            .andExpect(content().contentType("text/html;charset=UTF-8"))
                            .andExpect(view().name("country/input"));
                }

                @Test
                public void データ未入力時には入力チェックエラーが発生する() throws Exception {
                    // NotBlank/NotNullの入力チェックのテスト
                    MvcResult result = secmvc.auth.perform(TestHelper.postForm("/country/confirm", this.countryFormEmpty)
                                    .with(csrf())
                    )
                            .andExpect(status().isOk())
                            .andExpect(content().contentType("text/html;charset=UTF-8"))
                            .andExpect(view().name("country/input"))
                            .andExpect(xpath("/html/head/title").string("Countryデータ登録画面(入力)"))
                            .andExpect(model().hasErrors())
                            .andExpect(model().errorCount(11))
                            .andExpect(errors().hasFieldError("countryForm", "code", "NotBlank"))
                            .andExpect(errors().hasFieldError("countryForm", "name", "NotBlank"))
                            .andExpect(errors().hasFieldError("countryForm", "continent", "NotBlank"))
                            .andExpect(errors().hasFieldError("countryForm", "continent", "Pattern"))
                            .andExpect(errors().hasFieldError("countryForm", "region", "NotBlank"))
                            .andExpect(errors().hasFieldError("countryForm", "surfaceArea", "NotNull"))
                            .andExpect(errors().hasFieldError("countryForm", "population", "NotNull"))
                            .andExpect(errors().hasFieldError("countryForm", "localName", "NotBlank"))
                            .andExpect(errors().hasFieldError("countryForm", "governmentForm", "NotBlank"))
                            .andExpect(errors().hasFieldError("countryForm", "code2", "NotBlank"))
                            .andExpect(errors().hasFieldError("countryForm", "code2", "countryForm.code2.equalCode"))
                            .andReturn();
//                    // 発生しているfield errorを全て出力するには以下のようにする
//                    ModelAndView mav = result.getModelAndView();
//                    BindingResult br = (BindingResult) mav.getModel().get(BindingResult.MODEL_KEY_PREFIX + "countryForm");
//                    List<FieldError> listFE = br.getFieldErrors();
//                    for (FieldError fe : listFE) {
//                        System.out.println("★★★ " + fe.getField() + " : " + fe.getCode() + " : " + fe.getDefaultMessage());
//                    }
                }

                @Test
                public void 文字数桁数オーバー時には入力チェックエラーが発生する() throws Exception {
                    // Size/Pattern/Digitsの入力チェックのテスト
                    secmvc.auth.perform(TestHelper.postForm("/country/confirm", this.countryFormSizeDigitsCheck)
                                    .with(csrf())
                    )
                            .andExpect(status().isOk())
                            .andExpect(model().hasErrors())
                            .andExpect(model().errorCount(9))
                            .andExpect(errors().hasFieldError("countryForm", "code", "Size"))
                            .andExpect(errors().hasFieldError("countryForm", "name", "Size"))
                            .andExpect(errors().hasFieldError("countryForm", "continent", "Pattern"))
                            .andExpect(errors().hasFieldError("countryForm", "region", "Size"))
                            .andExpect(errors().hasFieldError("countryForm", "surfaceArea", "Digits"))
                            .andExpect(errors().hasFieldError("countryForm", "population", "Digits"))
                            .andExpect(errors().hasFieldError("countryForm", "localName", "Size"))
                            .andExpect(errors().hasFieldError("countryForm", "governmentForm", "Size"))
                            .andExpect(errors().hasFieldError("countryForm", "code2", "Size"))
                            .andExpect(content().contentType("text/html;charset=UTF-8"))
                            .andExpect(view().name("country/input"))
                            .andExpect(xpath("/html/head/title").string("Countryデータ登録画面(入力)"));
                }

                @Test
                public void countryForm_continent_notAsiaの入力チェックエラーのテスト() throws Exception {
                    // countryForm.continent.notAsia の入力チェックのテスト
                    secmvc.auth.perform(TestHelper.postForm("/country/confirm", this.countryFormValidateError1)
                                    .with(csrf())
                    )
                            .andExpect(status().isOk())
                            .andExpect(model().hasErrors())
                            .andExpect(model().errorCount(1))
                            .andExpect(errors().hasFieldError("countryForm", "continent", "countryForm.continent.notAsia"))
                            .andExpect(content().contentType("text/html;charset=UTF-8"))
                            .andExpect(view().name("country/input"))
                            .andExpect(xpath("/html/head/title").string("Countryデータ登録画面(入力)"));
                }

                @Test
                public void countryForm_region_notAsiaPatternの入力チェックエラーのテスト() throws Exception {
                    // countryForm.region.notAsiaPattern の入力チェックのテスト
                    secmvc.auth.perform(TestHelper.postForm("/country/confirm", this.countryFormValidateError2)
                                    .with(csrf())
                    )
                            .andExpect(status().isOk())
                            .andExpect(model().hasErrors())
                            .andExpect(model().errorCount(1))
                            .andExpect(errors().hasFieldError("countryForm", "region", "countryForm.region.notAsiaPattern"))
                            .andExpect(content().contentType("text/html;charset=UTF-8"))
                            .andExpect(view().name("country/input"))
                            .andExpect(xpath("/html/head/title").string("Countryデータ登録画面(入力)"));
                }

                @Test
                public void countryForm_global_duplicateの入力チェックエラーのテスト() throws Exception {
                    // countryForm.global.duplicate の入力チェックのテスト
                    secmvc.auth.perform(TestHelper.postForm("/country/confirm", this.countryFormDuplicate)
                                    .with(csrf())
                    )
                            .andExpect(status().isOk())
                            .andExpect(model().hasErrors())
                            .andExpect(model().errorCount(1))
                            .andExpect(errors().hasGlobalError("countryForm", "countryForm.global.duplicate"))
                            .andExpect(content().contentType("text/html;charset=UTF-8"))
                            .andExpect(view().name("country/input"))
                            .andExpect(xpath("/html/head/title").string("Countryデータ登録画面(入力)"));
                }

            }

        }
  • private CountryForm countryFormDuplicate = (CountryForm) new Yaml().load(getClass().getResourceAsStream("countryForm_duplicate.yaml")); を追加します。
  • countryForm_global_duplicateの入力チェックエラーのテスト メソッドを追加します。

履歴

2015/03/25
初版発行。