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


11.3.1. はじめに

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

  • TERASOLUNA Server Framework for Java (5.x)による基本的なRESTful Webサービスの構築方法


11.3.1.2. 対象読者


11.3.1.3. 検証環境

本チュートリアルは以下の環境で動作確認している。
REST Clientとして、Google Chromeの拡張機能を使用するため、Web BrowserはGoogle Chromeを使用する。

種別

プロダクト

REST Client

Talend API Tester 25.10.2

上記以外のプロダクト

チュートリアル(Todoアプリケーション JSP編)または チュートリアル(Todoアプリケーション Thymeleaf編)と同様


11.3.2. 環境構築

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


11.3.2.1. Talend API Testerのインストール

RESTクライアントとして、Chromeの拡張機能である「Talend API Tester」をインストールする。

Talend API Testerにアクセスし、「Chromeに追加」を押下する。

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

「拡張機能を追加」を押下する。

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

Chromeの右上の拡張機能のマークを押下して拡張機能一覧を開くと、Talend API Testerが追加されている。

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

Talend API Testerをクリックする。
以下の画面が表示されるので、「Use Talend API Tester - Free Edition」を押下する。
この画面は、ブラウザのアドレスバーに「chrome-extension://aejoelaoggembcahagimdiliamlcdmfm/index.html」を入力する事で開く事もできる。
../_images/install-dev-http-client4.png

以下の画面が表示されれば、インストール完了となる。

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

11.3.2.2. プロジェクト作成

本チュートリアルでは、「チュートリアル(Todoアプリケーション JSP編)」または「チュートリアル(Todoアプリケーション Thymeleaf編)」で作成したプロジェクトに対して、RESTful Webサービスを追加する手順となっている。

そのため、「チュートリアル(Todoアプリケーション JSP編)」または「チュートリアル(Todoアプリケーション Thymeleaf編)」で作成したプロジェクトが残っていない場合は、再度「チュートリアル(Todoアプリケーション JSP編)」または「チュートリアル(Todoアプリケーション Thymeleaf編)」を実施してプロジェクトを作成してほしい。

Note

再度「チュートリアル(Todoアプリケーション JSP編)」または「チュートリアル(Todoアプリケーション Thymeleaf編)」を実施する場合は、ドメイン層の作成まで行えば本チュートリアルを進める事ができる。


11.3.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)をパス変数として扱っている。


11.3.3.1. API仕様

HTTPリクエストとレスポンスの具体例を用いて、本チュートリアルで作成するREST APIのインタフェース仕様を示す。
本質的ではないHTTPヘッダー等は例から除いている。

11.3.3.1.1. GET Todos

[リクエスト]

> GET /todo/api/v1/todos HTTP/1.1

[レスポンス]

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

< HTTP/1.1 200
< 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"}]

11.3.3.1.2. POST Todos

[リクエスト]

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

> POST /todo/api/v1/todos HTTP/1.1
> Content-Type: application/json
> Content-Length: 29
>
{"todoTitle": "Study Spring"}

[レスポンス]

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

< HTTP/1.1 201
< 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"}

11.3.3.1.3. GET Todo

[リクエスト]

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

[レスポンス]

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

< HTTP/1.1 200
< 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"}

11.3.3.1.4. PUT Todo

[リクエスト]

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

[レスポンス]

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

< HTTP/1.1 200
< 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"}

11.3.3.1.5. DELETE Todo

[リクエスト]

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

> DELETE /todo/api/v1/todos/9aef3ee3-30d4-4a7c-be4a-bc184ca1d558 HTTP/1.1

[レスポンス]

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

< HTTP/1.1 204

11.3.3.1.6. エラー応答

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

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

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

< HTTP/1.1 400
< 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
< 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
< 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
< Content-Type: application/json;charset=UTF-8
<
{"code":"E500","message":"[E500] System error occurred."}

11.3.3.2. REST API用のDispatcherServletを用意

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


11.3.3.2.1. web.xmlの修正

REST API用の設定を追加する。

src/main/webapp/WEB-INF/web.xml

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_0.xsd"
    version="6.0">

    <context-param>
        <param-name>logbackDisableServletContainerInitializer</param-name>
        <param-value>true</param-value>
    </context-param>

    <context-param>
        <param-name>contextClass</param-name>
        <param-value>
            org.springframework.web.context.support.AnnotationConfigWebApplicationContext
        </param-value>
    </context-param>

    <listener>
        <listener-class>ch.qos.logback.classic.servlet.LogbackServletContextListener</listener-class>
    </listener>

    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>
    <context-param>
        <param-name>contextConfigLocation</param-name>
        <!-- Root ApplicationContext -->
        <param-value>
            com.example.todo.config.app.ApplicationContextConfig
            com.example.todo.config.web.SpringSecurityConfig
        </param-value>
    </context-param>

    <listener>
        <listener-class>org.terasoluna.gfw.web.logging.HttpSessionEventLoggingListener</listener-class>
    </listener>

    <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>contextClass</param-name>
          <param-value>
            org.springframework.web.context.support.AnnotationConfigWebApplicationContext
          </param-value>
        </init-param>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <!-- ApplicationContext for Spring MVC (REST) -->
            <param-value>com.example.todo.config.web.SpringMvcRestConfig</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>contextClass</param-name>
            <param-value>
                org.springframework.web.context.support.AnnotationConfigWebApplicationContext
            </param-value>
        </init-param>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <!-- ApplicationContext for Spring MVC -->
            <param-value>com.example.todo.config.web.SpringMvcConfig</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>
        <cookie-config>
            <http-only>true</http-only>
            <!-- <secure>true</secure> -->
        </cookie-config>
        <tracking-mode>COOKIE</tracking-mode>
    </session-config>

