7.1. チュートリアル(Todoアプリケーション REST編)

Caution

本バージョンの内容は既に古くなっています。最新のガイドラインはこちらからご参照ください。

7.1.1. はじめに

7.1.1.1. このチュートリアルで学ぶこと

  • TERASOLUNA Global Frameworkによる基本的なRESTful Webサービスの構築方法

7.1.1.3. 検証環境

このチュートリアルは以下の環境で動作確認している。他の環境で実施する際は本書をベースに適宜読み替えて設定していくこと。

ただし、REST Clientとして、Google Chromeの拡張機能である「Dev HTTP Client」を使用するため、 Web BrowserはGoogle Chromeを使用する必要がある。

種別 名前
OS Windows7 64bit
JVM Java 1.7
IDE Spring Tool Suite Version: 3.4.0.RELEASE, Build Id: 201310051614 (以降「STS」と呼ぶ)
Build Maven 3.0.4
Application Server VMWare vFabric tc Server Developer Edition v2.9 (以降「ts Server」と呼ぶ)
Web Browser Google Chrome 33.0.1750.154 m, Language is English(United States)
REST Client Dev HTTP Client 0.6.9.16

7.1.2. 環境構築

Java, STS, Maven, Google Chromeについては、チュートリアル(Todoアプリケーション)を実施する事でインストール済みの状態である事を前提とする。

7.1.2.1. Dev HTTP Clientのインストール

まず、RESTクライアントしてChromeの拡張機能である「Dev HTTP Client」をインストールする。

Chromeの「Tools」→「Extensions」を選択する。

../_images/install-dev-http-client1.png

「Get more extensions」のリンクを押下する。

../_images/install-dev-http-client2.png

検索フォームに「dev http clinet」を入力して検索する。

../_images/install-dev-http-client3.png

Dev HTTP Clientの「+ FREE」ボタンを押下する。

../_images/install-dev-http-client4.png

「Add」ボタンを押下する。

../_images/install-dev-http-client5.png

