概要
以下のような処理を非同期(JavaScriptのfetch)で行う登録機能を実装します。
-
単語帳名をフォームに入力し「追加する」ボタンをクリック
-
バリデーション&DB登録処理(Spring Boot側)を実行
-
成功すれば、登録された単語帳名を一覧に即時反映
(エラーがあればエラーメッセージを表示)
※spring securityを導入している前提
■ フロント側
SpringSecurityを導入しているため,POST,DELETE,PUTメソッドの送信にはCSRFトークンが必須になる。CSRFトークンの値はSpring Securityが自動でThymeleafに埋め込んで送信してくれるが、今回はJSでfetchする際にトークンの値を合わせて送信したいので、HTMLに手動で埋め込む。
html
<!-- 登録用フォーム -->
<form th:action="@{/wordbooks/api/regist}" th:object="${wordbookForm}">
<label>単語帳名:<input type="text" th:field="*{wordbookName}"></label>
<input type="hidden" id="csrfToken" th:value="${_csrf.token}" />
<!-- バリデーションエラーメッセージ表示 -->
<ul id="errorMsgList" class="errorMsgHidden"></ul>
<button type="submit" id="registBtn">追加する</button>
</form>
JavaScript
form.addEventListener("submit", async (event) => {
event.preventDefault(); //formでのリクエスト送信をキャンセル
errorMsgList.innerHTML = "";//エラーメッセージをいったん白紙に
//各パラメータの値を取得
const wordbookName = document.getElementById("wordbookName").value.trim();
const csrfToken = document.getElementById("csrfToken").value;
try {
const res = await fetch(`/wordbooks/api/regist`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",//フォームで送る
"X-CSRF-TOKEN": csrfToken//CSRFトークンもちゃんと送る
},
body: `wordbookName=${encodeURIComponent(wordbookName)}`
//単語帳名の入力文字に"?"や"="が含まれていても入力文字として認識されるようエンコードする
});
if (res.ok) {
const wordbook = await res.json();
// ここに単語帳一覧に表示する処理を記述する
} else {
const errorMessages = await res.json();
//ここにエラーメッセージを表示する処理を記述する
}
} catch (error) {
alert("登録に失敗しました");
}
});
■ サーバ側(バリデーションクラス)
SpringのValidatorインタフェースを使い、DBに重複する単語帳名が存在するかチェックする。
@Component
@RequiredArgsConstructor
public class WordbookValidator implements Validator{
private final WordbookService wordbookService;
@Override
public boolean supports(Class<?> clazz) {
return WordbookForm.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
WordbookForm wordbookForm = (WordbookForm) target;
//単語帳名でDB検索
Optional<Wordbook> wordbookOpt = wordbookService.findByWordbookName(wordbookForm.getWordbookName());
//存在した場合はエラー発生
if(wordbookOpt.isPresent()) {
errors.rejectValue("wordbookName", null, "この単語帳名は既に登録されています");
}
}
}
■ サーバ側(コントローラ)
ResponseEntityクラスを使うと、HTTPレスポンスのステータスコード・ヘッダー・ボディを自由にカスタマイズできる。フロント側で更新処理の成功or失敗で挙動を分けたい場合、サーバ側(コントローラ)はステータスコードを明示的に指定してレスポンスを返してあげればよい。
@RestController
@RequiredArgsConstructor
public class WordbookApiController {
private final WordbookService wordbookService;
private final WordbookValidator wordbookValidator;
//自作のバリデータをバインド
@InitBinder("wordbookForm")
public void initBinder(WebDataBinder binder) {
binder.addValidators(wordbookValidator);
}
@PostMapping("/regist")
public ResponseEntity<?> registWordBook(
@AuthenticationPrincipal LoginUserDetails loginUserDetails,
@Validated WordbookForm wordbookForm,
BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
// エラーメッセージのリストを作成
List<String> errorList = bindingResult.getAllErrors().stream()
.map(error -> error.getDefaultMessage())
.collect(Collectors.toList());
// 400 Bad Request でエラーリストを返す
return ResponseEntity.badRequest().body(errorList);
}
//登録処理
Wordbook wordbook = new Wordbook();
wordbook.setUser(loginUserDetails.getUser());
wordbook.setName(wordbookForm.getWordbookName());
WordbookDto dto = wordbookService.save(wordbook);
return ResponseEntity.ok(dto);
}
■ クライアント側(JS)でJSONを取得する
//fetch処理の続き
if (res.ok) {
const wordbook = await res.json();
//一覧リストに追加する関数
addWordbookToList(wordbook.wordbookName);
} else {
//バリデーションエラーがあるとき
errorMsgList.classList.replace("errorMsgHidden","errorMsgVisible");
const errorMessages = await res.json();
for(const msg of errorMessages){
const li = document.createElement("li");
li.textContent = msg;
errorMsgList.append(li);
}
}
} catch (error) {
alert("登録に失敗しました");
}
})