</web-app>

項番

説明

(1)
初期化パラメータ「contextConfigLocation」に、REST用のSpring MVC設定ファイルを指定する。
本チュートリアルでは、クラスパス上にある「com.example.todo.config.web.SpringMvcRestConfig」を指定している。
(2)
<url-pattern>要素に、REST API用のDispatcherServletにマッピングするURLのパターンを指定する。
本チュートリアルでは、/api/v1/から始まる場合はリクエストをREST APIへのリクエストとしてREST API用のDispatcherServletへマッピングしている。

11.3.3.2.2. spring-mvc-restの作成

REST用のSpring MVC設定ファイルを作成する。
REST用のSpring MVC設定ファイルは以下のような定義となる。
../_images/add-spring-mvc-rest_JavaConfig.png

src/main/java/com/example/todo/config/web/SpringMvcRestConfig.java

package com.example.todo.config.web;

import java.util.List;

import org.springframework.aop.Advisor;
import org.springframework.aop.aspectj.AspectJExpressionPointcut;
import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.context.support.PropertySourcesPlaceholderConfigurer;
import org.springframework.core.io.Resource;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.terasoluna.gfw.common.exception.ExceptionLogger;
import org.terasoluna.gfw.web.exception.HandlerExceptionResolverLoggingInterceptor;
import org.terasoluna.gfw.web.logging.TraceLoggingInterceptor;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.util.StdDateFormat;

/**
 * Configure SpringMVCRest.
 */
@ComponentScan(basePackages = { "com.example.todo.api" }) // (5)
@EnableAspectJAutoProxy // (7)
@EnableWebMvc
@Configuration
public class SpringMvcRestConfig implements WebMvcConfigurer {

    /**
     * Configure {@link PropertySourcesPlaceholderConfigurer} bean.
     * @param properties Property files to be read
     * @return Bean of configured {@link PropertySourcesPlaceholderConfigurer}
     */
    // (1)
    @Bean
    public static PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer(
            @Value("classpath*:/META-INF/spring/*.properties") Resource... properties) {
        PropertySourcesPlaceholderConfigurer bean = new PropertySourcesPlaceholderConfigurer();
        bean.setLocations(properties);
        return bean;
    }

    /**
     * Configure {@link MappingJackson2HttpMessageConverter}.
     * @return configured {@link MappingJackson2HttpMessageConverter}
     */
    // (2)
    @Bean("jsonMessageConverter")
    public MappingJackson2HttpMessageConverter jsonMessageConverter() {
        MappingJackson2HttpMessageConverter bean = new MappingJackson2HttpMessageConverter();
        bean.setObjectMapper(objectMapper());
        return bean;
    }

    /**
     * Configure {@link ObjectMapper}.
     * @return build to Jackson2ObjectMapper
     */
    @Bean("objectMapper")
    public ObjectMapper objectMapper() {
        // (3)
        return Jackson2ObjectMapperBuilder.json().build().setDateFormat(
                new StdDateFormat());
    }

    /**
     * {@inheritDoc}
     */
    // (4)
    @Override
    public void configureMessageConverters(
            List<HttpMessageConverter<?>> converters) {
        // If you want to add a converter after adding the default converter,
        // use extendMessageConverters(List<HttpMessageConverter<?>> converters).
        converters.add(jsonMessageConverter());
    }