Chromeのアプリケーション一覧を開く(ブラウザのアドレスバーに「chrome://apps/」を指定して開く)と、DHC(Dev HTTP Client)が追加されている。

../_images/install-dev-http-client6.png

DHC(Dev HTTP Client)をクリックする。
以下の画面が表示されれば、インストール完了となる。
この画面は、ブラウザのアドレスバーに「chrome-extension://aejoelaoggembcahagimdiliamlcdmfm/HttpClient.html」を入力する事で開く事もできる。
../_images/install-dev-http-client7.png

7.1.2.2. プロジェクト作成

チュートリアル用のプロジェクトを作成する。
ドメイン層はチュートリアル(Todoアプリケーション)で作成したTodoアプリケーションのものを利用するが、今回はプロジェクトの雛形をBlankプロジェクトから生成する。

以下のコマンドを実行し、MyBatis2版のMavenプロジェクトを作成する。

mvn archetype:generate -B^
 -DarchetypeCatalog=http://repo.terasoluna.org/nexus/content/repositories/terasoluna-gfw-releases^
 -DarchetypeGroupId=org.terasoluna.gfw.blank^
 -DarchetypeArtifactId=terasoluna-gfw-web-blank-mybatis2-archetype^
 -DarchetypeVersion=1.0.5.RELEASE^
 -DgroupId=todo^
 -DartifactId=todo-api^
 -Dversion=1.0-SNAPSHOT

コンソール上に以下のようなログが表示されれば、ブランクプロジェクトの作成は成功となる。

C:\workspace>mvn archetype:generate -B^
More?  -DarchetypeCatalog=http://repo.terasoluna.org/nexus/content/repositories/terasoluna-gfw-releases^
More?  -DarchetypeGroupId=org.terasoluna.gfw.blank^
More?  -DarchetypeArtifactId=terasoluna-gfw-web-blank-mybatis2-archetype^
More?  -DarchetypeVersion=1.0.5.RELEASE^
More?  -DgroupId=todo^
More?  -DartifactId=todo-api^
More?  -Dversion=1.0-SNAPSHOT
[INFO] Scanning for projects...
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] Building Maven Stub Project (No POM) 1
[INFO] ------------------------------------------------------------------------
[INFO]
[INFO] >>> maven-archetype-plugin:2.2:generate (default-cli) @ standalone-pom >>>
[INFO]
[INFO] <<< maven-archetype-plugin:2.2:generate (default-cli) @ standalone-pom <<<
[INFO]
[INFO] --- maven-archetype-plugin:2.2:generate (default-cli) @ standalone-pom ---
[INFO] Generating project in Batch mode
[INFO] Archetype repository missing. Using the one from [org.terasoluna.gfw.blank:terasoluna-gfw-web-blank-mybatis2-archetype:1.0.0.RELEASE -> http://repo.terasoluna.org/nexus/content/repositories/terasoluna-gfw-releases] found in catalog http://repo.terasoluna.org/nexus/content/repositories/terasoluna-gfw-releases
[INFO] ----------------------------------------------------------------------------
[INFO] Using following parameters for creating project from Archetype: terasoluna-gfw-web-blank-mybatis2-archetype:1.0.5.RELEASE
[INFO] ----------------------------------------------------------------------------
[INFO] Parameter: groupId, Value: todo
[INFO] Parameter: artifactId, Value: todo-api
[INFO] Parameter: version, Value: 1.0-SNAPSHOT
[INFO] Parameter: package, Value: todo
[INFO] Parameter: packageInPathFormat, Value: todo
[INFO] Parameter: package, Value: todo
[INFO] Parameter: version, Value: 1.0-SNAPSHOT
[INFO] Parameter: groupId, Value: todo
[INFO] Parameter: artifactId, Value: todo-api
[INFO] project created from Archetype in dir: C:\workspace\todo-api
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 2.287s
[INFO] Finished at: Fri Aug 22 20:20:55 JST 2014
[INFO] Final Memory: 11M/231M
[INFO] ------------------------------------------------------------------------
C:\workspace>

7.1.2.3. プロジェクトのインポート

作成したMavenプロジェクトをSTSのworkspaceにインポートする。

STSで「File」→「Import」→「Maven」→「Existing Maven Projects」を選択して「Next >」をクリックする。

先ほど作成した「todo-api」フォルダを選択し、「Finish」をクリックする。

../_images/import-maven-project.png

Tip

「Pacakge Presentaion」は、以下のように「Hierarchical」に変更することを推奨する。

「Package Explorer」→「View Menu」→「Pacakge Presentaion」を選択して「Hierarchical」をクリックする。

../_images/package-presentation-hierarchical.png

7.1.2.4. ドメイン層のファイルをコピー

チュートリアル(Todoアプリケーション)で作成したクラスのうち、

  • todo.domain.model.Todo
  • todo.domain.repository.todo.TodoRepository
  • todo.domain.repository.todo.TodoRepositoryImpl(MyBatis2版)
  • todo.domain.serivce.todo.TodoService
  • todo.domain.serivce.todo.TodoServiceImpl(インフラストラクチャ層の変更反映版)

と設定ファイル

  • src/main/resources/META-INF/mybatis/sql/todo-sqlmap.xml

を、STSにインポートしたプロジェクトにコピーする。

コピー後のSTSのworkspaceは以下のようになる。

../_images/after-cp-domain-layer.png

7.1.2.5. テーブル定義の追加

todoテーブルを生成するためのDDLを追加する。

チュートリアル(Todoアプリケーション)で作成した、src/main/resources/META-INF/spring/todo-infra.propertiesdatabase.urlに設定されているテーブル定義の設定内容を、 今回作成したsrc/main/resources/META-INF/spring/todo-api-infra.propertiesdatabase.urlにコピーする。

  • src/main/resources/META-INF/spring/todo-api-infra.properties

INITパラメータにテーブルを作成するDDLを指定する事で、アプリケーションサーバ起動時にH2のインメモリデータベースにtodoテーブルが作成される。

database=H2
database.url=jdbc:h2:mem:todo-api;DB_CLOSE_DELAY=-1;INIT=create table if not exists todo(todo_id varchar(36) primary key, todo_title varchar(30), finished boolean, created_at timestamp)
database.username=sa
database.password=
database.driverClassName=org.h2.Driver
# connection pool
cp.maxActive=96
cp.maxIdle=16
cp.minIdle=0
cp.maxWait=60000

7.1.3. REST APIの作成

本チュートリアルでは、todoテーブルで管理しているデータ(以降、「Todoリソース」呼ぶ)をWeb上に公開するためのREST APIを作成する。

API名
HTTP
メソッド
パス
ステータス
コード
説明
GET Todos
GET
/api/v1/todos
200
(OK)
Todoリソースを全件取得する。
POST Todos
POST
/api/v1/todos
201
(Created)
Todoリソースを新規作成する。
GET Todo
GET
/api/v1/todos/{todoId}
200
(OK)
Todoリソースを一件取得する。
PUT Todo
PUT
/api/v1/todos/{todoId}
200
(OK)
Todoリソースを完了状態に更新する。
DELETE Todo
DELETE
/api/v1/todos/{todoId}
204
(No Content)
Todoリソースを削除する。

Tip

パス内に含まれている{todoId}は、パス変数と呼ばれ、任意の可変値を扱う事ができる。 パス変数を使用する事で、GET /api/v1/todos/123GET /api/v1/todos/456を同じAPIで扱う事ができる。

本チュートリアルでは、Todoを一意に識別するためのID(Todo ID)をパス変数として扱っている。

7.1.3.1. API仕様

HTTPリクエストとレスポンスの具体例を用いて、本チュートリアルで作成するREST APIのインタフェース仕様を示す。
本質的ではないHTTPヘッダー等は例から除いている。
下記に示す具体例では、Webアプリケーションのコンテキストパスは「<contextPath>」で表現している。
本チュートリアルで作成するWebアプリケーションのコンテキストパスは、「todo-api」となる。

7.1.3.1.1. GET Todos

  • リクエスト

    > GET /<contextPath>/api/v1/todos HTTP/1.1
    
  • レスポンス

    作成済みのTodoリソースのリストをJSON形式で返却する。

    < HTTP/1.1 200 OK
    < Content-Type: application/json;charset=UTF-8
    <
    [{"todoId":"9aef3ee3-30d4-4a7c-be4a-bc184ca1d558","todoTitle":"Hello World!","finished":false,"createdAt":"2014-02-25T02:21:48.493+0000"}]
    

7.1.3.1.2. POST Todos

  • リクエスト

    新規作成するTodoリソースの内容(タイトル)をJSON形式で指定する。

    > POST /<contextPath>/api/v1/todos HTTP/1.1
    > Content-Type: application/json
    > Content-Length: 29
    >
    {"todoTitle": "Study Spring"}
    
  • レスポンス

    作成したTodoリソースをJSON形式で返却する。

    < HTTP/1.1 201 Created
    < Content-Type: application/json;charset=UTF-8
    <
    {"todoId":"d6101d61-b22c-48ee-9110-e106af6a1404","todoTitle":"Study Spring","finished":false,"createdAt":"2014-02-25T04:05:58.752+0000"}
    

7.1.3.1.3. GET Todo

  • リクエスト

    パス変数「todoId」に、取得対象のTodoリソースのIDを指定する。
    下記例では、パス変数「todoId」に9aef3ee3-30d4-4a7c-be4a-bc184ca1d558を指定している。
    > GET /<contextPath>/api/v1/todos/9aef3ee3-30d4-4a7c-be4a-bc184ca1d558 HTTP/1.1
    
  • レスポンス

    パス変数「todoId」に一致するTodoリソースをJSON形式で返却する。

    < HTTP/1.1 200 OK
    < Content-Type: application/json;charset=UTF-8
    <
    {"todoId":"9aef3ee3-30d4-4a7c-be4a-bc184ca1d558","todoTitle":"Hello World!","finished":false,"createdAt":"2014-02-25T02:21:48.493+0000"}
    

7.1.3.1.4. PUT Todo

  • リクエスト

    パス変数「todoId」に、更新対象のTodoのIDを指定する。
    PUT Todoでは、Todoリソースを完了状態に更新するだけなので、リクエストBODYを受け取らないインタフェース仕様にしている。
    > PUT /<contextPath>/api/v1/todos/9aef3ee3-30d4-4a7c-be4a-bc184ca1d558 HTTP/1.1
    
  • レスポンス

    パス変数「todoId」に一致するTodoリソースを完了状態(finishedフィールドをtrue)に更新し、JSON形式で返却する。

    < HTTP/1.1 200 OK
    < Content-Type: application/json;charset=UTF-8
    <
    {"todoId":"9aef3ee3-30d4-4a7c-be4a-bc184ca1d558","todoTitle":"Hello World!","finished":true,"createdAt":"2014-02-25T02:21:48.493+0000"}
    

7.1.3.1.5. DELETE Todo

  • リクエスト

    パス変数「todoId」に、削除対象のTodoリソースのIDを指定する。

    > DELETE /todo-api/api/v1/todos/9aef3ee3-30d4-4a7c-be4a-bc184ca1d558 HTTP/1.1
    
  • レスポンス

    DELETE Todoでは、Todoリソースの削除が完了した事で返却するリソースが存在しなくなった事を示すために、レスポンスBODYを返却しないインタフェース仕様にしている。

    < HTTP/1.1 204 No Content
    

7.1.3.1.6. エラー応答

REST APIでエラーが発生した場合は、JSON形式でエラー内容を返却する。
以下に代表的なエラー発生時のレスポンス仕様について記載する。
下記以外のエラーパターンもあるが、本チュートリアルでは説明は割愛する。

チュートリアル(Todoアプリケーション)では、エラーメッセージはプログラムの中でハードコーディングしていたが、本チュートリアルでは、エラーメッセージはエラーコードをキーにプロパティファイルから取得する。

  • 入力チェックエラー発生時のレスポンス仕様

    < HTTP/1.1 400 Bad Request
    < Content-Type: application/json;charset=UTF-8
    <
    {"code":"E400","message":"[E400] The requested Todo contains invalid values.","details":[{"code":"NotNull","message":"todoTitle may not be null.",target:"todoTitle"}]}
    
  • 業務エラー発生時のレスポンス仕様

    < HTTP/1.1 409 Conflict
    < Content-Type: application/json;charset=UTF-8
    <
    {"code":"E002","message":"[E002] The requested Todo is already finished. (id=353fb5db-151a-4696-9b4a-b958358a5ab3)"}
    
  • リソース未検出時のレスポンス仕様

    < HTTP/1.1 404 Not Found
    < Content-Type: application/json;charset=UTF-8
    <
    {"code":"E404","message":"[E404] The requested Todo is not found. (id=353fb5db-151a-4696-9b4a-b958358a5ab2)"}
    
  • システムエラー発生時のレスポンス仕様

    < HTTP/1.1 500 Internal Server Error
    < Content-Type: application/json;charset=UTF-8
    <
    {"code":"E500","message":"[E500] System error occurred."}
    

7.1.3.2. REST API用のDispatcherServletを用意

まず、REST API用のリクエストを処理するためのDispatcherServletの定義を追加する。

7.1.3.2.1. web.xmlの修正

REST API用の設定を追加する。
追加で設定する箇所をハイライトしている。
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
    version="3.0">
    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>
    <listener>
        <listener-class>org.terasoluna.gfw.web.logging.HttpSessionEventLoggingListener</listener-class>
    </listener>
    <context-param>
        <param-name>contextConfigLocation</param-name>
        <!-- Root ApplicationContext -->
        <param-value>
            classpath*:META-INF/spring/applicationContext.xml
            classpath*:META-INF/spring/spring-security.xml
        </param-value>
    </context-param>

    <filter>
        <filter-name>MDCClearFilter</filter-name>
        <filter-class>org.terasoluna.gfw.web.logging.mdc.MDCClearFilter</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>MDCClearFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <filter>
        <filter-name>exceptionLoggingFilter</filter-name>
        <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>exceptionLoggingFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <filter>
        <filter-name>XTrackMDCPutFilter</filter-name>
        <filter-class>org.terasoluna.gfw.web.logging.mdc.XTrackMDCPutFilter</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>XTrackMDCPutFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <filter>
        <filter-name>CharacterEncodingFilter</filter-name>
        <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
        <init-param>
            <param-name>encoding</param-name>
            <param-value>UTF-8</param-value>
        </init-param>
        <init-param>
            <param-name>forceEncoding</param-name>
            <param-value>true</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>CharacterEncodingFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <filter>
        <filter-name>springSecurityFilterChain</filter-name>
        <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
    </filter>

    <filter-mapping>
        <filter-name>springSecurityFilterChain</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <!-- (1) -->
    <servlet>
        <servlet-name>restApiServlet</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <!-- ApplicationContext for Spring MVC (REST) -->
            <param-value>classpath*:META-INF/spring/spring-mvc-rest.xml</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>

    <!-- (2) -->
    <servlet-mapping>
        <servlet-name>restApiServlet</servlet-name>
        <url-pattern>/api/v1/*</url-pattern>
    </servlet-mapping>

    <servlet>
        <servlet-name>appServlet</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <!-- ApplicationContext for Spring MVC -->
            <param-value>classpath*:META-INF/spring/spring-mvc.xml</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>

    <servlet-mapping>
        <servlet-name>appServlet</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>

    <jsp-config>
        <jsp-property-group>
            <url-pattern>*.jsp</url-pattern>
            <el-ignored>false</el-ignored>
            <page-encoding>UTF-8</page-encoding>
            <scripting-invalid>false</scripting-invalid>
            <include-prelude>/WEB-INF/views/common/include.jsp</include-prelude>
        </jsp-property-group>
    </jsp-config>

    <error-page>
        <error-code>500</error-code>
        <location>/WEB-INF/views/common/error/systemError.jsp</location>
    </error-page>
    <error-page>
        <error-code>404</error-code>
        <location>/WEB-INF/views/common/error/resourceNotFoundError.jsp</location>
    </error-page>
    <error-page>
        <exception-type>java.lang.Exception</exception-type>
        <location>/WEB-INF/views/common/error/unhandledSystemError.html</location>
    </error-page>

    <session-config>
        <!-- 30min -->
        <session-timeout>30</session-timeout>
    </session-config>

</web-app>
項番 説明
(1)
初期化パラメータ「contextConfigLocation」に、REST用のSpringMVC設定ファイルを指定する。
本チュートリアルでは、クラスパス上にある「META-INF/spring/spring-mvc-rest.xml」を指定している。
(2)
<url-pattern>要素に、REST API用のDispatcherServletにマッピングするURLのパターンを指定する。
本チュートリアルでは、/api/v1/から始まる場合はリクエストをREST APIへのリクエストとしてREST API用のDispatcherServletへマッピングしている。

7.1.3.2.2. spring-mvc-rest.xmlの作成

src/main/resources/META-INF/spring/spring-mvc.xmlをコピーして、REST用のSpringMVC設定ファイルを作成する。
REST用のSpringMVC設定ファイルは以下のような定義となる。
  • src/main/resources/META-INF/spring/spring-mvc-rest.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:mvc="http://www.springframework.org/schema/mvc"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xsi:schemaLocation="http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd
    http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
        http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd">

    <context:property-placeholder
            location="classpath*:/META-INF/spring/*.properties"/>

    <!-- (1) -->
    <context:component-scan base-package="todo.api"/>

    <mvc:annotation-driven>
        <mvc:message-converters>
            <!-- (2) -->
            <bean
                    class="org.springframework.http.converter.json.MappingJacksonHttpMessageConverter">
                <property name="objectMapper" ref="objectMapper"/>
            </bean>
        </mvc:message-converters>
        <mvc:argument-resolvers>
            <bean class="org.springframework.data.web.PageableHandlerMethodArgumentResolver"></bean>
        </mvc:argument-resolvers>
    </mvc:annotation-driven>

    <bean id="objectMapper" class="org.codehaus.jackson.map.ObjectMapper">
        <property name="dateFormat">
            <!-- (3) -->
            <bean class="org.codehaus.jackson.map.util.StdDateFormat"/>
        </property>
    </bean>

    <mvc:interceptors>
        <mvc:interceptor>
            <mvc:mapping path="/**"/>
            <mvc:exclude-mapping path="/resources/**"/>
            <mvc:exclude-mapping path="/**/*.html"/>
            <bean
                    class="org.terasoluna.gfw.web.logging.TraceLoggingInterceptor"/>
        </mvc:interceptor>
        <!-- (4) -->
        <!--  REMOVE THIS LINE IF YOU USE JPA
        <mvc:interceptor>
            <mvc:mapping path="/**"/>
            <mvc:exclude-mapping path="/resources/**"/>
            <mvc:exclude-mapping path="/**/*.html"/>
            <bean
                    class="org.springframework.orm.jpa.support.OpenEntityManagerInViewInterceptor"/>
        </mvc:interceptor>
              REMOVE THIS LINE IF YOU USE JPA  -->
    </mvc:interceptors>

    <!-- Setting AOP. -->
    <bean id="handlerExceptionResolverLoggingInterceptor"
        class="org.terasoluna.gfw.web.exception.HandlerExceptionResolverLoggingInterceptor">
        <property name="exceptionLogger" ref="exceptionLogger" />
    </bean>
    <aop:config>
        <aop:advisor advice-ref="handlerExceptionResolverLoggingInterceptor"
            pointcut="execution(* org.springframework.web.servlet.HandlerExceptionResolver.resolveException(..))" />
    </aop:config>
</beans>
項番 説明
(1)
REST API用のパッケージ配下に対してコンポーネントスキャンの設定をする。
本チュートリアルでは、REST API用のパッケージをtodo.apiにしている。
画面遷移用のContollerは、appパッケージ配下に格納していたが、REST API用のContollerは、apiパッケージ配下に格納する事を推奨する。
(2)
<mvc:message-converters>に、Contrtollerの引数及び返り値で扱うJavaBeanをシリアライズ/デシリアライズするためのクラスを設定する。
複数設定する事ができるが、今回のチュートリアルではJSONしか使用しないため、MappingJacksonHttpMessageConverterのみ指定している。
MappingJacksonHttpMessageConverterobjectMapperプロパティには、Jacksonより提供されている「JSON <-> JavaBean」の変換を行うためのコンポーネント「ObjectMapper」を指定する。
本チュートリアルでは、日時型の変換をカスタマイズしたObjectMapperを指定している。
(3)
本チュートリアルでは、java.util.Dateオブジェクトをシリアライズする際にISO-8601形式とする。
Dateオブジェクトをシリアライズする際にISO-8601形式にする場合は、ObjectMapperdateFormatプロパティにorg.codehaus.jackson.map.util.StdDateFormatを設定する事で実現する事ができる。
(4)
JPAを使用する場合はOpenEntityManagerInViewInterceptorを使用するが、今回のチュートリアルではMyBatis2を使用するので、コメントアウトのままにするか、この<mvc:interceptor>タグを削除する。
../_images/add-spring-mvc-rest.png

7.1.3.3. REST API用のSpring Securityの定義追加

本チュートリアルで作成するREST APIでは、CSRF対策を無効にする。
REST APIを使って構築するWebアプリケーションでも、CSRF対策は必要である。ただし、本チュートリアルの目的としてCSRF対策の話題は本質的ではないため、機能を無効化し、説明も割愛する。
CSRF対策を無効化すると、セッションを使用する必要がなくなる。
そのため、本チュートリアルではセッションを使用しないアーキテクチャ(ステートレスなアーキテクチャ)を採用する。
以下の設定を追加する事で、CSRF対策の無効化及びセッションを使用しないようにする事ができる。
  • src/main/resources/META-INF/spring/spring-security.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:sec="http://www.springframework.org/schema/security"
    xmlns:context="http://www.springframework.org/schema/context"
    xsi:schemaLocation="http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security.xsd
        http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd">

    <sec:http pattern="/resources/**" security="none"/>

    <!-- (1) -->
    <sec:http pattern="/api/v1/**" auto-config="true" use-expressions="true" create-session="stateless">
        <!--<sec:custom-filter ref="csrfFilter" before="LOGOUT_FILTER"/>--> <!-- (2) -->
        <sec:custom-filter ref="userIdMDCPutFilter" after="ANONYMOUS_FILTER"/>
        <!--<sec:session-management session-authentication-strategy-ref="sessionAuthenticationStrategy" />--> <!-- (3) -->
    </sec:http>

    <sec:http auto-config="true" use-expressions="true">
        <sec:custom-filter ref="csrfFilter" before="LOGOUT_FILTER"/>
        <sec:custom-filter ref="userIdMDCPutFilter" after="ANONYMOUS_FILTER"/>
        <sec:session-management session-authentication-strategy-ref="sessionAuthenticationStrategy" />
    </sec:http>
    <sec:authentication-manager></sec:authentication-manager>

    <!-- CSRF Protection -->
    <bean id="csrfTokenRepository"
        class="org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository" />

    <bean id="csrfFilter" class="org.springframework.security.web.csrf.CsrfFilter">
        <constructor-arg index="0" ref="csrfTokenRepository" />
        <property name="accessDeniedHandler">
            <bean
                class="org.springframework.security.web.access.AccessDeniedHandlerImpl">
                <property name="errorPage" value="/WEB-INF/views/common/error/csrfTokenError.jsp" />
            </bean>
        </property>
    </bean>

    <bean id="sessionAuthenticationStrategy"
        class="org.springframework.security.web.authentication.session.CompositeSessionAuthenticationStrategy">
        <constructor-arg index="0">
            <list>
                <bean
                    class="org.springframework.security.web.authentication.session.SessionFixationProtectionStrategy" />
                <bean
                    class="org.springframework.security.web.csrf.CsrfAuthenticationStrategy">
                    <constructor-arg index="0"
                        ref="csrfTokenRepository" />
                </bean>
            </list>
        </constructor-arg>
    </bean>

    <!-- Put UserID into MDC -->
    <bean id="userIdMDCPutFilter" class="org.terasoluna.gfw.security.web.logging.UserIdMDCPutFilter">
        <property name="removeValue" value="true" />
    </bean>

</beans>
項番 説明
(1)
REST API用のSpring Securityの定義を追加する。
<sec:http>要素のpattern属性に、REST API用のリクエストパスのURLパターンを指定している。
本チュートリアルでは/api/v1/で始まるリクエストパスをREST API用のリクエストパスとして扱う。
また、create-session属性をstatelessとする事で、Spring Securityの処理でセッションが使用されなくなる。
(2)
CSRF対策を無効化するために、CSRF対策用のServlet Filterの定義をコメントアウトしている。
(3)
セッションを使用しないアーキテクチャを採用するために、セッション管理に関係するコンポーネントへの参照定義をコメントアウトしている。

7.1.3.4. REST API用パッケージの作成

REST API用のクラスを格納するパッケージを作成する。

REST API用のクラスを格納するルートパッケージのパッケージ名はapiとして、配下にリソース毎のパッケージ(リソース名の小文字)を作成する事を推奨する。
本チュートリアルで扱うリソースのリソース名はTodoなので、todo.api.todoパッケージを作成する。
../_images/make-package-for-rest.png

Note

作成したパッケージに格納するクラスは、通常以下の3種類となる。 作成するクラスのクラス名は、以下のネーミングルールとする事を推奨する。

  • [リソース名]Resource
  • [リソース名]RestController
  • [リソース名]Helper (必要に応じて)

本チュートリアルで扱うリソースのリソース名がTodoなので、

  • TodoResource
  • TodoRestController

を作成する。

本チュートリアルでは、TodoRestHelperは作成しない。

7.1.3.5. Resourceクラスの作成

Todoリソースを表現するTodoResourceクラスを作成する。
本ガイドラインでは、REST APIの入出力となるJSON(またはXML)を表現するJava BeanをResourceクラスと呼ぶ。
  • TodoResource.java
package todo.api.todo;

import java.util.Date;

import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

public class TodoResource {

    private String todoId;

    @NotNull
    @Size(min = 1, max = 30)
    private String todoTitle;

    private boolean finished;

    private Date createdAt;

    public String getTodoId() {
        return todoId;
    }

    public void setTodoId(String todoId) {
        this.todoId = todoId;
    }

    public String getTodoTitle() {
        return todoTitle;
    }

    public void setTodoTitle(String todoTitle) {
        this.todoTitle = todoTitle;
    }

    public boolean isFinished() {
        return finished;
    }

    public void setFinished(boolean finished) {
        this.finished = finished;
    }

    public Date getCreatedAt() {
        return createdAt;
    }

    public void setCreatedAt(Date createdAt) {
        this.createdAt = createdAt;
    }
}
../_images/create-todo-resource.png

Note

DomainObjectクラス(本チュートリアルではTodoクラス)があるにも関わらず、Resourceクラスを作成する理由は、 クライアントとの入出力で使用するインタフェース上の情報と、業務処理で扱う情報は必ずしも一致しないためである。

これらを混同してして使用すると、アプリケーション層の影響がドメイン層におよび、保守性を低下させる。 DomainObjectとResourceクラスは別々に作成し、Dozer等のBeanMapperを利用してデータ変換を行うことを推奨する。

ResourceクラスはFormクラスと役割が似ているが、FormクラスはHTMLの<form> タグをJavaBeanで表現したもの、 ResourceクラスはREST APIの入出力をJavaBeanで表現したものであり、本質的には異なるものである。

ただし、実体としてはBean Validationのアノテーションを付与したJavaBeanであり、Controllerクラスと同じパッケージに格納することから、 Formクラスとほぼ同じである。

7.1.3.6. Controllerクラスの作成

TodoResourceのREST APIを提供するTodoRestControllerクラスを作成する。

  • TodoRestController.java
package todo.api.todo;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("todos") // (1)
public class TodoRestController {

}
項番 説明
(1)
リソースのパスを指定する。
/api/v1/の部分はweb.xmlに定義しているため、この設定を行うことで/<contextPath>/api/v1/todosというパスにマッピングされる。

7.1.3.6.1. GET Todosの実装

作成済みのTodoリソースを全件取得するAPI(GET Todos)の処理を、TodoRestControllergetTodosメソッドに実装する。

package todo.api.todo;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

import javax.inject.Inject;

import org.dozer.Mapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

import todo.domain.model.Todo;
import todo.domain.service.todo.TodoService;

@Controller
@RequestMapping("todos")
public class TodoRestController {
    @Inject
    TodoService todoService;
    @Inject
    Mapper beanMapper;

    @RequestMapping(method = RequestMethod.GET) // (1)
    @ResponseBody // (2)
    @ResponseStatus(HttpStatus.OK) // (3)
    public List<TodoResource> getTodos() {
        Collection<Todo> todos = todoService.findAll();
        List<TodoResource> todoResources = new ArrayList<>();
        for (Todo todo : todos) {
            todoResources.add(beanMapper.map(todo, TodoResource.class)); // (4)
        }
        return todoResources; // (5)
    }

}
項番 説明
(1)
メソッドがGETのリクエストを処理するために、method属性にRequestMethod.GETを設定する。
(2)
メソッドの返り値として返却したオブジェクトを、レスポンスBODYのメッセージとしてクライアントに返答することを示す@ResponseBodyアノテーションを付与する。
本チュートリアルでは、JSON形式のデータにシリアライズする。
(3)
応答するHTTPステータスコードを@ResponseStatusアノテーションに指定する。
HTTPステータスとして、”200 OK”を設定するため、value属性にはHttpStatus.OKを設定する。
(4)
TodoService.findAllで取得したTodoオブジェクトを、応答するJSONを表現するTodoResource型に変換する。
TodoTodoResourceの変換処理は、Dozerのorg.dozer.Mapperを使うと便利である。
(5)
List<TodoResource>オブジェクトを返却することで、spring-mvc-rest.xmlに定義したMappingJacksonHttpMessageConverterにより、[{"todoId" : "xxx", "todoTitle": "yyy", ...}, {"todoId" : "xxx", "todoTitle": "yyy", ...}, ...]形式のJSONにシリアライズされる。
tc Serverを起動し、実装したAPIの動作確認を行う。
プロジェクト名を右クリックして、「Run As」→「Run on Server」を選択する。
../_images/run-on-server1.png

「VMWare vFabric tc Server Developer Edition v2.9」を選択して、「Next >」をクリックする。

../_images/run-on-server2.png

「Configured」の欄に「todo-api」が含まれていることを確認して、「Finish」をクリックする。

../_images/run-on-server3.png

正常に起動して"localhost:8080/todo-api/"にアクセスすると、以下のようにデフォルトのwelcomeページが表示される。

../_images/run-on-server4.png

次に、REST APIにアクセスする。 Dev HTTP Clientを開いてURLに"localhost:8080/todo-api/api/v1/todos"を入力し、メソッドにGETを指定して、”Send”ボタンをクリックする。

../_images/get-todos1.png
以下のように「RESPONSE」の「BODY」に実行結果のJSONが表示される。
現時点ではデータが何も登録されていないため、空配列である[]が返却される。
../_images/get-todos2.png

Spring Securityの設定を、セッションを使用しない設定に変更しているため、「RESPONSE」の「HEADERS」に"Set-Cookie: JSESSIONID=xxxx"がないという点にも着目してほしい。

7.1.3.6.2. POST Todosの実装

Todoリソースを新規作成するAPI(POST Todos)の処理を、TodoRestControllerpostTodosメソッドに実装する。

package todo.api.todo;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

import javax.inject.Inject;

import org.dozer.Mapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

import todo.domain.model.Todo;
import todo.domain.service.todo.TodoService;

@Controller
@RequestMapping("todos")
public class TodoRestController {
    @Inject
    TodoService todoService;
    @Inject
    Mapper beanMapper;

    @RequestMapping(method = RequestMethod.GET)
    @ResponseBody
    @ResponseStatus(HttpStatus.OK)
    public List<TodoResource> getTodos() {
        Collection<Todo> todos = todoService.findAll();
        List<TodoResource> todoResources = new ArrayList<>();
        for (Todo todo : todos) {
            todoResources.add(beanMapper.map(todo, TodoResource.class));
        }
        return todoResources;
    }

    @RequestMapping(method = RequestMethod.POST) // (1)
    @ResponseBody
    @ResponseStatus(HttpStatus.CREATED) // (2)
    public TodoResource postTodos(@RequestBody @Validated TodoResource todoResource) { // (3)
        Todo createdTodo = todoService.create(beanMapper.map(todoResource, Todo.class)); // (4)
        TodoResource createdTodoResponse = beanMapper.map(createdTodo, TodoResource.class); // (5)
        return createdTodoResponse; // (6)
    }

}
項番 説明
(1)
メソッドがPOSTのリクエストを処理するために、method属性にRequestMethod.POSTを設定する。
(2)
応答するHTTPステータスコードを@ResponseStatusアノテーションに指定する。
HTTPステータスとして、”201 Created”を設定するため、value属性にはHttpStatus.CREATEDを設定する。
(3)
HTTPリクエストのBody(JSON)をJavaBeanにマッピングするために、@RequestBodyアノテーションをマッピング対象のTodoResourceクラスに付与する。
また、入力チェックするために@Validatedも付与する。例外ハンドリングは別途行う必要がある。
(4)
TodoResourceTodoクラスに変換後、TodoService.createを実行し、Todoリソースを新規作成する。
(5)
TodoService.createによって新規作成されたTodoオブジェクトを、応答するJSONを表現するTodoResource型に変換する。
(6)
TodoResourceオブジェクトを返却することで、spring-mvc-rest.xmlに定義したMappingJacksonHttpMessageConverterにより、{"todoId" : "xxx", "todoTitle": "yyy", ...}形式のJSONにシリアライズされる。
HTTP Dev Clientを使用して、実装したAPIの動作確認を行う。
Dev HTTP Clientを開いてURLに"localhost:8080/todo-api/api/v1/todos"を入力し、メソッドにPOSTを指定する。
「REQUEST」の「BODY」に以下のJSONを入力する。
{
  "todoTitle": "Hello World!"
}

また、「REQUEST」の「HEADERS」の「+」ボタンでHTTPヘッダーを追加し、「Content-Type」に「application/json」を設定後、”Send”ボタンをクリックする。

../_images/post-todos1.png

“201 Created”のHTTPステータスが返却され、「RESPONSE」の「Body」に新規作成されたTodoリソースのJSONが表示される。

../_images/post-todos2.png

この状態で再びGET Todosを実行すると、作成したTodoリソースを含む配列が返却される。

../_images/get-todos3.png

7.1.3.6.3. GET Todoの実装

チュートリアル(Todoアプリケーション)では、TodoServiceに一件取得用のメソッド(findOne)を作成しなかったため、 TodoServiceTodoServiceImplに以下のハイライト部を追加する。

  • TodoService.java

findOneメソッドの定義を追加する。

package todo.domain.service.todo;

import java.util.Collection;

import todo.domain.model.Todo;

public interface TodoService {
    Collection<Todo> findAll();

    Todo findOne(String todoId);

    Todo create(Todo todo);

    Todo finish(String todoId);

    void delete(String todoId);
}
  • TodoServiceImpl.java

findOneメソッド呼び出し時に開始されるトランザクションを読み取り専用に設定する。

package todo.domain.service.todo;

import java.util.Collection;
import java.util.Date;
import java.util.UUID;

import javax.inject.Inject;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.terasoluna.gfw.common.exception.BusinessException;
import org.terasoluna.gfw.common.exception.ResourceNotFoundException;
import org.terasoluna.gfw.common.message.ResultMessage;
import org.terasoluna.gfw.common.message.ResultMessages;

import todo.domain.model.Todo;
import todo.domain.repository.todo.TodoRepository;

@Service
@Transactional
public class TodoServiceImpl implements TodoService {
    @Inject
    TodoRepository todoRepository;

    private static final long MAX_UNFINISHED_COUNT = 5;

    @Override
    @Transactional(readOnly = true)
    public Todo findOne(String todoId) {
        Todo todo = todoRepository.findOne(todoId);
        if (todo == null) {
            ResultMessages messages = ResultMessages.error();
            messages.add(ResultMessage
                    .fromText("[E404] The requested Todo is not found. (id="
                            + todoId + ")"));
            throw new ResourceNotFoundException(messages);
        }
        return todo;
    }

    @Override
    @Transactional(readOnly = true)
    public Collection<Todo> findAll() {
        return todoRepository.findAll();
    }

    @Override
    public Todo create(Todo todo) {
        long unfinishedCount = todoRepository.countByFinished(false);
        if (unfinishedCount >= MAX_UNFINISHED_COUNT) {
            ResultMessages messages = ResultMessages.error();
            messages.add(ResultMessage
                    .fromText("[E001] The count of un-finished Todo must not be over "
                            + MAX_UNFINISHED_COUNT + "."));

            throw new BusinessException(messages);
        }

        String todoId = UUID.randomUUID().toString();
        Date createdAt = new Date();

        todo.setTodoId(todoId);
        todo.setCreatedAt(createdAt);
        todo.setFinished(false);

        todoRepository.save(todo);

        return todo;
    }

    @Override
    public Todo finish(String todoId) {
        Todo todo = findOne(todoId);
        if (todo.isFinished()) {
            ResultMessages messages = ResultMessages.error();
            messages.add(ResultMessage
                    .fromText("[E002] The requested Todo is already finished. (id="
                            + todoId + ")"));
            throw new BusinessException(messages);
        }
        todo.setFinished(true);
        todoRepository.save(todo);
        return todo;
    }

    @Override
    public void delete(String todoId) {
        Todo todo = findOne(todoId);
        todoRepository.delete(todo);
    }
}

Todoリソースを一件取得するAPI(GET Todo)の処理を、TodoRestControllergetTodoメソッドに実装する。

package todo.api.todo;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

import javax.inject.Inject;

import org.dozer.Mapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

import todo.domain.model.Todo;
import todo.domain.service.todo.TodoService;

@Controller
@RequestMapping("todos")
public class TodoRestController {
    @Inject
    TodoService todoService;
    @Inject
    Mapper beanMapper;

    @RequestMapping(method = RequestMethod.GET)
    @ResponseBody
    @ResponseStatus(HttpStatus.OK)
    public List<TodoResource> getTodos() {
        Collection<Todo> todos = todoService.findAll();
        List<TodoResource> todoResources = new ArrayList<>();
        for (Todo todo : todos) {
            todoResources.add(beanMapper.map(todo, TodoResource.class));
        }
        return todoResources;
    }

    @RequestMapping(method = RequestMethod.POST)
    @ResponseBody
    @ResponseStatus(HttpStatus.CREATED)
    public TodoResource postTodos(@RequestBody @Validated TodoResource todoResource) {
        Todo createdTodo = todoService.create(beanMapper.map(todoResource, Todo.class));
        TodoResource createdTodoResponse = beanMapper.map(createdTodo, TodoResource.class);
        return createdTodoResponse;
    }

    @RequestMapping(value="{todoId}", method = RequestMethod.GET) // (1)
    @ResponseBody
    @ResponseStatus(HttpStatus.OK)
    public TodoResource getTodo(@PathVariable("todoId") String todoId) { // (2)
        Todo todo = todoService.findOne(todoId); // (3)
        TodoResource todoResource = beanMapper.map(todo, TodoResource.class);
        return todoResource;
    }

}
項番 説明
(1)
パスからtodoIdを取得するために、@RequestMappingアノテーションのvalue属性にパス変数を指定する。
メソッドがGETのリクエストを処理するために、method属性にRequestMethod.GETを設定する。
(2)
@PathVariableアノテーションのvalue属性に、todoIdを取得するためのパス変数名を指定する。
(3)
パス変数から取得したtodoIdを使用して、Todoリソースを一件を取得する。
HTTP Dev Clientを使用して、実装したAPIの動作確認を行う。
Dev HTTP Clientを開いてURLに"localhost:8080/todo-api/api/v1/todos/{todoId}”を入力し、メソッドにGETを指定する。
{todoId}の部分は実際のIDを入れる必要があるので、POST TodosまたはGET Todosを実行してResponse中のtodoIdをコピーして貼り付けてから、”Send”ボタンをクリックする。

“200 OK”のHTTPステータスが返却され、「RESPONSE」の「Body」に指定したTodoリソースのJSONが表示される。

../_images/get-todo1.png

7.1.3.6.4. PUT Todoの実装

Todoリソースを一件更新(完了状態へ更新)するAPI(PUT Todo)の処理を、TodoRestControllerputTodoメソッドに実装する。

package todo.api.todo;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

import javax.inject.Inject;

import org.dozer.Mapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

import todo.domain.model.Todo;
import todo.domain.service.todo.TodoService;

@Controller
@RequestMapping("todos")
public class TodoRestController {
    @Inject
    TodoService todoService;
    @Inject
    Mapper beanMapper;

    @RequestMapping(method = RequestMethod.GET)
    @ResponseBody
    @ResponseStatus(HttpStatus.OK)
    public List<TodoResource> getTodos() {
        Collection<Todo> todos = todoService.findAll();
        List<TodoResource> todoResources = new ArrayList<>();
        for (Todo todo : todos) {
            todoResources.add(beanMapper.map(todo, TodoResource.class));
        }
        return todoResources;
    }

    @RequestMapping(method = RequestMethod.POST)
    @ResponseBody
    @ResponseStatus(HttpStatus.CREATED)
    public TodoResource postTodos(@RequestBody @Validated TodoResource todoResource) {
        Todo createdTodo = todoService.create(beanMapper.map(todoResource, Todo.class));
        TodoResource createdTodoResponse = beanMapper.map(createdTodo, TodoResource.class);
        return createdTodoResponse;
    }

    @RequestMapping(value="{todoId}", method = RequestMethod.GET)
    @ResponseBody
    @ResponseStatus(HttpStatus.OK)
    public TodoResource getTodo(@PathVariable("todoId") String todoId) {
        Todo todo = todoService.findOne(todoId);
        TodoResource todoResource = beanMapper.map(todo, TodoResource.class);
        return todoResource;
    }

    @RequestMapping(value="{todoId}", method = RequestMethod.PUT) // (1)
    @ResponseBody
    @ResponseStatus(HttpStatus.OK)
    public TodoResource putTodo(@PathVariable("todoId") String todoId) { // (2)
        Todo finishedTodo = todoService.finish(todoId); // (3)
        TodoResource finishedTodoResource = beanMapper.map(finishedTodo, TodoResource.class);
        return finishedTodoResource;
    }

}
項番 説明
(1)
パスからtodoIdを取得するために、@RequestMappingアノテーションのvalue属性にパス変数を指定する。
メソッドがGETのリクエストを処理するために、method属性にRequestMethod.GETを設定する。
(2)
@PathVariableアノテーションのvalue属性に、todoIdを取得するためのパス変数名を指定する。
(3)
パス変数から取得したtodoIdを使用して、Todoリソースを完了状態へ更新する。
HTTP Dev Clientを使用して、実装したAPIの動作確認を行う。
Dev HTTP Clientを開いてURLに"localhost:8080/todo-api/api/v1/todos/{todoId}"を入力し、メソッドにPUTを指定する。
{todoId}の部分は実際のIDを入れる必要があるので、POST TodosまたはGET Todosを実行してResponse中のtodoIdをコピーして貼り付けてから、”Send”ボタンをクリックする。
../_images/put-todo1.png
“200 OK”のHTTPステータスが返却され、「RESPONSE」の「Body」に更新されたTodoリソースのJSONが表示される。
finishedtrueに更新されている。
../_images/put-todo2.png

7.1.3.6.5. DELETE Todoの実装

最後に、Todoリソースを一件削除するAPI(DELETE Todo)の処理を、TodoRestControllerdeleteTodoメソッドに実装する。

package todo.api.todo;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

import javax.inject.Inject;

import org.dozer.Mapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

import todo.domain.model.Todo;
import todo.domain.service.todo.TodoService;

@Controller
@RequestMapping("todos")
public class TodoRestController {
    @Inject
    TodoService todoService;
    @Inject
    Mapper beanMapper;

    @RequestMapping(method = RequestMethod.GET)
    @ResponseBody
    @ResponseStatus(HttpStatus.OK)
    public List<TodoResource> getTodos() {
        Collection<Todo> todos = todoService.findAll();
        List<TodoResource> todoResources = new ArrayList<>();
        for (Todo todo : todos) {
            todoResources.add(beanMapper.map(todo, TodoResource.class));
        }
        return todoResources;
    }

    @RequestMapping(method = RequestMethod.POST)
    @ResponseBody
    @ResponseStatus(HttpStatus.CREATED)
    public TodoResource postTodos(@RequestBody @Validated TodoResource todoResource) {
        Todo createdTodo = todoService.create(beanMapper.map(todoResource, Todo.class));
        TodoResource createdTodoResponse = beanMapper.map(createdTodo, TodoResource.class);
        return createdTodoResponse;
    }

    @RequestMapping(value="{todoId}", method = RequestMethod.GET)
    @ResponseBody
    @ResponseStatus(HttpStatus.OK)
    public TodoResource getTodo(@PathVariable("todoId") String todoId) {
        Todo todo = todoService.findOne(todoId);
        TodoResource todoResource = beanMapper.map(todo, TodoResource.class);
        return todoResource;
    }

    @RequestMapping(value="{todoId}", method = RequestMethod.PUT)
    @ResponseBody
    @ResponseStatus(HttpStatus.OK)
    public TodoResource putTodo(@PathVariable("todoId") String todoId) {
        Todo finishedTodo = todoService.finish(todoId);
        TodoResource finishedTodoResource = beanMapper.map(finishedTodo, TodoResource.class);
        return finishedTodoResource;
    }

    @RequestMapping(value="{todoId}", method = RequestMethod.DELETE) // (1)
    @ResponseBody
    @ResponseStatus(HttpStatus.NO_CONTENT) // (2)
    public void deleteTodo(@PathVariable("todoId") String todoId) { // (3)
        todoService.delete(todoId); // (4)
    }

}
項番 説明
(1)
パスからtodoIdを取得するために、@RequestMappingアノテーションのvalue属性にパス変数を指定する。
メソッドがDELETEのリクエストを処理するために、method属性にRequestMethod.DELETEを設定する。
(2)
応答するHTTPステータスコードを@ResponseStatusアノテーションに指定する。
HTTPステータスとして、”204 No Content”を設定するため、value属性にはHttpStatus.NO_CONTENTを設定する。
(3)
DELETEの場合は返却するコンテンツがないため、返り値の型をvoidとする。
(4)
パス変数から取得したtodoIdを使用して、Todoリソースを削除する。
HTTP Dev Clientを使用して、実装したAPIの動作確認を行う。
Dev HTTP Clientを開いてURLに"localhost:8080/todo-api/api/v1/todos/{todoId}"を入力し、メソッドにDELETEを指定する。
{todoId}の部分は実際のIDを入れる必要があるので、POST TodosまたはGET Todosを実行してResponse中のtodoIdをコピーして貼り付けてから、”Send”ボタンをクリックする。
../_images/delete-todo1.png

“204 No Content”のHTTPステータスが返却され、「RESPONSE」の「Body」は空である。

../_images/delete-todo2.png
Dev HTTP ClientのURLに"localhost:8080/todo-api/api/v1/todos"を入力し、メソッドにGETを指定してから”Send”ボタンをクリックする。
Todoリソースが削除されている事が確認できる。
../_images/delete-todo3.png

7.1.3.7. 例外ハンドリングの実装

本チュートリアルでは、例外ハンドリングの実装方法をイメージしやすくするため、本ガイドラインで推奨している実装よりシンプルな実装にしてある。
実際の例外ハンドリングは、RESTful Web Service説明されている方法でハンドリングを行うことを強く推奨する

7.1.3.7.1. ドメイン層の実装を変更

本チュートリアルでは、エラーコードをキーにプロパティファイルからエラーメッセージを取得する。
そのため、例外ハンドリングの実装を行う前に、チュートリアル(Todoアプリケーション)で作成したServiceクラスの実装を以下のように変更する。
  • TodoServiceImpl.java

ハードコーディングされていたエラーメッセージの代わりに、エラーコードを指定するように変更する。

package todo.domain.service.todo;

import java.util.Collection;
import java.util.Date;
import java.util.UUID;

import javax.inject.Inject;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.terasoluna.gfw.common.exception.BusinessException;
import org.terasoluna.gfw.common.exception.ResourceNotFoundException;
import org.terasoluna.gfw.common.message.ResultMessages;

import todo.domain.model.Todo;
import todo.domain.repository.todo.TodoRepository;

@Service
@Transactional
public class TodoServiceImpl implements TodoService {
    @Inject
    TodoRepository todoRepository;

    private static final long MAX_UNFINISHED_COUNT = 5;

    @Override
    @Transactional(readOnly = true)
    public Todo findOne(String todoId) {
        Todo todo = todoRepository.findOne(todoId);
        if (todo == null) {
            ResultMessages messages = ResultMessages.error();
            messages.add("E404", todoId);
            throw new ResourceNotFoundException(messages);
        }
        return todo;
    }

    @Override
    @Transactional(readOnly = true)
    public Collection<Todo> findAll() {
        return todoRepository.findAll();
    }

    @Override
    public Todo create(Todo todo) {
        long unfinishedCount = todoRepository.countByFinished(false);
        if (unfinishedCount >= MAX_UNFINISHED_COUNT) {
            ResultMessages messages = ResultMessages.error();
            messages.add("E001", MAX_UNFINISHED_COUNT);
            throw new BusinessException(messages);
        }

        String todoId = UUID.randomUUID().toString();
        Date createdAt = new Date();

        todo.setTodoId(todoId);
        todo.setCreatedAt(createdAt);
        todo.setFinished(false);

        todoRepository.save(todo);

        return todo;
    }

    @Override
    public Todo finish(String todoId) {
        Todo todo = findOne(todoId);
        if (todo.isFinished()) {
            ResultMessages messages = ResultMessages.error();
            messages.add("E002", todoId);
            throw new BusinessException(messages);
        }
        todo.setFinished(true);
        todoRepository.save(todo);
        return todo;
    }

    @Override
    public void delete(String todoId) {
        Todo todo = findOne(todoId);
        todoRepository.delete(todo);
    }
}

7.1.3.7.2. エラーメッセージの定義

本チュートリアルでは、エラーコードをキーにプロパティファイルからエラーメッセージを取得する。
そのため、例外ハンドリングの実装を行う前に、エラーコードに対応するエラーメッセージを、メッセージ用のプロパティファイルに定義する。

処理結果用のエラーコードに対応するエラーメッセージを、メッセージ用のプロパティファイル(src/main/resources/i18n/application-messages.properties)に定義する。

  • application-messages.properties

ハイライトした部分のメッセージ定義を追加する。

e.xx.fw.5001 = Resource not found.

e.xx.fw.7001 = Illegal screen flow detected!
e.xx.fw.7002 = CSRF attack detected!

e.xx.fw.8001 = Business error occurred!

e.xx.fw.9001 = System error occurred!
e.xx.fw.9002 = Data Access error!

E001 = [E001] The count of un-finished Todo must not be over {0}.
E002 = [E002] The requested Todo is already finished. (id={0})
E400 = [E400] The requested Todo contains invalid values.
E404 = [E404] The requested Todo is not found. (id={0})
E500 = [E500] System error occurred.
E999 = [E999] Error occurred. Caused by : {0}
../_images/application-messages.png

入力チェック用のエラーコードに対応するエラーメッセージを、Bean Validationのメッセージ用のプロパティファイル(src/main/resources/ValidationMessages.properties)に定義する。

  • ValidationMessages.properties
デフォルトのメッセージは、メッセージの中に項目名が含まれないため、デフォルトのメッセージ定義を変更する。
本チュートリアルでは、TodoResourceクラスで使用しているルール(@NotNull@Size)に対応するメッセージのみ定義する。
javax.validation.constraints.NotNull.message = {0} may not be null.
javax.validation.constraints.Size.message    = {0} size must be between {min} and {max}.
../_images/validation-messages.png

7.1.3.7.3. エラーハンドリング用のクラスを格納するパッケージの作成

エラーハンドリング用のクラスを格納するためのパッケージを作成する。
本チュートリアルでは、todo.api.common.errorをエラーハンドリング用のクラスを格納するためのパッケージとする。
../_images/exception-package.png

7.1.3.7.4. REST APIのエラーハンドリングを行うクラスの作成

REST APIのエラーハンドリングは、Spring MVCから提供されているorg.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandlerを継承したクラスを作成し、@ControllerAdviceアノテーションを付与する方法でハンドリングする事を推奨する。
以下に、ResponseEntityExceptionHandlerを継承したtodo.api.common.error.RestGlobalExceptionHandlerクラスを作成する。
  • RestGlobalExceptionHandler.java
package todo.api.common.error;

import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

@ControllerAdvice
public class RestGlobalExceptionHandler extends ResponseEntityExceptionHandler {

}
../_images/exception-handlingclass.png

7.1.3.7.5. REST APIのエラー情報を保持するJavaBeanの作成

REST APIで発生したエラー情報を保持するクラスとして、ApiErrorクラスをtodo.api.common.errorパッケージに作成する。
ApiErrorクラスがJSONに変換されて、クライアントに応答される。
  • ApiError.java
package todo.api.common.error;

import java.util.ArrayList;
import java.util.List;

import org.codehaus.jackson.map.annotate.JsonSerialize;
import org.codehaus.jackson.map.annotate.JsonSerialize.Inclusion;

public class ApiError {

    private final String code;

    private final String message;

    @JsonSerialize(include = Inclusion.NON_EMPTY)
    private final String target;

    @JsonSerialize(include = Inclusion.NON_EMPTY)
    private final List<ApiError> details = new ArrayList<>();

    public ApiError(String code, String message) {
        this(code, message, null);
    }

    public ApiError(String code, String message, String target) {
        this.code = code;
        this.message = message;
        this.target = target;
    }

    public String getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }

    public String getTarget() {
        return target;
    }

    public List<ApiError> getDetails() {
        return details;
    }

    public void addDetail(ApiError detail) {
        details.add(detail);
    }

}
../_images/exception-apierror.png

