はじめに

こんにちは。西日本テクノロジー&イノベーション室の齊藤です。

私の所属する西日本テクノロジー&イノベーション室では、2020年9月末に「SPA + REST API構成のサービス開発リファレンス(以下サービス開発リファレンス)」を公開しました。こちらは、シングルページアプリケーション(以下SPA)とREST APIから構成されるWebアプリケーションを開発する際に活用して頂けるコンテンツになっています。

前回のブログでは、Todo一覧取得のREST APIを作り、タスク管理をするための簡易的な画面(Todo画面)とつなぎこむ実装をご紹介しました。
今回はタスク登録処理の実装を行いながら、バリデーションのやり方を解説します。

  1. サービス開発リファレンスを使ってWebアプリケーションを作成してみよう<導入編>
  2. サービス開発リファレンスを使って1画面作成してみよう
  3. サービス開発リファレンスを使ってREST APIを1つ作成してみよう
  4. サービス開発リファレンスを使ってバリデーションしてみよう(←今回の記事)

    事前準備

    1~3までの記事でご紹介した作業の完成状態をベースに作成しますので、こちらの作業がまだの方は実施してみてください。

    バリデーションの方針

    フロントエンドのUIで入力された値のバリデーションは、フロントエンド・バックエンドの両方で行います。

    バリデーションをフロントエンドで行う理由は、バリデーションのためにHTTP通信する必要がなく、バリデーション完了後の正しい状態となっている値だけを送信できるためです。 また、バリデーションがHTTPリクエストを伴わないためバリデーションの結果を速やかにユーザーへ通知できます。

    バックエンドでもバリデーションを行う理由は、不正なリクエストからアプリケーションを守るためです。 前述のようにフロントエンドでバリデーションを行うため、フロントエンドのUIで入力された値であれば必ず正しい状態でバックエンドへ送信されます。 しかし、ブラウザの開発者コンソールで不正な値を送信するJavaScriptプログラムを実行したり、curlやその他のHTTPクライアントを用いて不正な値を直接バックエンドへ送信できま。 そういった不正なリクエストを阻止するために、バックエンドでも必ずバリデーションは行います。

    この場合、正しくフロントエンドのUIを使用しているならバックエンドではバリデーションエラーに成り得ません。そのため、バックエンドのバリデーションではユーザーフレンドリーなエラーメッセージを返す必要はありません。

    ただし、DBの値で存在チェックを行うといった、フロントエンドからは参照できないリソースが必要となるバリデーションがあります。
    この場合はフロントエンドではバリデーションができないので、バックエンドでのバリデーションのみを行います。

    バックエンド

    まずはバリデーションの機能を実装するために、Todoを登録するREST APIを作成します。

    Todo登録機能の実装

    最新のTodoIdを取得するためのSQLを作成します。

    backend/src/main/resources/com/example/infrastructure/persistence/entity/TodoEntity.sql

    FIND_MAX_TODO_ID =
    SELECT
      MAX(TODO_ID) AS TODO_ID
    FROM
      TODO

    次に、アクションクラスにTodoを登録するメソッドを以下のように追加します。

    backend/src/main/java/com/example/presentation/restapi/todo/TodosAction.java

        @POST
        @Consumes(MediaType.APPLICATION_JSON)
        @Produces(MediaType.APPLICATION_JSON)
        public TodoResponse post(PostRequest requestBody) {
            TodoEntity entity = UniversalDao.findBySqlFile(TodoEntity.class, "FIND_MAX_TODO_ID", new Object[0]);
            Long newTodoId = entity.getTodoId() + 1;
    
            TodoEntity todoEntity = new TodoEntity();
            todoEntity.setTodoId(newTodoId);
            todoEntity.setText(requestBody.text);
            UniversalDao.insert(todoEntity);
    
            return new TodoResponse(newTodoId, requestBody.text);
        }
    
        public static class PostRequest {
            public String text;
        }

    バリデーションの実装

    次にバリデーションを実装します。
    入力値をチェックするため、オブジェクトを受け取った後はValidatorUtilを使用してBeanValidationを実行します。
    フロントエンドから受け取るtextは、空文字を許容しないようにするため javax.validation.constraints.NotBlankアノテーションを付与します。

    ここまで実装すると、TodosActionは以下のようになります。

    backend/src/main/java/com/example/presentation/restapi/todo/TodosAction.java

    package com.example.presentation.restapi.todo;
    
    import com.example.infrastructure.persistence.entity.TodoEntity;
    import nablarch.common.dao.EntityList;
    import nablarch.common.dao.UniversalDao;
    import nablarch.core.repository.di.config.externalize.annotation.SystemRepositoryComponent;
    import nablarch.core.validation.ee.ValidatorUtil;
    
    import javax.validation.constraints.NotBlank;
    import javax.ws.rs.*;
    import javax.ws.rs.core.MediaType;
    import java.util.List;
    import java.util.stream.Collectors;
    
    @SystemRepositoryComponent
    @Path("/todos")
    public class TodosAction {
    
        @GET
        @Produces(MediaType.APPLICATION_JSON)
        public List<TodoResponse> get() {
            EntityList<TodoEntity> todoEntities = UniversalDao.findAll(TodoEntity.class);
    
            return todoEntities.stream()
                    .map(entity -> new TodoResponse(entity.getTodoId(), entity.getText()))
                    .collect(Collectors.toList());
        }
    
        @POST
        @Consumes(MediaType.APPLICATION_JSON)
        @Produces(MediaType.APPLICATION_JSON)
        public TodoResponse post(PostRequest requestBody) {
            ValidatorUtil.validate(requestBody);
    
            TodoEntity entity = UniversalDao.findBySqlFile(TodoEntity.class, "FIND_MAX_TODO_ID", new Object[0]);
            Long newTodoId = entity.getTodoId() + 1;
    
            TodoEntity todoEntity = new TodoEntity();
            todoEntity.setTodoId(newTodoId);
            todoEntity.setText(requestBody.text);
            UniversalDao.insert(todoEntity);
    
            return new TodoResponse(newTodoId, requestBody.text);
        }
    
        public static class TodoResponse {
    
            public final Long id;
    
            public final String text;
    
            public TodoResponse(Long id, String text) {
                this.id = id;
                this.text = text;
            }
        }
    
        public static class PostRequest {
            @NotBlank
            public String text;
        }
    }

    サーバの起動

    ここまで実装できたら、サーバを起動してみましょう。

    以下コマンドでまずデータベースを起動します。

    $ docker-compose up -d

    次のようにdocker-compose psコマンドを実行してStateUpになっていれば起動成功です。

    $ docker-compose ps
    Name                 Command                        State     Ports                     
    -----------------------------------------------------------------------------------------------------------------
    example_postgres_1   docker-entrypoint.sh postgres    Up      0.0.0.0:5432->5432/tcp  
    example_redis_1      docker-entrypoint.sh redis ...   Up      0.0.0.0:6379->6379/tcp

    次に、backendディレクトリで以下コマンドを実行してサーバを起動しましょう。

    $ mvn jetty:run

    動作確認はフロントエンドとあわせて実施します。

    フロントエンド

    Todoを登録するREST APIが作成できましたので、フロントエンドから呼び出すようにします。

    Todo登録機能のつなぎこみ

    Todo登録のREST APIを呼び出すため、BackendServiceに以下を追加します。

    frontend/src/example/backend/BackendService.ts

    const postTodo = async (text: string) => {
      Logger.debug('call service of postTodo');
    
      const response = await restClient.post('/api/todos', {text});
      Logger.debug(response);
      if (response.ok) {
        return;
      }
      throw new Error(`Web API call failed. [ status code: ${response.status} ]`);
    };

    そして、export defaultpostTodoを追加します。

    export default {
      getHelloWorld,
      getTodos,
      postTodo,
    };

    次に、Todo.tsx handleSubmitを以下のように修正してTodoを登録できるようにします。

    frontend/src/example/components/pages/Todo.tsx

      const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        BackendService.postTodo(text)
            .then(() => setTodos([...todos, text]))
      };

    これでSPA側の実装も完了したため、以下コマンドをfrontendディレクトリで実行してみましょう。

    $ npm start

    自動でブラウザが立ち上がり、Top画面が表示されているはずです。
    以下URLのTodo画面を開いて、実際にTodoを登録してみましょう。
    http://localhost:3000/todo

    このように新規追加というタスクが追加されました。

    バリデーションの実装

    では、ここからフロントエンドにバリデーションを実装します。

    まずは、すでに定義されているhandleSubmitpostTodoという名前に変更します。

      const postTodo = (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        BackendService.postTodo(text)
            .then(() => setTodos([...todos, text]));
      };

    次に、バリデーション用のフックであるuseValidationを使用してバリデーションを実装します。 このuseValidationはサービス開発リファレンスで用意されているもので、必要に応じて再利用いただけます。

    const { error, handleSubmit } = useValidation<{text: string}>({
        text: stringField()
            .required('やることは必須です。')
            .maxLength(20, 'やることは20字以内で入力してください。'),
      });

    useValidationでは、型引数により各項目で使用できるバリデーションを制限します。そのため、まずはバリデーション対象になる項目を定義します。 useValidationの型引数に{text: string}を指定し、引数にバリデーションを定義したオブジェクトを生成して渡します。プロパティのキーは型引数に対応し、各項目でどのようなバリデーションを行うかを定義します。

    string型の項目を検証するためのstringFieldsが用意されているので、これを起点にバリデーションを指定していきます。stringFieldsでは、必須入力をチェックするためのrequiredや、最大文字数をチェックするためのmaxLength等が用意されており、ここでのバリデーションはそれらを使用します。

    useValidationの戻り値として、バリデーションエラーが格納されるerrorと、サブミット時に自動で実行するためのhandleSubmitを受け取ります。

    errorのプロパティはuseValidationのプロパティに一対一で対応しています。例えば、textに対するバリデーションでエラーとなった場合、error.textにエラーメッセージが設定されます。

    <input type="text" {...textAttributes} placeholder="やることを入力してください" />
    <ValidationError message={error.text}/>

    次に、各テキストボックスの下にエラーメッセージを表示するように、inputの直後にエラーメッセージを表示するようにします。 ValidationErrorというエラーを表示するためのコンポーネントが用意されていますので、そちらを使用します。

    <form className="form" onSubmit={handleSubmit({ text }, postTodo)}>

    handleSubmitは関数であり、第1引数に入力値と、第2引数にエラーが発生しなかった場合のコールバック関数を渡します。 こちらをformonSubmitに設定します。

    ここまで実装すると、以下のようになります。

    frontend/src/example/components/pages/Todo.tsx

    import React, {useEffect, useState} from 'react';
    import {Logger, stringField, useInput, useValidation} from '../../../framework';
    import { BackendService } from '../../backend';
    import {ValidationError} from '../basics';
    import './Todo.css';
    
    type Todo = {
      id: number
      text: string
    }
    
    const Todo: React.FC = () => {
      const [todos, setTodos] = useState<string[]>([]);
      const [text, textAttributes] = useInput('');
      const { error, handleSubmit } = useValidation<{text: string}>({
        text: stringField()
          .required('やることは必須です。')
          .maxLength(20, 'やることは20字以内で入力してください。'),
      });
    
      useEffect(() => {
        BackendService.getTodos()
          .then(response => {
            Logger.debug(response);
            setTodos(response.map((todo: Todo) => todo.text));
          });
      }, []);
    
      const postTodo = (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        BackendService.postTodo(text)
          .then(() => setTodos([...todos, text]));
      };
    
      return (
        <div className="content">
          <div className="form_field">
            <form className="form" onSubmit={handleSubmit({ text }, postTodo)}>
              <div className="input_field">
                <input type="text" {...textAttributes} placeholder="やることを入力してください" />
                <ValidationError message={error.text}/>
              </div>
              <div className="button_field">
                <button type="submit">追加</button>
              </div>
            </form>
          </div>
          <ul className="list">
            {todos.map((todo, index) =>
              <li className="item" key={index}>
                <div className="todo">
                  <span>{todo}</span>
                </div>
              </li>
            )}
          </ul>
        </div>
      );
    };
    
    export default Todo;

    これでバリデーションの実装が完了したため、画面を開いて、やることに何も入力せずに追加ボタンを押してみましょう。
    バリデーションエラーが発生するはずです。

    また、maxLength20を設定しているため、21文字以上入力して追加ボタンを押してもバリデーションエラーが発生します。

    まとめ

    今回は、サービス開発リファレンスを使ってバリデーションを実装しました。
    次回はSPAにおけるファイルダウンロードをご紹介します。


    本コンテンツはクリエイティブコモンズ(Creative Commons) 4.0 の「表示—継承」に準拠しています。