    /**
     * {@inheritDoc}
     */
    // (6)
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        addInterceptor(registry, traceLoggingInterceptor());
    }

    /**
     * Common processes used in #addInterceptors.
     * @param registry {@link InterceptorRegistry}
     * @param interceptor {@link HandlerInterceptor}
     */
    // (6)
    private void addInterceptor(InterceptorRegistry registry,
            HandlerInterceptor interceptor) {
        registry.addInterceptor(interceptor).addPathPatterns("/**")
                .excludePathPatterns("/resources/**", "/*/*.html");
    }

    /**
     * Configure {@link TraceLoggingInterceptor} bean.
     * @return Bean of configured {@link TraceLoggingInterceptor}
     */
    // (6)
    @Bean
    public TraceLoggingInterceptor traceLoggingInterceptor() {
        return new TraceLoggingInterceptor();
    }

    /**
     * Configure messages logging AOP.
     * @param exceptionLogger Bean defined by ApplicationContext#exceptionLogger
     * @see com.example.todo.config.app.ApplicationContext#exceptionLogger()
     * @return Bean of configured {@link HandlerExceptionResolverLoggingInterceptor}
     */
    // (7)
    @Bean(name = "handlerExceptionResolverLoggingInterceptor")
    public HandlerExceptionResolverLoggingInterceptor handlerExceptionResolverLoggingInterceptor(
            ExceptionLogger exceptionLogger) {
        HandlerExceptionResolverLoggingInterceptor bean = new HandlerExceptionResolverLoggingInterceptor();
        bean.setExceptionLogger(exceptionLogger);
        return bean;
    }

    /**
     * Configure messages logging AOP advisor.
     * @param handlerExceptionResolverLoggingInterceptor Bean defined by #handlerExceptionResolverLoggingInterceptor
     * @see #handlerExceptionResolverLoggingInterceptor(ExceptionLogger)
     * @return Advisor configured for PointCut
     */
    // (7)
    @Bean
    public Advisor handlerExceptionResolverLoggingInterceptorAdvisor(
            HandlerExceptionResolverLoggingInterceptor handlerExceptionResolverLoggingInterceptor) {
        AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
        pointcut.setExpression(
                "execution(* org.springframework.web.servlet.HandlerExceptionResolver.resolveException(..))");
        return new DefaultPointcutAdvisor(pointcut, handlerExceptionResolverLoggingInterceptor);
    }
}

項番

説明

(1)

アプリケーション層のコンポーネントでプロパティファイルに定義されている値を参照する必要がある場合は、PropertySourcesPlaceholderConfigurer.classを使用してプロパティファイルを読み込む必要がある。

(2)

Controllerの引数と返り値で扱うJavaBeanをシリアライズ/デシリアライズするためのクラス(org.springframework.http.converter.HttpMessageConverter)を設定する。 ここではJSON形式を扱うMappingJackson2HttpMessageConverterを使用する。

MappingJackson2HttpMessageConverterobjectMapperプロパティに、Jacksonより提供されているObjectMapper(「JSON <-> JavaBean」の変換を行うためのコンポーネント)を指定する。 本チュートリアルでは、日時型のフォーマットをカスタマイズしたObjectMapperを指定している。 カスタマイズする必要がない場合はobjectMapperプロパティは省略可能である。

(3)

ObjectMapperdateFormatプロパティに、日時型フィールドの形式を指定する。

本チュートリアルでは、java.util.Dateオブジェクトをシリアライズする際にISO-8601形式とする。 Dateオブジェクトをシリアライズする際にISO-8601形式にする場合は、com.fasterxml.jackson.databind.util.StdDateFormatを設定する事で実現する事ができる。

(4)

HttpMessageConverter.classに、MappingJackson2HttpMessageConverterを登録する。

(5)

REST API用のパッケージ配下のコンポーネントをスキャンする。

本チュートリアルでは、REST API用のパッケージをcom.example.todo.apiにしている。 画面遷移用のControllerは、appパッケージ配下に格納していたが、REST API用のControllerは、apiパッケージ配下に格納する事を推奨する。

(6)

Controllerの処理開始、終了時の情報をログに出力するために、共通ライブラリから提供されているTraceLoggingInterceptorを定義する。

(7)

Spring MVCのフレームワークでハンドリングされた例外を、ログ出力するためのAOP定義を指定する。


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

ブランクプロジェクトでは、CSRF対策といった、Spring Securityのセキュリティ対策機能が有効になっている。
REST APIを使って構築するWebアプリケーションでも、セキュリティ対策機能は必要である。ただし、本チュートリアルの目的として、
セキュリティ対策の話題は本質的ではないため、機能を無効化し、説明も割愛する。
以下の設定を追加する事で、Spring Securityのセキュリティ対策機能を無効化することができる。

src/main/java/com/example/todo/config/web/SpringSecurityConfig.java

package com.example.todo.config.web;

import java.util.LinkedHashMap;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
import org.springframework.security.web.access.DelegatingAccessDeniedHandler;
import org.springframework.security.web.access.expression.DefaultWebSecurityExpressionHandler;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
import org.springframework.security.web.csrf.InvalidCsrfTokenException;
import org.springframework.security.web.csrf.MissingCsrfTokenException;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.terasoluna.gfw.security.web.logging.UserIdMDCPutFilter;

/**
 * Bean definition to configure SpringSecurity.
 */
@Configuration
@EnableWebSecurity
public class SpringSecurityConfig {