7.1.3.7.6. HTTPレスポンスBODYにエラー情報を出力するための実装

ResponseEntityExceptionHandlerはデフォルトではHTTPステータス(400や500など)の設定のみを行い、HTTPレスポンスのBODYは設定しない。 そのため、handleExceptionInternalメソッドを以下のようにオーバーライドして、BODYを出力するように実装する。

  • RestGlobalExceptionHandler.java
package todo.api.common.error;

import javax.inject.Inject;

import org.springframework.context.MessageSource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

@ControllerAdvice
public class RestGlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Inject
    MessageSource messageSource;

    @Override
    protected ResponseEntity<Object> handleExceptionInternal(Exception ex,
            Object body, HttpHeaders headers, HttpStatus status,
            WebRequest request) {
        Object responseBody = body;
        if (body == null) {
            responseBody = createApiError(request, "E999", ex.getMessage());
        }
        return new ResponseEntity<Object>(responseBody, headers, status);
    }

    private ApiError createApiError(WebRequest request, String errorCode,
            Object... args) {
        return new ApiError(errorCode, messageSource.getMessage(errorCode,
                args, request.getLocale()));
    }

}
上記実装を行う事で、ResponseEntityExceptionHandlerでハンドリングされる例外については、HTTPレスポンスBODYにエラー情報が出力される。
ResponseEntityExceptionHandlerでハンドリングされる例外については、DefaultHandlerExceptionResolverで設定されるHTTPレスポンスコードについてを参照されたい。
HTTP Dev Clientを使用して、実装したエラーハンドリングの動作確認を行う。
Dev HTTP Clientを開いてURLに"localhost:8080/todo-api/api/v1/todos"を入力し、メソッドにPUTを指定してから、”Send”ボタンをクリックする。

