Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その22 )( 気になった点を修正2 )
概要
Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その21 )( 検索画面 ( Spring Data JPA 版 ) 作成 ) の続きです。
- 今回の手順で確認できるのは以下の内容です。
- 登録画面で既に code に入力された文字列をキーに持つデータが登録されている場合にはエラーにします。
ソフトウェア一覧
参考にしたサイト
-
Java, Spring: creating error messages in Spring - reject() vs rejectValue()
http://krangsquared.blogspot.jp/2013/04/java-spring-creating-error-messages-in.html -
thymeleaf - Tutorial: Thymeleaf + Spring - 8.3 Global errors
http://www.thymeleaf.org/doc/tutorials/2.1/thymeleafspring.html#global-errors- global errors を表示する方法を参照しました。
Java Stream
http://www.ne.jp/asahi/hishidama/home/tech/java/stream.html- Stream API で実装する際に参考にしました。
きしだのはてな - Java8のStreamを使いこなす
http://d.hatena.ne.jp/nowokay/20130504- Stream API で実装する際に参考にしました。
手順
登録画面で既に code に入力された文字列をキーに持つデータが登録されている場合にはエラーにする
IntelliJ IDEA 上で 1.0.x-errorduplicate ブランチを作成します。
登録画面上部に共通エラーメッセージ表示エリアを追加します。src/main/resources/templates/country の input.html を リンク先の内容 に変更します。
エラーメッセージを追加します。src/main/resources の下の messages_ja_JP.properties を リンク先の内容 に変更します。
src/main/java/ksbysample/webapp/basic/web の下の CountryController.java を リンク先の内容 に変更します。
動作確認します。bootRun タスクを実行して Tomcat を起動します。
http://localhost:8080/country/input にアクセスして登録画面を表示し、以下の画像の値を入力して「確認」ボタンをクリックします。
画面上部の共通エラーメッセージ表示エリアにエラーメッセージが表示されます。Run View で Ctrl+F2 を押して Tomcat を停止します。
テストクラスを作成します。まずは GlobalErrors をチェックするための ResultMatchers クラスを作成したいので、既存の FieldErrorsMatchers クラスをリファクタリングで ErrorsResultMatchers クラスにリネームします ( クラス名に "Result" が抜けていたので修正します ) 。src/test/java/ksbysample/webapp/basic/test の下の FieldErrorsMatchers.java を選択後、コンテキストメニューを表示して「Refactor」-「Rename...」を選択します。
「Rename」ダイアログが表示されますので、クラス名を "ErrorsResultMatchers" に変更した後「Refactor」ボタンをクリックします。
次にメソッド名を
public static ErrorsResultMatchers fieldErrors() { ... }
→public static ErrorsResultMatchers errors() { ... }
へ変更します。メソッド名のfieldErrors
にカーソルを移動後、Shift+F6 を2回押下します。「Rename」ダイアログが表示されますので、メソッド名を "errors" に変更した後「Refactor」ボタンをクリックします。ErrorsResultMatchers クラスに GlobalErrors をチェックするためのメソッドを追加します。src/test/java/ksbysample/webapp/basic/test の下の ErrorsResultMatchers.java を リンク先の内容 に変更します。
テストデータを用意します。src/test/resources/ksbysample/webapp/basic/web の下に countryForm_duplicate.yaml を新規作成します。作成後、リンク先の内容 に変更します。
テストメソッドを追加します。src/test/java/ksbysample/webapp/basic/web の下の CountryControllerTest.java を リンク先の内容 に変更します。
「Run 'Tests in 'ksbysample...' with Coverage」を実行して、テストが全て成功することを確認します。
commit の前に build タスクを実行し、BUILD SUCCESSFUL が表示されることを確認します。
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
初版発行。