    /**
     * Configure ignore security pattern.
     * @return Bean of configured {@link WebSecurityCustomizer}
     */
    // (1)
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring().requestMatchers(
                new AntPathRequestMatcher("/resources/**"),
                new AntPathRequestMatcher("/api/v1/**"));
    }

    /**
     * Configure {@link SecurityFilterChain} bean.
     * @param http Builder class for setting up authentication and authorization
     * @return Bean of configured {@link SecurityFilterChain}
     * @throws Exception Exception that occurs when setting HttpSecurity
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.formLogin(Customizer.withDefaults());
        http.logout(Customizer.withDefaults());
        http.exceptionHandling(ex -> ex.accessDeniedHandler(
                accessDeniedHandler()));
        http.addFilterAfter(userIdMDCPutFilter(),
                AnonymousAuthenticationFilter.class);
        http.sessionManagement(Customizer.withDefaults());
        http.authorizeHttpRequests(authz -> authz.requestMatchers(
                new AntPathRequestMatcher("/**")).permitAll());

        return http.build();
    }

    /**
     * Configure {@link AccessDeniedHandler} bean.
     * @return Bean of configured {@link AccessDeniedHandler}
     */
    @Bean("accessDeniedHandler")
    public AccessDeniedHandler accessDeniedHandler() {
        LinkedHashMap<Class<? extends AccessDeniedException>, AccessDeniedHandler> errorHandlers = new LinkedHashMap<>();

        // Invalid CSRF authenticator error handler
        AccessDeniedHandlerImpl invalidCsrfTokenErrorHandler = new AccessDeniedHandlerImpl();
        invalidCsrfTokenErrorHandler.setErrorPage(
                "/WEB-INF/views/common/error/invalidCsrfTokenError.jsp");
        errorHandlers.put(InvalidCsrfTokenException.class,
                invalidCsrfTokenErrorHandler);

        // Missing CSRF authenticator error handler
        AccessDeniedHandlerImpl missingCsrfTokenErrorHandler = new AccessDeniedHandlerImpl();
        missingCsrfTokenErrorHandler.setErrorPage(
                "/WEB-INF/views/common/error/missingCsrfTokenError.jsp");
        errorHandlers.put(MissingCsrfTokenException.class,
                missingCsrfTokenErrorHandler);

        // Default error handler
        AccessDeniedHandlerImpl defaultErrorHandler = new AccessDeniedHandlerImpl();
        defaultErrorHandler.setErrorPage(
                "/WEB-INF/views/common/error/accessDeniedError.jsp");

        return new DelegatingAccessDeniedHandler(errorHandlers, defaultErrorHandler);
    }

    /**
     * Configure {@link DefaultWebSecurityExpressionHandler} bean.
     * @return Bean of configured {@link DefaultWebSecurityExpressionHandler}
     */
    @Bean("webSecurityExpressionHandler")
    public DefaultWebSecurityExpressionHandler webSecurityExpressionHandler() {
        return new DefaultWebSecurityExpressionHandler();
    }

    /**
     * Configure {@link UserIdMDCPutFilter} bean.
     * @return Bean of configured {@link UserIdMDCPutFilter}
     */
    @Bean("userIdMDCPutFilter")
    public UserIdMDCPutFilter userIdMDCPutFilter() {
        return new UserIdMDCPutFilter();
    }
}

項番

説明

(1)
REST API用のSpring Securityのセキュリティ機能を無効にする定義を追加する。
WebSecurity.classignoringメソッドに、REST API用のリクエストパスのURLパターンを指定している。
本チュートリアルでは/api/v1/で始まるリクエストパスをREST API用のリクエストパスとして扱う。

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

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

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

Note

作成したパッケージに格納するクラスは、通常以下の3種類となる。

作成するクラスのクラス名は、以下のネーミングルールとする事を推奨する。

  • [リソース名]Resource

  • [リソース名]RestController

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

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

  • TodoResource

  • TodoRestController

を作成する。

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


11.3.3.5. Resourceクラスの作成

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

src/main/java/com/example/todo/api/todo/TodoResource.java

package com.example.todo.api.todo;

import java.io.Serializable;
import java.util.Date;

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public class TodoResource implements Serializable {

    private static final long serialVersionUID = 1L;

    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;
    }
}

Note

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

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

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

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


11.3.3.6. マッパーインタフェースの作成

Beanマッピングのマッパーインタフェースを作成する。

src/main/java/com/example/todo/api/todo/TodoMapper.java

package com.example.todo.api.todo;

import org.mapstruct.Mapper;

import com.example.todo.domain.model.Todo;

@Mapper
public interface TodoMapper {

    TodoResource map(Todo todo);

    Todo map(TodoResource todoResource);
}

Note

マッパーインタフェース追加後、以下のようなビルドエラーが発生する場合がある。

Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.example.todo.app.todo.TodoMapper'

この場合は、プロジェクト名を右クリックし、「Run As」->「Maven build」をクリックする。

Goalsに「compile」を指定し「Run」をクリックする。

../_images/mvnBuild.png

ビルドが成功した後、プロジェクト名を右クリックし、「Run As」->「Maven install」をクリックする。


11.3.3.7. Controllerクラスの作成

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

src/main/java/com/example/todo/api/todo/TodoRestController.java

package com.example.todo.api.todo;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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

}

項番

説明

(1)
@RestControllerを指定する。
@RestControllerの詳細については、RestControllerクラスの作成を参照されたい。
(2)
リソースのパスを指定する。
/api/v1/の部分はweb.xmlに定義しているため、この設定を行うことで/<contextPath>/api/v1/todosというパスにマッピングされる。

11.3.3.7.1. GET Todosの実装

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

src/main/java/com/example/todo/api/todo/TodoRestController.java