“405 Method Not Allowed”のHTTPステータスが返却され、「RESPONSE」の「Body」には、エラー情報のJSONが表示される。

../_images/exception-genericerror.png

7.1.3.7.7. 入力エラーのエラーハンドリングの実装

入力エラーの種類は、

  • org.springframework.web.bind.MethodArgumentNotValidException
  • org.springframework.validation.BindException
  • org.springframework.http.converter.HttpMessageNotReadableException
  • org.springframework.beans.TypeMismatchException

となる。

本チュートリアルでは、MethodArgumentNotValidExceptionのエラーハンドリングの実装を行う。
MethodArgumentNotValidExceptionは、HTTPリクエストBODYに格納されているデータに入力エラーがあった場合に発生する例外である。
  • RestGlobalExceptionHandler.java
package todo.api.common.error;

import javax.inject.Inject;

import org.springframework.context.MessageSource;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

@ControllerAdvice
public class RestGlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Inject
    MessageSource messageSource;

    @Override
    protected ResponseEntity<Object> handleExceptionInternal(Exception ex,
            Object body, HttpHeaders headers, HttpStatus status,
            WebRequest request) {
        Object responseBody = body;
        if (body == null) {
            responseBody = createApiError(request, "E999", ex.getMessage());
        }
        return new ResponseEntity<Object>(responseBody, headers, status);
    }

    private ApiError createApiError(WebRequest request, String errorCode,
            Object... args) {
        return new ApiError(errorCode, messageSource.getMessage(errorCode,
                args, request.getLocale()));
    }

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatus status, WebRequest request) {
        ApiError apiError = createApiError(request, "E400");
        for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
            apiError.addDetail(createApiError(request, fieldError, fieldError
                    .getField()));
        }
        for (ObjectError objectError : ex.getBindingResult().getGlobalErrors()) {
            apiError.addDetail(createApiError(request, objectError, objectError
                    .getObjectName()));
        }
        return handleExceptionInternal(ex, apiError, headers, status, request);
    }

    private ApiError createApiError(WebRequest request,
            DefaultMessageSourceResolvable messageSourceResolvable,
            String target) {
        return new ApiError(messageSourceResolvable.getCode(), messageSource
                .getMessage(messageSourceResolvable, request.getLocale()), target);
    }

}
HTTP Dev Clientを使用して、実装したエラーハンドリングの動作確認を行う。
Dev HTTP Clientを開いてURLに"localhost:8080/todo-api/api/v1/todos"を入力し、メソッドにPOSTを指定する。
「REQUEST」の「BODY」に以下のJSONを入力する。
{
  "todoTitle": null
}