package com.example.todo.api.todo;

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

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

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

import jakarta.inject.Inject;

@RestController
@RequestMapping("todos")
public class TodoRestController {

    @Inject
    TodoService todoService;

    @Inject
    TodoMapper beanMapper;

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

}

項番

説明

(1)
メソッドがGETのリクエストを処理するために、@GetMappingアノテーションを設定する。
(2)
応答するHTTPステータスコードを@ResponseStatusアノテーションに指定する。
HTTPステータスとして、”200 OK”を設定するため、value属性にはHttpStatus.OKを設定する。
(3)
TodoServicefindAllメソッドから返却されたTodoオブジェクトを、応答するJSONを表現するTodoResource型のオブジェクトに変換する。
TodoTodoResourceの変換処理は、Mapstructを使うと便利である。
(4)
List<TodoResource>オブジェクトを返却することで、spring-mvc-rest.xmlまたはSpringMvcRestConfig.javaに定義したMappingJackson2HttpMessageConverterによってJSONにシリアライズされる。

Application Serverを起動し、実装したAPIの動作確認を行う。

REST API(Get Todos)にアクセスする。
Talend API Testerを開いてURLにlocalhost:8080/todo/api/v1/todosを入力し、メソッドにGETを指定して、”Send”ボタンをクリックする。
../_images/get-todos1.png

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

11.3.3.7.2. POST Todosの実装

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

src/main/java/com/example/todo/api/todo/TodoRestController.java

package com.example.todo.api.todo;

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

import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

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

import jakarta.inject.Inject;

@RestController
@RequestMapping("todos")
public class TodoRestController {

    @Inject
    TodoService todoService;

    @Inject
    TodoMapper beanMapper;

    @GetMapping
    @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));
        }
        return todoResources;
    }

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

}

項番

説明

(1)
メソッドがPOSTのリクエストを処理するために、@PostMappingアノテーションを設定する。
(2)
応答するHTTPステータスコードを@ResponseStatusアノテーションに指定する。
HTTPステータスとして、”201 Created”を設定するため、value属性にはHttpStatus.CREATEDを設定する。
(3)
HTTPリクエストのBody(JSON)をJavaBeanにマッピングするために、@RequestBodyアノテーションをマッピング対象のTodoResourceクラスに付与する。
また、入力チェックするために@Validatedも付与する。例外ハンドリングは別途行う必要がある。
(4)
TodoResourceTodoクラスに変換後、TodoServicecreateメソッドを実行し、Todoリソースを新規作成する。
(5)
TodoServicecreateメソッドによって新規作成されたTodoオブジェクトを、応答するJSONを表現するTodoResource型に変換する。
(6)
TodoResourceオブジェクトを返却することで、spring-mvc-rest.xmlまたはSpringMvcRestConfig.javaに定義したMappingJackson2HttpMessageConverterによってJSONにシリアライズされる。

Talend API Testerを使用して、実装したAPIの動作確認を行う。
Talend API Testerを開いてURLにlocalhost:8080/todo/api/v1/todosを入力し、メソッドにPOSTを指定する。
「REQUEST」の「BODY」に以下のJSONを入力する。
{
  "todoTitle": "Hello World!"
}

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

../_images/post-todos1.png

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

../_images/post-todos2.png

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

../_images/get-todos3.png

11.3.3.7.3. GET Todoの実装

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

findOneメソッドの定義を追加する。
src/main/java/com/example/todo/domain/service/todo/TodoService.java
package com.example.todo.domain.service.todo;

import java.util.Collection;

import com.example.todo.domain.model.Todo;

public interface TodoService {
    Todo findOne(String todoId);

    Collection<Todo> findAll();

    Todo create(Todo todo);

    Todo finish(String todoId);

    void delete(String todoId);
}

findOneメソッド呼び出し時に開始されるトランザクションを読み取り専用に設定し、アクセス修飾子をpublicに変更してfindAllメソッドの上に移動する。
src/main/java/com/example/todo/domain/service/todo/TodoServiceImpl.java
package com.example.todo.domain.service.todo;

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

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 com.example.todo.domain.model.Todo;
import com.example.todo.domain.repository.todo.TodoRepository;

import jakarta.inject.Inject;

@Service
@Transactional
public class TodoServiceImpl implements TodoService {

    private static final long MAX_UNFINISHED_COUNT = 5;

    @Inject
    TodoRepository todoRepository;

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

    @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.create(todo);
        /* REMOVE THIS LINE IF YOU USE JPA
            todoRepository.save(todo);
           REMOVE THIS LINE IF YOU USE JPA */

        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.update(todo);
        /* REMOVE THIS LINE IF YOU USE JPA
            todoRepository.save(todo);
           REMOVE THIS LINE IF YOU USE JPA */
        return todo;
    }

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

Todoリソースを一件取得するAPI(GET Todo)の処理を、TodoRestControllergetTodoメソッドに実装する。
src/main/java/com/example/todo/api/todo/TodoRestController.java
package com.example.todo.api.todo;

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

import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

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

import jakarta.inject.Inject;