また、「REQUEST」の「HEADERS」の「+」ボタンでHTTPヘッダーを追加し、「Content-Type」に「application/json」を設定後、”Send”ボタンをクリックする。

“400 Bad Request”のHTTPステータスが返却され、「RESPONSE」の「Body」には、エラー情報のJSONが表示される。
todoTitleは必須項目なので、必須エラーが発生している。
../_images/exception-inputerror.png

7.1.3.7.8. 業務例外のエラーハンドリングの実装

RestGlobalExceptionHandlerorg.terasoluna.gfw.common.exception.BusinessExceptionをハンドリングするメソッドを追加して、業務例外をハンドリングする。

業務例外が発生した場合は、”409 Conflict”のHTTPステータスを設定する。

  • RestGlobalExceptionHandler.java
package todo.api.common.error;

import javax.inject.Inject;

import org.springframework.context.MessageSource;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import org.terasoluna.gfw.common.exception.BusinessException;
import org.terasoluna.gfw.common.exception.ResultMessagesNotificationException;
import org.terasoluna.gfw.common.message.ResultMessage;

@ControllerAdvice
public class RestGlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Inject
    MessageSource messageSource;

    @Override
    protected ResponseEntity<Object> handleExceptionInternal(Exception ex,
            Object body, HttpHeaders headers, HttpStatus status,
            WebRequest request) {
        Object responseBody = body;
        if (body == null) {
            responseBody = createApiError(request, "E999", ex.getMessage());
        }
        return new ResponseEntity<Object>(responseBody, headers, status);
    }

    private ApiError createApiError(WebRequest request, String errorCode,
            Object... args) {
        return new ApiError(errorCode, messageSource.getMessage(errorCode,
                args, request.getLocale()));
    }

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatus status, WebRequest request) {
        ApiError apiError = createApiError(request, "E400");
        for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
            apiError.addDetail(createApiError(request, fieldError, fieldError
                    .getField()));
        }
        for (ObjectError objectError : ex.getBindingResult().getGlobalErrors()) {
            apiError.addDetail(createApiError(request, objectError, objectError
                    .getObjectName()));
        }
        return handleExceptionInternal(ex, apiError, headers, status, request);
    }

    private ApiError createApiError(WebRequest request,
            DefaultMessageSourceResolvable messageSourceResolvable,
            String target) {
        return new ApiError(messageSourceResolvable.getCode(), messageSource
                .getMessage(messageSourceResolvable, request.getLocale()), target);
    }

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<Object> handleBusinessException(BusinessException ex,
            WebRequest request) {
        return handleResultMessagesNotificationException(ex, null,
                HttpStatus.CONFLICT, request);
    }

    private ResponseEntity<Object> handleResultMessagesNotificationException(
            ResultMessagesNotificationException ex, HttpHeaders headers,
            HttpStatus status, WebRequest request) {
        ResultMessage message = ex.getResultMessages().iterator().next();
        ApiError apiError = createApiError(request, message.getCode(), message
                .getArgs());
        return handleExceptionInternal(ex, apiError, headers, status, request);
    }

}
HTTP Dev Clientを使用して、実装したエラーハンドリングの動作確認を行う。
Dev HTTP Clientを開いてURLに"localhost:8080/todo-api/api/v1/todos/{todoId}"を入力し、メソッドにPUTを指定する。
{todoId}の部分は実際のIDを入れる必要があるので、POST TodosまたはGET Todosを実行してResponse中のtodoIdをコピーして貼り付けてから、”Send”ボタンを2回クリックする。
未完了状態のTodoのtodoIdを指定すること。

2回目のリクエストに対するレスポンスとして、”409 Conflict”のHTTPステータスが返却され、「RESPONSE」の「Body」には、エラー情報のJSONが表示される。

../_images/exception-businesserror.png

7.1.3.7.9. リソース未検出例外のエラーハンドリングの実装

RestGlobalExceptionHandlerorg.terasoluna.gfw.common.exception.ResourceNotFoundExceptionをハンドリングするメソッドを追加して、リソース未検出例外をハンドリングする。

リソース未検出例外が発生した場合、”404 NotFound”のHTTPステータスを設定する。
  • RestGlobalExceptionHandler.java
package todo.api.common.error;

import javax.inject.Inject;

import org.springframework.context.MessageSource;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import org.terasoluna.gfw.common.exception.BusinessException;
import org.terasoluna.gfw.common.exception.ResourceNotFoundException;
import org.terasoluna.gfw.common.exception.ResultMessagesNotificationException;
import org.terasoluna.gfw.common.message.ResultMessage;

@ControllerAdvice
public class RestGlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Inject
    MessageSource messageSource;

    @Override
    protected ResponseEntity<Object> handleExceptionInternal(Exception ex,
            Object body, HttpHeaders headers, HttpStatus status,
            WebRequest request) {
        Object responseBody = body;
        if (body == null) {
            responseBody = createApiError(request, "E999", ex.getMessage());
        }
        return new ResponseEntity<Object>(responseBody, headers, status);
    }

    private ApiError createApiError(WebRequest request, String errorCode,
            Object... args) {
        return new ApiError(errorCode, messageSource.getMessage(errorCode,
                args, request.getLocale()));
    }

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatus status, WebRequest request) {
        ApiError apiError = createApiError(request, "E400");
        for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
            apiError.addDetail(createApiError(request, fieldError, fieldError
                    .getField()));
        }
        for (ObjectError objectError : ex.getBindingResult().getGlobalErrors()) {
            apiError.addDetail(createApiError(request, objectError, objectError
                    .getObjectName()));
        }
        return handleExceptionInternal(ex, apiError, headers, status, request);
    }

    private ApiError createApiError(WebRequest request,
            DefaultMessageSourceResolvable messageSourceResolvable,
            String target) {
        return new ApiError(messageSourceResolvable.getCode(), messageSource
                .getMessage(messageSourceResolvable, request.getLocale()), target);
    }

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<Object> handleBusinessException(BusinessException ex,
            WebRequest request) {
        return handleResultMessagesNotificationException(ex, null,
                HttpStatus.CONFLICT, request);
    }

    private ResponseEntity<Object> handleResultMessagesNotificationException(
            ResultMessagesNotificationException ex, HttpHeaders headers,
            HttpStatus status, WebRequest request) {
        ResultMessage message = ex.getResultMessages().iterator().next();
        ApiError apiError = createApiError(request, message.getCode(), message
                .getArgs());
        return handleExceptionInternal(ex, apiError, headers, status, request);
    }

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<Object> handleResourceNotFoundException(
            ResourceNotFoundException ex, WebRequest request) {
        return handleResultMessagesNotificationException(ex, null,
                HttpStatus.NOT_FOUND, request);
    }

}
HTTP Dev Clientを使用して、実装したエラーハンドリングの動作確認を行う。
Dev HTTP Clientを開いてURLに"localhost:8080/todo-api/api/v1/todos/{todoId}"を入力し、メソッドにGETを指定する。
{todoId}の部分には存在しないIDを指定して、”Send”ボタンをクリックする。