@RestController
@RequestMapping("todos")
public class TodoRestController {

    @Inject
    TodoService todoService;

    @Inject
    TodoMapper beanMapper;

    @GetMapping
    @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));
        }
        return todoResources;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public TodoResource postTodos(
            @RequestBody @Validated TodoResource todoResource) {
        Todo createdTodo = todoService.create(beanMapper.map(todoResource));
        TodoResource createdTodoResponse = beanMapper.map(createdTodo);
        return createdTodoResponse;
    }

    @GetMapping("{todoId}") // (1)
    @ResponseStatus(HttpStatus.OK)
    public TodoResource getTodo(@PathVariable("todoId") String todoId) { // (2)
        Todo todo = todoService.findOne(todoId); // (3)
        TodoResource todoResource = beanMapper.map(todo);
        return todoResource;
    }

}

項番

説明

(1)
メソッドがGETのリクエストを処理するために、@GetMappingアノテーションを設定する。
パスからtodoIdを取得するために、value属性にパス変数を指定する。
(2)
@PathVariableアノテーションのvalue属性に、todoIdを取得するためのパス変数名を指定する。
(3)
パス変数から取得したtodoIdを使用して、Todoリソースを一件取得する。

Talend API Testerを使用して、実装したAPIの動作確認を行う。
Talend API Testerを開いてURLにlocalhost:8080/todo/api/v1/todos/{todoId}を入力し、メソッドにGETを指定する。
{todoId}の部分は実際のIDを入れる必要があるので、POST TodosまたはGET Todosを実行してResponse中のtodoIdをコピーして貼り付けてから、”Send”ボタンをクリックする。

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

../_images/get-todo1.png

11.3.3.7.4. PUT Todoの実装

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

src/main/java/com/example/todo/api/todo/TodoRestController.java

package com.example.todo.api.todo;

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

import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

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

import jakarta.inject.Inject;

@RestController
@RequestMapping("todos")
public class TodoRestController {

    @Inject
    TodoService todoService;

    @Inject
    TodoMapper beanMapper;

    @GetMapping
    @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));
        }
        return todoResources;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public TodoResource postTodos(
            @RequestBody @Validated TodoResource todoResource) {
        Todo createdTodo = todoService.create(beanMapper.map(todoResource));
        TodoResource createdTodoResponse = beanMapper.map(createdTodo);
        return createdTodoResponse;
    }

    @GetMapping("{todoId}")
    @ResponseStatus(HttpStatus.OK)
    public TodoResource getTodo(@PathVariable("todoId") String todoId) {
        Todo todo = todoService.findOne(todoId);
        TodoResource todoResource = beanMapper.map(todo);
        return todoResource;
    }

    @PutMapping("{todoId}") // (1)
    @ResponseStatus(HttpStatus.OK)
    public TodoResource putTodo(@PathVariable("todoId") String todoId) { // (2)
        Todo finishedTodo = todoService.finish(todoId); // (3)
        TodoResource finishedTodoResource = beanMapper.map(finishedTodo);
        return finishedTodoResource;
    }

}

項番

説明

(1)
メソッドがPUTのリクエストを処理するために、@PutMappingアノテーションを設定する。
パスからtodoIdを取得するために、value属性にパス変数を指定する。
(2)
@PathVariableアノテーションのvalue属性に、todoIdを取得するためのパス変数名を指定する。
(3)
パス変数から取得したtodoIdを使用して、Todoリソースを完了状態へ更新する。

Talend API Testerを使用して、実装したAPIの動作確認を行う。
Talend API Testerを開いてURLにlocalhost:8080/todo/api/v1/todos/{todoId}を入力し、メソッドにPUTを指定する。
{todoId}の部分は実際のIDを入れる必要があるので、POST TodosまたはGET Todosを実行してResponse中のtodoIdをコピーして貼り付けてから、”Send”ボタンをクリックする。
../_images/put-todo1.png

“200”のHTTPステータスが返却され、「RESPONSE」の「Body」に更新されたTodoリソースのJSONが表示される。
finishedtrueに更新されている。
../_images/put-todo2.png

11.3.3.7.5. DELETE Todoの実装

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

src/main/java/com/example/todo/api/todo/TodoRestController.java

package com.example.todo.api.todo;

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

import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

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

import jakarta.inject.Inject;

@RestController
@RequestMapping("todos")
public class TodoRestController {

    @Inject
    TodoService todoService;

    @Inject
    TodoMapper beanMapper;

    @GetMapping
    @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));
        }
        return todoResources;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public TodoResource postTodos(
            @RequestBody @Validated TodoResource todoResource) {
        Todo createdTodo = todoService.create(beanMapper.map(todoResource));
        TodoResource createdTodoResponse = beanMapper.map(createdTodo);
        return createdTodoResponse;
    }

    @GetMapping("{todoId}")
    @ResponseStatus(HttpStatus.OK)
    public TodoResource getTodo(@PathVariable("todoId") String todoId) {
        Todo todo = todoService.findOne(todoId);
        TodoResource todoResource = beanMapper.map(todo);
        return todoResource;
    }

    @PutMapping("{todoId}")
    @ResponseStatus(HttpStatus.OK)
    public TodoResource putTodo(@PathVariable("todoId") String todoId) {
        Todo finishedTodo = todoService.finish(todoId);
        TodoResource finishedTodoResource = beanMapper.map(finishedTodo);
        return finishedTodoResource;
    }

    @DeleteMapping("{todoId}") // (1)
    @ResponseStatus(HttpStatus.NO_CONTENT) // (2)
    public void deleteTodo(@PathVariable("todoId") String todoId) { // (3)
        todoService.delete(todoId); // (4)
    }

}