“404 Not Found”のHTTPステータスが返却され、「RESPONSE」の「Body」には、エラー情報のJSONが表示される。

../_images/exception-notfound.png

7.1.3.7.10. システム例外のエラーハンドリングの実装

最後に、RestGlobalExceptionHandlerjava.lang.Exceptionをハンドリングするメソッドを追加して、システム例外をハンドリングする。

システム例外が発生した場合、”500 InternalServerError”のHTTPステータスを設定する。

  • RestGlobalExceptionHandler.java
package todo.api.common.error;

import javax.inject.Inject;

import org.springframework.context.MessageSource;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import org.terasoluna.gfw.common.exception.BusinessException;
import org.terasoluna.gfw.common.exception.ResourceNotFoundException;
import org.terasoluna.gfw.common.exception.ResultMessagesNotificationException;
import org.terasoluna.gfw.common.message.ResultMessage;

@ControllerAdvice
public class RestGlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Inject
    MessageSource messageSource;

    @Override
    protected ResponseEntity<Object> handleExceptionInternal(Exception ex,
            Object body, HttpHeaders headers, HttpStatus status,
            WebRequest request) {
        Object responseBody = body;
        if (body == null) {
            responseBody = createApiError(request, "E999", ex.getMessage());
        }
        return new ResponseEntity<Object>(responseBody, headers, status);
    }

    private ApiError createApiError(WebRequest request, String errorCode,
            Object... args) {
        return new ApiError(errorCode, messageSource.getMessage(errorCode,
                args, request.getLocale()));
    }

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatus status, WebRequest request) {
        ApiError apiError = createApiError(request, "E400");
        for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
            apiError.addDetail(createApiError(request, fieldError, fieldError
                    .getField()));
        }
        for (ObjectError objectError : ex.getBindingResult().getGlobalErrors()) {
            apiError.addDetail(createApiError(request, objectError, objectError
                    .getObjectName()));
        }
        return handleExceptionInternal(ex, apiError, headers, status, request);
    }

    private ApiError createApiError(WebRequest request,
            DefaultMessageSourceResolvable messageSourceResolvable,
            String target) {
        return new ApiError(messageSourceResolvable.getCode(), messageSource
                .getMessage(messageSourceResolvable, request.getLocale()), target);
    }

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<Object> handleBusinessException(BusinessException ex,
            WebRequest request) {
        return handleResultMessagesNotificationException(ex, null,
                HttpStatus.CONFLICT, request);
    }

    private ResponseEntity<Object> handleResultMessagesNotificationException(
            ResultMessagesNotificationException ex, HttpHeaders headers,
            HttpStatus status, WebRequest request) {
        ResultMessage message = ex.getResultMessages().iterator().next();
        ApiError apiError = createApiError(request, message.getCode(), message
                .getArgs());
        return handleExceptionInternal(ex, apiError, headers, status, request);
    }

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<Object> handleResourceNotFoundException(
            ResourceNotFoundException ex, WebRequest request) {
        return handleResultMessagesNotificationException(ex, null,
                HttpStatus.NOT_FOUND, request);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<Object> handleSystemError(Exception ex,
            WebRequest request) {
        ApiError apiError = createApiError(request, "E500");
        return handleExceptionInternal(ex, apiError, null,
                HttpStatus.INTERNAL_SERVER_ERROR, request);
    }

}
HTTP Dev Clientを使用して、実装したエラーハンドリングの動作確認を行う。
システムエラーを発生させるために、テーブルを未作成の状態でアプリケーションを起動させる。
  • todo-api-infra.properties
database=H2
#database.url=jdbc:h2:mem:todo-api;DB_CLOSE_DELAY=-1;INIT=create table if not exists todo(todo_id varchar(36) primary key, todo_title varchar(30), finished boolean, created_at timestamp)
database.url=jdbc:h2:mem:todo-api;DB_CLOSE_DELAY=-1
database.username=sa
database.password=
database.driverClassName=org.h2.Driver
# connection pool
cp.maxActive=96
cp.maxIdle=16
cp.minIdle=0
cp.maxWait=60000
Dev HTTP Clientを開いてURLに"localhost:8080/todo-api/api/v1/todos"を入力し、メソッドにGETを指定して、”Send”ボタンをクリックする。

“500 Internal Server Error”のHTTPステータスが返却され、「RESPONSE」の「Body」には、エラー情報のJSONが表示される。

../_images/exception-systemerror.png

Note

システムエラーが発生した場合、クライアントへ返却するメッセージは、エラー原因が特定されないシンプルなエラーメッセージを設定することを推奨する。 エラー原因が特定できるメッセージを設定してしまうと、システムの脆弱性をクライアントに公開する可能性があり、セキュリティー上問題がある。

エラー原因は、エラー解析用にログに出力すればよい。 Blankプロジェクトのデフォルトの設定では、共通ライブラリから提供しているExceptionLoggerによってログが出力されるようなっているため、ログを出力するための設定や実装は不要である。

ExceptionLoggerによって出力されるログは以下の通りである。 Todoテーブルが存在しない事が原因でシステムエラーが発生している事がわかる。

date:2014-04-04 15:15:07    thread:tomcat-http--3   X-Track:09689dcae9d04ad3b25650cba2ebbe3a    level:ERROR logger:o.t.gfw.common.exception.ExceptionLogger         message:[e.xx.fw.9001] SqlMapClient operation; bad SQL grammar []; nested exception is com.ibatis.common.jdbc.exception.NestedSQLException:
--- The error occurred while executing query.
--- Check the  SELECT todo_id,        todo_title,        finished,        created_at FROM   todo .
--- Check the SQL Statement (preparation failed).
--- Cause: org.h2.jdbc.JdbcSQLException: Table "TODO" not found; SQL statement:
 SELECT todo_id,        todo_title,        finished,        created_at FROM   todo  [42102-172]
org.springframework.jdbc.BadSqlGrammarException: SqlMapClient operation; bad SQL grammar []; nested exception is com.ibatis.common.jdbc.exception.NestedSQLException:
--- The error occurred while executing query.
--- Check the  SELECT todo_id,        todo_title,        finished,        created_at FROM   todo .
--- Check the SQL Statement (preparation failed).
--- Cause: org.h2.jdbc.JdbcSQLException: Table "TODO" not found; SQL statement:
 SELECT todo_id,        todo_title,        finished,        created_at FROM   todo  [42102-172]
    at org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator.doTranslate(SQLErrorCodeSQLExceptionTranslator.java:237) ~[spring-jdbc-3.2.4.RELEASE.jar:3.2.4.RELEASE]
    at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:72) ~[spring-jdbc-3.2.4.RELEASE.jar:3.2.4.RELEASE]
    at org.springframework.orm.ibatis.SqlMapClientTemplate.execute(SqlMapClientTemplate.java:206) ~[spring-orm-3.2.4.RELEASE.jar:3.2.4.RELEASE]
    at org.springframework.orm.ibatis.SqlMapClientTemplate.queryForList(SqlMapClientTemplate.java:296) ~[spring-orm-3.2.4.RELEASE.jar:3.2.4.RELEASE]
    at jp.terasoluna.fw.dao.ibatis.QueryDAOiBatisImpl.executeForObjectList(QueryDAOiBatisImpl.java:421) ~[terasoluna-ibatis-2.0.5.0.jar:2.0.5.0]
    at todo.domain.repository.todo.TodoRepositoryImpl.findAll(TodoRepositoryImpl.java:33) ~[TodoRepositoryImpl.class:na]
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.7.0_51]
    ...

    (omitted)

7.1.4. おわりに

このチュートリアルでは、以下の内容を学習した。

  • TERASOLUNA Global Frameworkによる基本的なRESTful Webサービスの構築方法
  • REST API(GET, POST, PUT, DELETE)を提供するControllerクラスの実装
  • JavaBeanとJSONの相互変換方法
  • エラーメッセージの定義方法
  • Spring MVCを使用した各種例外のハンドリング方法

ここでは、基本的なRESTful Webサービスの実装法について示した。 考え方の元となるアーキテクチャ・設計指針等について理解を深める為には、「RESTful Web Service」 を参照されたい。