項番

説明

(1)
メソッドがDELETEのリクエストを処理するために、@DeleteMappingアノテーションを設定する。
パスからtodoIdを取得するために、value属性にパス変数を指定する。
(2)
応答するHTTPステータスコードを@ResponseStatusアノテーションに指定する。
HTTPステータスとして、”204 No Content”を設定するため、value属性にはHttpStatus.NO_CONTENTを設定する。
(3)
DELETEの場合は返却するコンテンツがないため、返り値の型をvoidとする。
(4)
パス変数から取得したtodoIdを使用して、Todoリソースを削除する。

Talend API Testerを使用して、実装したAPIの動作確認を行う。
Talend API Testerを開いてURLにlocalhost:8080/todo/api/v1/todos/{todoId}を入力し、メソッドにDELETEを指定する。
{todoId}の部分は実際のIDを入れる必要があるので、POST TodosまたはGET Todosを実行してResponse中のtodoIdをコピーして貼り付けてから、”Send”ボタンをクリックする。
../_images/delete-todo1.png

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

../_images/delete-todo2.png

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

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

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

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

本チュートリアルでは、エラーコードをキーにプロパティファイルからエラーメッセージを取得する。
そのため、例外ハンドリングの実装を行う前に、チュートリアル(Todoアプリケーション JSP編)で作成したServiceクラスの実装を以下のように変更する。
ハードコーディングされていたエラーメッセージの代わりに、エラーコードを指定するように変更する。
src/main/java/com/example/todo/domain/service/todo/TodoServiceImpl.java
package com.example.todo.domain.service.todo;

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

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 com.example.todo.domain.model.Todo;
import com.example.todo.domain.repository.todo.TodoRepository;

import jakarta.inject.Inject;

@Service
@Transactional
public class TodoServiceImpl implements TodoService {

    private static final long MAX_UNFINISHED_COUNT = 5;

    @Inject
    TodoRepository todoRepository;

    @Override
    @Transactional(readOnly = true)
    public Todo findOne(String todoId) {
        return todoRepository.findById(todoId).orElseThrow(() -> {
            ResultMessages messages = ResultMessages.error();
            messages.add("E404", todoId);
            return new ResourceNotFoundException(messages);
        });
    }

    @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.create(todo);
        /* REMOVE THIS LINE IF YOU USE JPA
            todoRepository.save(todo);
           REMOVE THIS LINE IF YOU USE JPA */

        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.update(todo);
        /* REMOVE THIS LINE IF YOU USE JPA
            todoRepository.save(todo);
           REMOVE THIS LINE IF YOU USE JPA */
        return todo;
    }

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

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

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

処理結果用のエラーコードに対応するエラーメッセージを、メッセージ用のプロパティファイルに定義する。

../_images/application-messages.png

src/main/resources/i18n/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.7003 = Access Denied detected!
e.xx.fw.7004 = Missing CSRF detected!

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

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

# typemismatch
typeMismatch="{0}" is invalid.
typeMismatch.int="{0}" must be an integer.
typeMismatch.double="{0}" must be a double.
typeMismatch.float="{0}" must be a float.
typeMismatch.long="{0}" must be a long.
typeMismatch.short="{0}" must be a short.
typeMismatch.boolean="{0}" must be a boolean.
typeMismatch.java.lang.Integer="{0}" must be an integer.
typeMismatch.java.lang.Double="{0}" must be a double.
typeMismatch.java.lang.Float="{0}" must be a float.
typeMismatch.java.lang.Long="{0}" must be a long.
typeMismatch.java.lang.Short="{0}" must be a short.
typeMismatch.java.lang.Boolean="{0}" is not a boolean.
typeMismatch.java.util.Date="{0}" is not a date.
typeMismatch.java.lang.Enum="{0}" is not a valid value.

# For this tutorial
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}

入力チェック用のエラーコードに対応するエラーメッセージを、Bean Validationのメッセージ用のプロパティファイルに定義する。
デフォルトのメッセージは、メッセージの中に項目名が含まれないため、デフォルトのメッセージ定義を変更する。
本チュートリアルでは、TodoResourceクラスで使用しているルール(@NotNull@Size)に対応するメッセージのみ定義する。
../_images/validation-messages.png

src/main/resources/ValidationMessages.properties

jakarta.validation.constraints.NotNull.message = {0} may not be null.
jakarta.validation.constraints.Size.message    = {0} size must be between {min} and {max}.

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

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

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

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

src/main/java/com/example/todo/api/common/error/RestGlobalExceptionHandler.java

package com.example.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 {

}

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

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

src/main/java/com/example/todo/api/common/error/ApiError.java

package com.example.todo.api.common.error;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;

import com.fasterxml.jackson.annotation.JsonInclude;

public class ApiError implements Serializable {

    private static final long serialVersionUID = 1L;

    private final String code;

    private final String message;

    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    private final String target;

    @JsonInclude(JsonInclude.Include.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);
    }

}

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

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

src/main/java/com/example/todo/api/common/error/RestGlobalExceptionHandler.java

package com.example.todo.api.common.error;

import org.springframework.context.MessageSource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
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;

import jakarta.inject.Inject;

@ControllerAdvice
public class RestGlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Inject
    MessageSource messageSource;

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

    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レスポンスコードについてを参照されたい。

Talend API Testerを使用して、実装したエラーハンドリングの動作確認を行う。
Talend API Testerを開いてURLにlocalhost:8080/todo/api/v1/todosを入力し、メソッドにPUTを指定してから、”Send”ボタンをクリックする。

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

../_images/exception-genericerror.png

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

入力エラーの種類は、

  • org.springframework.web.bind.MethodArgumentNotValidException

  • org.springframework.http.converter.HttpMessageNotReadableException

  • org.springframework.beans.TypeMismatchException

となる。

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

src/main/java/com/example/todo/api/common/error/RestGlobalExceptionHandler.java

package com.example.todo.api.common.error;

import org.springframework.context.MessageSource;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
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;

import jakarta.inject.Inject;

@ControllerAdvice
public class RestGlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Inject
    MessageSource messageSource;

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

    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,
            HttpStatusCode 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);
    }

}

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

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

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

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

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

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

src/main/java/com/example/todo/api/common/error/RestGlobalExceptionHandler.java

package com.example.todo.api.common.error;

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.HttpStatusCode;
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;

import jakarta.inject.Inject;

@ControllerAdvice
public class RestGlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Inject
    MessageSource messageSource;

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

    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,
            HttpStatusCode 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, new HttpHeaders(),
                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);
    }

}

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

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

../_images/exception-businesserror.png

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

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

リソース未検出例外が発生した場合、”404 NotFound”のHTTPステータスを設定する。

src/main/java/com/example/todo/api/common/error/RestGlobalExceptionHandler.java

package com.example.todo.api.common.error;

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.HttpStatusCode;
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;

import jakarta.inject.Inject;

@ControllerAdvice
public class RestGlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Inject
    MessageSource messageSource;

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

    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,
            HttpStatusCode 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, new HttpHeaders(),
                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, new HttpHeaders(),
                HttpStatus.NOT_FOUND, request);
    }

}

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

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

../_images/exception-notfound.png

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

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

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

src/main/java/com/example/todo/api/common/error/RestGlobalExceptionHandler.java

package com.example.todo.api.common.error;

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.HttpStatusCode;
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;

import jakarta.inject.Inject;

@ControllerAdvice
public class RestGlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Inject
    MessageSource messageSource;

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

    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,
            HttpStatusCode 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, new HttpHeaders(),
                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, new HttpHeaders(),
                HttpStatus.NOT_FOUND, request);
    }

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

}

Talend API Testerを使用して、実装したエラーハンドリングの動作確認を行う。
システムエラーを発生させるために、テーブルを未作成の状態でアプリケーションを起動させる。

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

database=H2
#database.url=jdbc:h2:mem:todo;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;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

Talend API Testerを開いてURLにlocalhost:8080/todo/api/v1/todosを入力し、メソッドにGETを指定して、”Send”ボタンをクリックする。

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

../_images/exception-systemerror.png

Note

システムエラーが発生した場合、クライアントへ返却するメッセージは、エラー原因が特定されないシンプルなエラーメッセージを設定することを推奨する。

エラー原因が特定できるメッセージを設定してしまうと、システムの脆弱性をクライアントに公開する可能性があり、セキュリティー上問題がある。

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

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

date:2015-01-19 02:08:47  thread:tomcat-http--4   X-Track:aadf5822205d423c95a6531f2f76036f        level:ERROR     logger:o.t.gfw.common.exception.ExceptionLogger         message:[e.xx.fw.9002]
### Error querying database.  Cause: org.h2.jdbc.JdbcSQLException: Table "TODO" not found; SQL statement:
SELECT
            todo_id,
            todo_title,
            finished,
            created_at
        FROM
            todo [42102-182]
### The error may exist in com/example/todo/domain/repository/todo/TodoRepository.xml
### The error may involve com.example.todo.domain.repository.todo.TodoRepository.findAll
### The error occurred while executing a query

... (omitted)

11.3.4. おわりに

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

  • TERASOLUNA Server Framework for Java (5.x)による基本的なRESTful Webサービスの構築方法

  • REST API(GET, POST, PUT, DELETE)を提供するControllerクラスの実装

  • JavaBeanとJSONの相互変換方法

  • エラーメッセージの定義方法

  • Spring MVCを使用した各種例外のハンドリング方法

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