5.1. RESTful Web Service

目次

5.1.1. Overview

本節では、RESTful Web Serviceの基本的な概念とSpring MVCを使った開発について説明する。

RESTful Web Serviceのアーキテクチャ、設計、実装に対する具体的な説明については、

  • RESTful Web Serviceの基本的なアーキテクチャについて説明している。
  • RESTful Web Serviceの設計を行う際に考慮すべき点などを説明している。
  • RESTful Web Serviceのアプリケーション構成やAPIの実装方法について説明している。

を参照されたい。


5.1.1.1. RESTful Web Serviceとは

まずRESTとは、「Representational State Transfer」の略であり、
クライアントとサーバ間でデータをやりとりするアプリケーションを構築するためのアーキテクチャスタイルの一つである。
RESTのアーキテクチャスタイルには、いくつかの重要な原則があり、これらの原則に従っているもの(システムなど)は RESTfulと表現される。
つまり、「RESTful Web Service」とは、RESTの原則に従って構築されているWeb Serviceという事になる。
RESTful Web Serviceにおいて最も重要なのは、「リソース」という概念である。
RESTful Web Serviceでは、データベースなどで管理している情報の中からクライアントに提供すべき情報を「リソース」として抽出し、抽出した「リソース」に対するCRUD操作をHTTPメソッド(POST/GET/PUT/DELETE)を使ってクライアントに提供することになる。
「リソース」に対するCRUD操作は「REST API」や「RESTful API」と呼ばれる事があり、本ガイドラインでは「REST API」と呼ぶ。
また、クライアントとサーバ間でリソースをやりとりする際の電文形式には、電文の視認性、及びデータ構造の表現性が高いJSONやXMLを使用するのが一般的である。
RESTful Web Serviceを利用するアプリケーションのシステム構成は、主に以下の2パターンとなる。
クライアントとサーバ間で「リソース」をやりとりするための具体的なアーキテクチャについては、「Architecture」で説明する。
Constitution of RESTful Web Service
項番 説明
(1)
ユーザインタフェースを持つクライアントアプリケーションとRESTful Web Serviceの間で、直接リソースのやりとりを行う。
このパターンは、要件や仕様の変更頻度が多いユーザインタフェースに依存するロジックと、より普遍的で変更頻度が少ないデータモデルに対するロジックを分離する際に採用される構成である。
(2)
ユーザインタフェースを持つクライアントアプリケーションと直接リソースをやり取りするのではなく、システム間でリソースのやりとりを行う。
このパターンは、各システムで管理しているビジネスデータを一元管理するようなシステムを構築する際に採用される構成である。

5.1.1.2. RESTful Web Serviceの開発について

Macchinetta Server Framework (1.x)では、Spring MVCの機能を利用してRESTful Web Serviceの開発を行う。

Spring MVCでは、RESTful Web Serviceを開発する上で必要となる共通的な機能がデフォルトで組み込まれている。
そのため、特別な設定の追加や実装を行うことなく、RESTful Web Serviceの開発を開始する事ができる。
Spring MVCにデフォルトで組み込まれている主な共通機能は以下の通りである。
これらの機能は、REST APIを提供するControllerのメソッドにアノテーションを指定だけで有効にする事ができる。
項番 機能概要
(1)
リクエストBODYに設定されているJSONやXML形式の電文をResourceオブジェクト(JavaBean)に変換し、Controllerクラスのメソッド(REST API)に引き渡す機能
(2)
電文から変換されたResourceオブジェクト(JavaBean)に格納された値に対して入力チェックを実行する機能
(3)
Controllerクラスのメソッド(REST API)から返却したResourceオブジェクト(JavaBean)を、JSONやXML形式に変換しレスポンスBODYに設定する機能

Note

例外ハンドリングについて

例外ハンドリングについては、Spring MVCから汎用的な機能の提供がないため、プロジェクト毎に実装が必要となる。

例外ハンドリングの詳細については、「例外のハンドリングの実装」を参照されたい。


Spring MVCの機能を利用してRESTful Web Serviceを開発した場合、アプリケーションは以下のような構成となり、そのうち実装が必要なのは、赤枠の部分となる。
Application constitution of RESTful Web Service on Spring MVC
項番 処理レイヤ 説明
(1)
Spring MVC
(Framework)
Spring MVCは、クライアントからのリクエストを受け取り、呼び出すREST API(Controllerのハンドラメソッド)を決定する。
(2)


Spring MVCは、HttpMessageConverterを使用して、リクエストBODYに指定されているJSON形式の電文をResourceオブジェクトに変換する。
(3)


Spring MVCは、Validatorを使用して、Resourceオブジェクトに格納されて値に対して入力チェックを行う。
(4)


Spring MVCは、REST APIを呼び出す。
その際に、JSONから変換した入力チェック済みのResourceオブジェクトがREST APIに引き渡される。
(5)
REST API
REST APIは、Serviceのメソッドを呼び出し、EntityなどのDomainObjectに対する処理を行う。
(6)

Serviceのメソッドは、Repositoryのメソッドを呼び出し、EntityなどのDomainObjectのCRUD処理を行う。
(7)
Spring MVC
(Framework)
Spring MVCは、HttpMessageConverterを使用して、REST APIから返却されたResourceオブジェクトをJSON形式の電文に変換する。
(8)


Spring MVCは、JSON形式の電文をレスポンスBODYに設定し、クライアントへレスポンスする。

5.1.1.2.1. RESTful Web Serviceのモジュールの構成

Spring MVCから提供されている機能を使うことにより、RESTful Web Service固有の処理の多くをSpring MVCに任せることが出来る。
そのため、開発するモジュールの構成は、HTMLを応答する従来型のWebアプリケーションの開発とほとんど同じ構成となる。
以下に、モジュールの構成要素について説明する。
Constitution of Modules
  • アプリケーション層のモジュール

    項番 モジュール名 説明
    (1)
    Controllerクラス
    REST APIを提供するクラス。
    Controllerクラスはリソース単位に作成し、リソース毎のREST APIのエンドポイント(URI)の指定を行う。
    リソースに対するCRUD処理は、ドメイン層のServiceに委譲する事で実現する。
    (2)
    Resourceクラス
    REST APIの入出力となるJSON(またはXML)を表現するJava Bean。
    このクラスには、Bean Validationのアノテーションの指定や、JSONやXMLのフォーマットを制御するためのアノテーションの指定を行う。
    (3)
    Validatorクラス
    (Optional)
    入力値の相関チェックを実装するクラス。
    入力値の相関チェックが不要な場合は、本クラスを作成する必要はないため、オプションの扱いとしている。
    入力値の相関チェックについては、「入力チェック」を参照されたい。
    (4)
    Helperクラス
    (Optional)
    Controllerで行う処理を補助するための処理を実装するクラス。
    本クラスは、Controllerの処理をシンプルに保つことを目的として作成するクラスである。
    具体的には、ResourceオブジェクトとDomainObjectのモデル変換処理などを行うメソッドを実装する。
    モデル変換が単純な値のコピーのみで済む場合は、Helperクラスは作成せずに「Beanマッピング(MapStruct)」を使用すればよいため、オプションの扱いにしている。

  • ドメイン層のモジュール

    項番 説明
    (5)
    ドメイン層で実装するモジュールは、アプリケーションの種類に依存しないため、本節での説明は割愛する。
    各モジュールの役割については「アプリケーションのレイヤ化」を、ドメイン層の開発については「ドメイン層の実装」を参照されたい。

  • インフラストラクチャ層のモジュール

    項番 説明
    (6)
    インフラストラクチャ層で実装するモジュールは、アプリケーションの種類に依存しないため、本節での説明は割愛する。
    各モジュールの役割については「アプリケーションのレイヤ化」を、インフラストラクチャ層の開発については「インフラストラクチャ層の実装」を参照されたい。

5.1.1.2.2. REST APIの実装サンプル

詳細な説明を行う前に、どのようなクラスをアプリケーション層に作成する事になるのかを知ってもらうために、ResourceクラスとControllerクラスの実装サンプルを以下に示す。
下記に示す実装サンプルは、「チュートリアル(Todoアプリケーション REST編)」で題材としているTodoリソースのREST APIである。

Note

詳細な説明を読む前に、まずはチュートリアル(Todoアプリケーション REST編)を実践する事を強く推奨する。

チュートリアルでは”習うより慣れろ”を目的としており、 詳細な説明の前に実際に手を動かすことでMacchinetta Server Framework (1.x)によるRESTful Web Serviceの開発を体感する事が出来る。

RESTful Web Serviceの開発を体感した後に、詳細な説明を読むことで、RESTful Web Serviceの開発に対する理解度がより深まる事が期待できる。

特にRESTful Web Serviceの開発経験がない場合は、「チュートリアルの実践」 → 「アーキテクチャ、設計、開発に関する詳細な説明(次節以降で説明)」 → 「チュートリアルの振り返り(再実践)」というプロセスを踏むことを推奨する。


  • 実装サンプルで扱うリソース

    実装サンプルで扱うリソース(Todoリソース)は、以下のJSON形式とする。

    {
        "todoId" : "9aef3ee3-30d4-4a7c-be4a-bc184ca1d558",
        "todoTitle" : "Hello World!",
        "finished" : false,
        "createdAt" : "2014-02-25T02:21:48.493+0000"
    }
    

  • Resourceクラスの実装サンプル

    上記で示したTodoリソースを表現するJavaBeanとして、Resourceクラスを作成する。

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

  • Controllerクラス(REST API)の実装サンプル

    Todoリソースに対して、以下の5つのREST API(Controllerのハンドラメソッド)を作成する。

    項番
    API名
    HTTP
    メソッド
    パス
    ステータス
    コード
    説明
    (1)
    GET Todos
    GET
    /api/v1/todos
    200
    (OK)
    Todoリソースを全件取得する。
    (2)
    POST Todos
    POST
    /api/v1/todos
    201
    (Created)
    Todoリソースを新規作成する。
    (3)
    GET Todo
    GET
    /api/v1/todos/{todoId}
    200
    (OK)
    Todoリソースを一件取得する。
    (4)
    PUT Todo
    PUT
    /api/v1/todos/{todoId}
    200
    (OK)
    Todoリソースを完了状態に更新する。
    (5)
    DELETE Todo
    DELETE
    /api/v1/todos/{todoId}
    204
    (No Content)
    Todoリソースを削除する。
    package todo.api.todo;
    
    import java.util.ArrayList;
    import java.util.Collection;
    import java.util.List;
    
    import org.springframework.http.HttpStatus;
    import org.springframework.security.access.prepost.PreAuthorize;
    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 jakarta.inject.Inject;
    
    import todo.domain.model.Todo;
    import todo.domain.service.todo.TodoService;
    
    @RestController
    @RequestMapping("todos")
    public class TodoRestController {
        @Inject
        TodoService todoService;
        @Inject
        BeanMapper beanMapper;
    
        // (1)
        @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;
        }
    
        // (2)
        @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;
        }
    
        // (3)
        @GetMapping("{todoId}")
        @ResponseStatus(HttpStatus.OK)
        public TodoResource getTodo(@PathVariable("todoId") String todoId) {
            Todo todo = todoService.findOne(todoId);
            TodoResource todoResource = beanMapper.map(todo);
            return todoResource;
        }
    
        // (4)
        @PutMapping("{todoId}")
        @ResponseStatus(HttpStatus.OK)
        public TodoResource putTodo(@PathVariable("todoId") String todoId) {
            Todo finishedTodo = todoService.finish(todoId);
            TodoResource finishedTodoResource = beanMapper.map(finishedTodo);
            return finishedTodoResource;
        }
    
        // (5)
        @DeleteMapping("{todoId}")
        @ResponseStatus(HttpStatus.NO_CONTENT)
        public void deleteTodo(@PathVariable("todoId") String todoId) {
            todoService.delete(todoId);
        }
    
    }
    

5.1.2. Architecture

本節では、RESTful Web Serviceを構築するためのアーキテクチャについて説明する。
RESTful Web Serviceを構築するためのアーキテクチャとして、リソース指向アーキテクチャ(ROA)というものが存在する。
ROAは、「Resource Oriented Architecture」の略であり、RESTのアーキテクチャスタイル(原則)に従ったWeb Serviceを構築するための具体的なアーキテクチャを定義している。
RESTful Web Serviceを作る際は、まずROAのアーキテクチャの理解を深めてほしい。
本節では、ROAのアーキテクチャとして、以下の7つについて説明する。
これらは、RESTful Web Serviceを構築する上で重要なアーキテクチャ要素であるが、必ず全てを適用しなくてはいけないという事ではない。
開発するアプリケーションの特性を考慮し、必要なものを適用してほしい。

以下の5つのアーキテクチャは、アプリケーションの特性に関係なく適用すべきアーキテクチャである。

項番 アーキテクチャ アーキテクチャの概要
(1)
システム上で管理する情報をクライアントに提供する手段として、Web上のリソースとして公開する。
(2)
クライアントに公開するリソースには、Web上のリソースとして一意に識別できるURI(Universal Resource Identifier)を割り当てる。
(3)
リソースに対する操作は、HTTPメソッド(GET,POST,PUT,DELETE)を使い分けることで実現する。
(4)
リソースのフォーマットは、JSON又はXMLなどのデータ構造を示すためのフォーマットを使用する。
(5)
クライアントへ返却するレスポンスには、適切なHTTPステータスコードを設定する。

以下の2つのアーキテクチャは、アプリケーションの特性に応じて、適用するアーキテクチャである。

項番 アーキテクチャ アーキテクチャの概要
(6)
サーバ上でアプリケーションの状態は保持せずに、クライアントからリクエストされてきた情報のみで処理を行うようにする。
(7)
リソースの中には、指定されたリソースと関連をもつ他のリソースへのリンク(URI)を含める。

5.1.2.1. Web上のリソースとして公開

システム上で管理する情報をクライアントに提供する手段として、Web上のリソースとして公開する。
これは、HTTPプロトコルを使ってリソースにアクセスできるようにする事を意味しており、その際にリソースを識別する方法とし、URIが使用される。

例えば、ショッピングサイトを提供するWebシステムであれば、以下のような情報がWeb上のリソースとして公開する事になる。

  • 商品の情報
  • 在庫の情報
  • 注文の情報
  • 会員の情報
  • 会員毎の認証の情報(ログインIDとパスワードなど)
  • 会員毎の注文履歴の情報
  • 会員毎の認証履歴の情報
  • and more …

5.1.2.2. URIによるリソースの識別

クライアントに公開するリソースには、Web上のリソースとして一意に識別できるURI(Universal Resource Identifier)を割り当てる。
実際に使用されるのは、URIのサブセットであるURL(Uniform Resource Locator)となる。
ROAでは、URIを使用してWeb上のリソースにアクセスできる状態になっていることを「アドレス可能性」と呼んでいる。
これは同じURIを使用すれば、どこからでも同じリソースにアクセスできる状態になっている事を意味している。
RESTful Web Serviceに割り当てるURIは、「リソースの種類を表す名詞」と「リソースを一意に識別するための値(IDなど)」の組み合わせとする。
例えば、ショッピングサイトを提供するWebシステムで扱う商品情報のURIは、以下のようになる。
  • http://example.com/api/v1/items
    items」の部分が「リソースの種類を表す名詞」となり、リソースの数が複数になる場合は、複数系の名詞を使用する。
    上記例では、商品情報である事を表す名詞の複数系を指定しており、商品情報を一括で操作する際に使用するURIとなる。これは、ファイルシステムに置き換えると、ディレクトリに相当する。
  • http://example.com/api/v1/items/I312-535-01216
    I312-535-01216」の部分が「リソースを識別するための値」となり、リソース毎に異なる値となる。
    上記例では、商品情報を一意に識別するための値として商品IDを指定しており、特定の商品情報を操作する際に使用するURIとなる。これは、ファイルシステムに置き換えると、ディレクトリの中に格納されているファイルに相当する。

Warning

RESTful Web Serviceに割り当てるURIには、下記で示すような操作を表す動詞を含んではいけない。

  • http://example.com/api/v1/items?get&itemId=I312-535-01216
  • http://example.com/api/v1/items?delete&itemId=I312-535-01216

上記例では、 URIの中にgetdeleteという動詞を含んでいるため、RESTful Web Serviceに割り当てるURIとして適切ではない。

RESTful Web Serviceでは、リソースに対する操作はHTTPメソッド(GET,POST,PUT,DELETE)を使用して表現する。


5.1.2.3. HTTPメソッドによるリソースの操作

リソースに対する操作は、HTTPメソッド(GET,POST,PUT,DELETE)を使い分けることで実現する。
ROAでは、HTTPメソッドの事を「統一インタフェース」と呼んでいる。
これは、HTTPメソッドがWeb上で公開される全てのリソースに対して実行する事ができ、且つリソース毎にHTTPメソッドの意味が変わらない事を意味している。

以下に、HTTPメソッドに割り当てられる、リソースに対する操作の対応付けと、それぞれの操作が保証すべき事後条件について説明する。

項番 HTTPメソッド リソースに対する操作 操作が保証すべき事後条件
(1)
GET
リソースを取得する。
安全性、べき等性。
(2)
POST
リソースを作成する。
作成したリソースのURIの割り当てはサーバが行い、割り当てたURIはレスポンスのLocationヘッダに設定してクライアントに返却する。
(4)
PUT
リソースを作成又は更新する。
べき等性。
(5)
PATCH
リソースを差分更新する。
べき等性。
(6)
DELETE
リソースを削除する。
べき等性。
(7)
HEAD
リソースのメタ情報を取得する。
GETと同じ処理を行いヘッダのみ応答する。
安全性、べき等性。
(8)
OPTIONS
リソースに対して使用できるHTTPメソッドの一覧を応答する。
安全性、べき等性。

Note

安全性とべき等性の保証について

HTTPメソッドを使ってリソースの操作を行う場合、事後条件として、「安全性」と「べき等性」の保証を行う事が求められる。

【安全性とは】

ある数字に1を何回掛けても、数字がかわらない事(10に1を何回掛けても結果は10のままである事)を保証する。これは、同じ操作を何回行ってもリソースの状態が変わらない事を保証する事である。

【べき等性とは】

数字に0を何回掛けても0になる事(10に0を1回掛けても何回掛けても結果は共に0になる事)を保証する。これは、一度操作を行えば、その後で同じ操作を何回行ってもリソースの状態が変わらない事を保証する事である。ただし、別のクライアントが同じリソースの状態を変更している場合は、べき等性を保障する必要はなく、事前条件に対するエラーとして扱ってもよい。

Tip

クライアントがリソースに割り当てるURIを指定してリソースを作成する場合

リソースを作成する際に、クライアントによってリソースに割り当てるURIを指定する場合は、作成するリソースに割り当てるURIに対して、PUTメソッドを呼び出すことで実現する。

PUTメソッドを使用してリソースを作成する場合、

  • 指定されたURIにリソースが存在しない場合はリソースを作成
  • 既にリソースが存在する場合はリソースの状態を更新

するのが一般的な動作である。

以下に、PUTとPOSTメソッドを使ってリソースを作成する際の処理イメージの違いについて説明する。

【PUTメソッドを使用してリソースを作成する際の処理イメージ】

Image of processing for create new resource using by PUT method
項番 説明
(1)
URIに作成するリソースのURI(ID)を指定して、PUTメソッドを呼び出す。
(2)
URIで指定されたIDのEntityを作成する。
既に同じIDで作成済みの場合は、内容を更新する。
(3)
作成又は更新したリソースを応答する。

【POSTメソッドを使用してリソースを作成する際の処理イメージ】

Image of processing for create new resource using by POST method
項番 説明
(1)
POSTメソッドを呼び出す。
(2)
リクエストされリソースを識別するためのIDを生成する。
(3)
(2)で生成したIDのEntityを作成する。
(4)
作成したリソースを応答する。
レスポンスのLocationヘッダに生成したリソースにアクセスするためのURIを設定する。

5.1.2.4. 適切なフォーマットの使用

リソースのフォーマットは、JSON又はXMLなどのデータ構造を示すためのフォーマットを使用する。

ただし、リソースの種類によっては、JSONやXML以外のフォーマットを使ってもよい。
例えば、統計情報に分類される様なリソースでは、折れ線グラフを画像フォーマット(バイナリデータ)としてリソースを公開する事も考えられる。
リソースのフォーマットとして、複数のフォーマットをサポートする場合は、以下のどちらかの方法で切り替えを行う。
  • 拡張子によって切り替えを行う。

    レスポンスのフォーマットは、拡張子を指定する事で切り替える事ができる。
    本ガイドラインでは、拡張子による切り替えを推奨する。
    推奨する理由は、レスポンスするフォーマット指定が簡単であるという点と、レスポンスするフォーマットがURIに含まれ、直感的なURIになるという点である。

    Note

    拡張子で切り替える場合のURI例

    • http://example.com/api/v1/items.json
    • http://example.com/api/v1/items.xml
    • http://example.com/api/v1/items/I312-535-01216.json
    • http://example.com/api/v1/items/I312-535-01216.xml

  • リクエストのAcceptヘッダのMIMEタイプによって切り替えを行う。

    RESTful Web Serviceで使用される代表的なMIMEタイプを以下に示す。

    項番 フォーマット MIMEタイプ
    (1)
    JSON
    application/json
    (2)
    XML
    application/xml

5.1.2.5. 適切なHTTPステータスコードの使用

クライアントへ返却するレスポンスには、適切なHTTPステータスコードを設定する。

HTTPステータスコードには、クライアントから受け取ったリクエストをサーバがどのように処理したかを示す値を設定する。
これはHTTPの仕様であり、HTTPの仕様に可能な限り準拠することを推奨する。

Tip

HTTPの仕様について

RFC 7230(Hypertext Transfer Protocol – HTTP/1.1)の3.1.2 Status Lineを参照されたい。


ブラウザにHTMLを返却するような伝統的なWebシステムでは、処理結果に関係なく200 OKを応答し、処理結果はエンティティボディ(HTML)の中で表現するという事が一般的であった。
HTMLを返却するような伝統的なWebアプリケーションでは、処理結果を判断するのはオペレータ(人間)のため、この仕組みでも問題が発生する事はなかった。
しかし、この仕組みでRESTful Web Serviceを構築した場合、以下のような問題が潜在的に存在することになるため、適切なHTTPステータスコードを設定することを推奨する。
項番 潜在的な問題点
(1)
処理結果(成功と失敗)のみを判断すればよい場合でも、エンティティボディを解析処理が必須になるため、無駄な処理が必要になる。
(2)
エラーハンドリングを行う際に、システム独自に定義されたエラーコードを意識する事が必須になるため、クライアント側のアーキテクチャ(設計及び実装)に悪影響を与える可能性がある。
(3)
クライアント側でエラー原因を解析する際に、システム独自に定義されたエラーコードの意味を理解しておく必要があるため、直感的なエラー解析の妨げになる可能性がある。

Note

HTTPのメッセージ構文を規定するRFC 7230では、HTTPステータスコードの説明句(reason-phrase)の出力は必須ではなく、クライアントは無視すべきであると規定されている。 例えば、RFC 7230に準拠した実装のTomcat 8.5では、説明句が出力されない。

RFC 7230(Hypertext Transfer Protocol – HTTP/1.1)の3.1.2 Status Lineを参照されたい。


5.1.2.6. ステートレスなクライアント/サーバ間の通信

サーバ上でアプリケーションの状態は保持せずに、クライアントからリクエストされてきた情報のみで処理を行うようにする。
ROAでは、サーバ上でアプリケーションの状態を保持しない事を「ステートレス性」と呼んでいる。
これは、アプリケーションサーバのメモリ(HTTPセッションなど)にアプリケーションの状態を保持しない事を意味し、リクエストされた情報のみでリソースに対する操作が完結できる状態にしておく事を意味している。
本ガイドラインでは、可能な限り「ステートレス性」を保つことを推奨する。

Note

アプリケーションの状態とは

Webページの遷移状態、入力値、プルダウン/チェックボックス/ラジオボタンなどの選択状態、認証状態などの事である。

Note

CSRF対策との関連

本ガイドラインに記載されているCSRF対策をRESTful Web Serviceに対して行った場合、CSRF対策用のトークン値がHTTPセッションに保存されるため、クライアントとサーバ間の「ステートレス性」を保つ事が出来ないという点を補足しておく。

そのため、CSRF対策を行う場合は、システムの可用性を考慮する必要がある。

高い可用性が求められるシステムでは、

  • APサーバをクラスタ化し、セッションをレプリケーションする。
  • セッションの保存先をAPサーバのメモリ以外にする。

等の対策が必要となる。 ただし、上記対策は性能への影響があるため、性能要件も考慮する必要がある。

CSRF対策については、CSRF対策を参照されたい。


5.1.2.7. 関連のあるリソースへのリンク

リソースの中には、指定されたリソースと関連をもつ他のリソースへのハイパーメディアリンク(URI)を含める。
ROAでは、リソース状態の表現の中に、他のリソースへのハイパーメディアリンクを含めることを「接続性」と呼んでいる。
これは、関連をもつリソース同士が相互にリンクを保持し、そのリンクをたどる事で関連する全てのリソースにアクセスできる状態にしておく事を意味している。

下記に、ショッピングサイトの会員情報のリソースを例に、リソースの接続性について説明する。

Image of resource connectivity

項番 説明
(1)
会員情報のリソースを取得(GET http://example.com/api/v1/memebers/M000000001)を行うと、以下のJSONが返却される。
{
    "memberId" : "M000000001",
    "memberName" : "John Smith",
    "address" : {
        "address1" : "45 West 36th Street",
        "address2" : "7th Floor",
        "city" : "New York",
        "state" : "NY",
        "zipCode" : "10018"
    },
    "links" : [
        {
            "rel" : "orders",
            "href" : "http://example.com/api/v1/memebers/M000000001/orders"
        },
        {
            "rel" : "authentications",
            "href" : "http://example.com/api/v1/memebers/M000000001/authentications"
        }
    ]
}
ハイライトした部分が、関連をもつ他のリソースへのハイパーメディアリンク(URI)となる。
上記例では、会員毎の注文履歴と認証履歴のリソースに対して接続性を保持している。
(2)
返却されたJSONに設定されているハイパーメディアリンク(URI)を使用して、注文履歴のリソースを取得(GET http://example.com/api/v1/memebers/M000000001/orders)を行うと、以下のJSONが返却される。
{
    "orders" : [
        {
            "orderId" : "029b49d7-0efa-411b-bc5a-6570ce40ead8",
            "orderDatetime" : "2013-12-27T20:34:50.897Z",
            "orderName" : "Note PC",
            "shopName" : "Global PC Shop",
            "links" : [
                {
                    "rel" : "order",
                    "href" : "http://example.com/api/v1/memebers/M000000001/orders/029b49d7-0efa-411b-bc5a-6570ce40ead8"
                }
            ]
        },
        {
            "orderId" : "79bf991d-d42d-4546-9265-c5d4d59a80eb",
            "orderDatetime" : "2013-12-03T19:01:44.109Z",
            "orderName" : "Orange Juice 100%",
            "shopName" : "Global Food Shop",
            "links" : [
                {
                    "rel" : "order",
                    "href" : "http://example.com/api/v1/memebers/M000000001/orders/79bf991d-d42d-4546-9265-c5d4d59a80eb"
                }
            ]
        }
    ],
    "links" : [
        {
            "rel" : "ownerMember",
            "href" : "http://example.com/api/v1/memebers/M000000001"
        }
    ]
}
ハイライトした部分が、関連をもつ他のリソースへのハイパーメディアリンク(URI)となる。
上記例では、注文履歴のオーナに関する会員情報のリソース及び注文履歴のリソースに対する接続性を保持している。
(3)
注文履歴のオーナとなる会員情報のリソースを再度取得(GET http://example.com/api/v1/memebers/M000000001)し、返却されたJSONに設定されているハイパーメディアリンク(URI)を使用して、認証履歴のリソースを取得(GET http://example.com/api/v1/memebers/M000000001/authentications/)を行うと、以下のJSONが返却される。
{
    "authentications" : [
        {
            "authenticationId" : "6ae9613b-85b6-4dd1-83da-b53c43994433",
            "authenticationDatetime" : "2013-12-27T20:34:50.897Z",
            "clientIpaddress" : "230.210.3.124",
            "authenticationResult" : true
        },
        {
            "authenticationId" : "103bf3c5-7707-46eb-b2d8-c00ce6243d5f",
            "authenticationDatetime" : "2013-12-26T10:03:45.001Z",
            "clientIpaddress" : "230.210.3.124",
            "authenticationResult" : false
        }
    ],
    "links" : [
        {
            "rel" : "ownerMember",
            "href" : "http://example.com/api/v1/memebers/M000000001"
        }
    ]
}
ハイライトした部分が、関連をもつ他のリソースへのハイパーメディアリンク(URI)となる。
上記例では、認証履歴のオーナとなる会員情報のリソースに対して接続性を保持している。

リソースの中に他のリソースへのハイパーメディアリンク(URI)を含めることは、必須ではない。
事前に全てのREST APIのエンドポイント(URI)を公開している場合、リソースの中に関連リソースへのリンクを設けても、リンクが使用されない可能性が高い。
特に、システム間でリソースのやりとりを行うためのREST APIの場合は、事前に公開されているREST APIのエンドポイントに対して直接アクセスするような実装になる事が多いため、リンクを設ける意味がない事がある。
リンクを設ける意味がない場合は、無理にリンクを設ける必要はない。
逆に、ユーザインタフェースを持つクライアントアプリケーションとRESTful Web Serviceの間で直接リソースのやりとりを行う場合は、リンクを設けることで、クライアントとサーバ間の疎結合性を高めることが出来る。
クライアントとサーバ間の疎結合性を高めることが出来る理由は以下の通りである。
項番 疎結合性を高めることが出来る理由
(1)
クライアントアプリケーションは、リンクの論理名のみ事前に知っていればよいため、REST APIを呼び出すための具体的なURIを意識する必要がなくなる。
(2)
クライアントアプリケーションが具体的なURIを意識する必要がなくなるため、サーバ側のURIを変更する際に与える影響度を最小限に抑える事ができる。

上記にあげた点を考慮し、他のリソースへのハイパーメディアリンク(URI)を設けるか否かを判断すること。

Tip

HATEOASとの関係

HATEOASは、「Hypermedia As The Engine Of Application State」の略であり、RESTfulなWebアプリケーションを作成するためのアーキテクチャの一つである。

HATEOASのアーキテクチャでは、

  • サーバは、クライアントとサーバ間でやり取りするリソース(JSONやXML)の中に、アクセス可能なリソースへのハイパーメディアリンク(URI)を含める。
  • クライアントは、リソース表現(JSONやXML)の中に含まれるハイパーメディアリンクを介して、サーバから必要なリソースを取得し、アプリケーションの状態(画面の状態など)を変化させる。

ことになるため、関連のあるリソースへのリンクを設ける事は、HATEOASのアーキテクチャと一致する。

サーバとクライアントとの疎結合性を高めたい場合は、HATEOASのアーキテクチャを採用する事を検討されたい。


5.1.3. How to design

本説では、RESTful Web Serviceの設計について説明する。

5.1.3.1. リソースの抽出

まず、Web上に公開するリソースを抽出する。

リソースを抽出する際の注意点を以下に示す。

項番 リソース抽出時の注意点
(1)
Web上に公開するリソースは、データベースなどで管理されている情報になるが、安易にデータベースのデータモデルをそのままリソースとして公開してはいけない。
データベースに格納されている項目の中には、クライアントに公開すべきでない項目もあるので、精査が必要である。
(2)
データベースの同じテーブルで管理されている情報であっても、情報の種類が異なる場合は、別のリソースとして公開する事を検討する。
本質的には別の情報だが、データ構造が同じという理由で同じテーブルで管理されているケースがあるので、精査が必要である。
(3)
RESTful Web Serviceでは、イベントで操作する情報をリソースとして抽出する。
イベント自体をリソースとして抽出してはいけない。

例えば、ワークフロー機能で発生するイベント(承認、否認、差し戻しなど)から呼び出されるRESTful Web Serviceを作成する場合、ワークフロー自体やワークフローの状態を管理するための情報をリソースとして抽出する。

5.1.3.2. URIの割り当て

抽出したリソースに対して、リソースを識別するためのURIを割り当てる。

URIは、以下の形式を推奨する。

  • http(s)://{ドメイン名(:ポート番号)}/{REST APIであることを示す値}/{APIバージョン}/{リソースを識別するためのパス}
  • http(s)://{REST APIであることを示すドメイン名(:ポート番号)}/{APIバージョン}/{リソースを識別するためのパス}

具体例は以下の通り。

  • http://example.com/api/v1/members/M000000001
  • http://api.example.com/v1/members/M000000001

5.1.3.2.1. REST APIであることを示すためのURIの割り当て

RESTful Web Service(REST API)向けのURIであること明確にするために、URI内のドメイン又はパスに apiを含めることを推奨する。

具体的には、以下のようなURIとする。

  • http://example.com/api/...
  • http://api.example.com/...

5.1.3.2.2. APIバージョンを識別するためのURIの割り当て

RESTful Web Serviceは、複数のバージョンで稼働が必要になる可能性があるため、クライアントに公開するURIには、APIバージョンを識別するための値を含めるようにする事を推奨する。

具体的には、以下のような形式のURIとする。

  • http://example.com/api/{APIバージョン}/{リソースを識別するためのパス}
  • http://api.example.com/{APIバージョン}/{リソースを識別するためのパス}

5.1.3.2.3. リソースを識別するためのパスの割り当て

Web上に公開するリソースに対して、以下の2つのURIを割り当てる。
下記の例では、会員情報をWeb上に公開する場合のURI例を記載している。
項番 URIの形式 URIの具体例 説明
(1)
/{リソースのコレクションを表す名詞}
/api/v1/members
リソースを一括で操作する際に使用するURIとなる。
(2)
/{リソースのコレクションを表す名詞/リソースの識別子(IDなど)}
/api/v1/members/M0001
特定のリソースを操作する際に使用するURIとなる。

Web上に公開する関連リソースへのURIは、ネストさせて表現する。
下記の例では、会員毎の注文情報をWeb上に公開する場合のURI例を記載している。
項番 URIの形式 URIの具体例 説明
(3)
{リソースのURI}/{関連リソースのコレクションを表す名詞}
/api/v1/members/M0001/orders
関連リソースを一括で操作する際に使用するURIとなる。
(4)
{リソースのURI}/{関連リソースのコレクションを表す名詞}/{関連リソースの識別子(IDなど)}
/api/v1/members/M0001/orders/O0001
特定の関連リソースを操作する際に使用するURIとなる。

Web上に公開する関連リソースの要素が1件の場合は、関連リソースを示す名詞は複数系ではなく単数形とする。
下記の例では、会員毎の資格情報をWeb上に公開する場合のURI例を記載している。
項番 URIの形式 URIの具体例 説明
(5)
{リソースのURI}/{関連リソースを表す名詞}
/api/v1/members/M0001/credential
要素が1件の関連リソースを操作する際に使用するURI。

5.1.3.3. HTTPメソッドの割り当て

リソース毎に割り当てたURIに対して、以下のHTTPメソッドを割り当て、リソースに対するCRUD操作をREST APIとして公開する。


5.1.3.3.1. リソースコレクションのURIに対するHTTPメソッドの割り当て

項番 HTTPメソッド 実装するREST APIの概要
(1)
GET
URIで指定されたリソースのコレクションを取得するREST APIを実装する。
(2)
POST
指定されたリソースを作成しコレクションに追加するREST APIを実装する。
(3)
PUT
URIで指定されたリソースの一括更新を行うREST APIを実装する。
(4)
DELETE
URIで指定されたリソースの一括削除を行うREST APIを実装する。
(5)
HEAD
URIで指定されたリソースコレクションのメタ情報を取得するREST APIを実装する。
GETと同じ処理を行いヘッダのみ応答する。
(6)
OPTIONS
URIで指定されたリソースコレクションでサポートされているHTTPメソッド(API)のリストを応答するREST APIを実装する。

5.1.3.3.2. 特定リソースのURIに対するHTTPメソッドの割り当て

項番 HTTPメソッド 実装するREST APIの概要
(1)
GET
URIで指定されたリソースを取得するREST APIを実装する。
(2)
PUT
URIで指定されたリソースの作成又は更新を行うREST APIを実装する。
(3)
DELETE
URIで指定されたリソースの削除を行うREST APIを実装する。
(4)
HEAD
URIで指定されたリソースのメタ情報を取得するREST APIを実装する。
GETと同じ処理を行いヘッダのみ応答する。
(5)
OPTIONS
URIで指定されたリソースでサポートされているHTTPメソッド(API)のリストを応答するREST APIを実装する。

5.1.3.4. リソースのフォーマット

リソースを表現するフォーマットとしては、JSONを使用する事を推奨する。
以降の説明では、リソースを表現するフォーマットとしてJSONを使用する前提で説明を記載する。

5.1.3.4.1. JSONのフィールド名

JSONのフィールド名は、「lower camel case」にすることを推奨する。
これはクライアントアプリケーションの一つとして想定されるJavaScriptとの相性を考慮した結果である。
フィールド名を「lower camel case」にした場合のJSONのサンプルは以下の通り。
「lower camel case」は、先頭文字を小文字にし、単語の先頭文字を大文字にする。
{
    "memberId" : "M000000001"
}

5.1.3.4.2. NULLとブランク文字

JSONの値として、NULLとブランク文字は区別する事を推奨する。
アプリケーションの処理としてNULLとブランク文字を同一視する事はよくあるが、JSONに設定する値としては、NULLとブランク文字は区別しておいた方がよい。
NULLとブランク文字を区別した場合のJSONのサンプルは以下の通り。
{
    "dateOfBirth" : null,
    "address1" : ""
}

5.1.3.4.3. 日時のフォーマット

JSONの日時フィールドの形式は、ISO-8601の拡張形式とする事を推奨する。
ISO-8601の拡張形式以外でもよいが、特に理由がない場合は、ISO-8601の拡張形式にすればよい。
ISO-8601には基本形式と拡張形式があるが、拡張形式の方が視認性が高い表記方法である。

具体的には、以下の3つの形式となる。

  1. yyyy-MM-dd
{
    "dateOfBirth" : "1977-03-12"
}
  1. yyyy-MM-dd’T’HH:mm:ss.SSSZ
{
    "lastModifiedAt" : "2014-03-12T22:22:36.637+09:00"
}
  1. yyyy-MM-dd’T’HH:mm:ss.SSS’Z’ (UTC用の形式)
{
    "lastModifiedAt" : "2014-03-12T13:11:27.356Z"
}

5.1.3.4.4. ハイパーメディアリンクの形式

ハイパーメディアリンクを設ける場合は、以下に示す形式とすることを推奨する。
推奨する形式のサンプルは以下の通り。
{
    "links" : [
        {
            "rel" : "ownerMember",
            "href" : "http://example.com/api/v1/memebers/M000000001"
        }
    ]
}
  • relhrefという2つのフィールドを持ったLinkオブジェクトをコレクション形式で保持する。
  • relには、なんのリンクか識別するためのリンク名を指定する。
  • hrefには、リソースにアクセスするためのURIを指定する。
  • Linkオブジェクトをコレクション形式で保持するフィールドは、linksとする。

5.1.3.4.5. エラー応答時のフォーマット

エラーを検知した場合、どのようなエラーが発生したのか保持できるフォーマットにする事を推奨する。
特に、クライアントが再操作する事でエラーが解消できる可能性がある場合は、より詳細なエラー情報を含めた方がよい。
逆に、システムの脆弱性をさらすような事象が発生した場合は、詳細なエラー情報は含めるべきではない。この場合、詳細なエラー情報はログに出力すべきである。

エラーを検知した際に応答するフォーマット例を以下に示す。

{
  "code" : "e.ex.fw.7001",
  "message" : "Validation error occurred on item in the request body.",
  "details" : [ {
    "code" : "ExistInCodeList",
    "message" : "\"genderCode\" must exist in code list of CL_GENDER.",
    "target" : "genderCode"
  } ]
}

上記のフォーマット例では、

  • エラーコード(code)
  • エラーメッセージ(message)
  • エラー詳細リスト(details)
をエラー応答時のフォーマットとして用意している。
エラー詳細リストは、入力チェックエラー発生時に利用する事を想定しており、どのフィールドで、どのようなエラーが発生したのかを保持できるフォーマットとしている。

5.1.3.5. HTTPステータスコード

HTTPステータスコードは、以下の指針に則って応答する。

項番 方針
(1)
リクエストが成功した場合は、成功又は転送を示すHTTPステータスコード(2xx又は3xx系)を応答する。
(2)
リクエストが失敗した原因がクライアント側にある場合は、クライアントエラーを示すHTTPステータスコード(4xx系)を応答する。
リクエストが失敗した原因はクライアントにはないが、クライアントの再操作によってリクエストが成功する可能性がある場合も、クライアントエラーとする。
(3)
リクエストが失敗した原因がサーバ側にある場合は、サーバエラーを示すHTTPステータスコード(5xx系)を応答する。

5.1.3.5.1. リクエストが成功した場合のHTTPステータスコード

リクエストが成功した場合は、状況に応じて以下のHTTPステータスコードを応答する。

項番
HTTP
ステータスコード
説明
適用条件
(1)
200
OK
リクエストが成功した事を通知するHTTPステータスコード。
リクエストが成功した結果として、レスポンスのエンティティボディに、リクエストに対応するリソースの情報を出力する際に応答する。
(2)
201
Created
新しいリソースを作成した事を通知するHTTPステータスコード。
POSTメソッドを使用して、新しいリソースを作成した際に使用する。
レスポンスのLocationヘッダに、作成したリソースのURIを設定する。
(3)
204
No Content
リクエストが成功した事を通知するHTTPステータスコード。
リクエストが成功した結果として、レスポンスのエンティティボディに、リクエストに対応するリソースの情報を出力しない時に応答する。

Tip

"200 OK204 No Contentの違いは、レスポンスボディにリソースの情報を出力する/しないの違いとなる。


5.1.3.5.2. リクエストが失敗した原因がクライアント側にある場合のHTTPステータスコード

リクエストが失敗した原因がクライアント側にある場合は、状況に応じて以下のHTTPステータスコードを応答する。

リソースを扱う個々のREST APIで意識する必要があるステータスコードは以下の通り。

項番
HTTP
ステータスコード
説明
適用条件
(1)
400
Bad Request
リクエストの構文やリクエストされた値が間違っている事を通知するHTTPステータスコード。
エンティティボディに指定されたJSONやXMLの形式不備を検出した場合や、JSONやXML又はリクエストパラメータに指定された入力値の不備を検出した場合に応答する。
(2)
404
Not Found
指定されたリソースが存在しない事を通知するHTTPステータスコード。
指定されたURIに対応するリソースがシステム内に存在しない場合に応答する。
(3)
409
Conflict
リクエストされた内容でリソースの状態を変更すると、リソースの状態に矛盾が発生ため処理を中止した事を通知するHTTPステータスコード。
排他エラーが発生した場合や業務エラーを検知した場合に応答する。
エンティティボディには矛盾の内容や矛盾を解決するために必要なエラー内容を出力する必要がある。

リソースを扱う個々のREST APIで意識する必要がないステータスコードは以下の通り。
以下のステータスコードは、フレームワークや共通処理として意識する必要がある。
項番
HTTP
ステータスコード
説明
適用条件
(4)
405
Method Not Allowed
使用されたHTTPメソッドが、指定されたリソースでサポートしていない事を通知するHTTPステータスコード。
サポートされていないHTTPメソッドが使用された事を検知した場合に応答する。
レスポンスのAllowヘッダに、許可されているメソッドの列挙を設定する。
(5)
406
Not Acceptable
指定された形式でリソースの状態を応答する事が出来ないため、リクエストを受理できない事を通知するHTTPステータスコード。
レスポンス形式として、拡張子又はAcceptヘッダで指定された形式をサポートしていない場合に応答する。
(6)
415
Unsupported Media Type
エンティティボディに指定された形式をサポートしていないため、リクエストが受け取れない事を通知するHTTPステータスコード。
リクエスト形式として、Content-Typeヘッダで指定された形式をサポートしていない場合に応答する。

5.1.3.5.3. リクエストが失敗した原因がサーバ側にある場合のHTTPステータスコード

リクエストが失敗した原因がサーバ側にある場合は、状況に応じて以下のHTTPステータスコードを応答する。

項番
HTTP
ステータスコード
説明
適用条件
(1)
500
Internal Server Error
サーバ内部でエラーが発生した事を通知するHTTPステータスコード。
サーバ内で予期しないエラーが発生した場合や、正常稼働時には発生してはいけない状態を検知した場合に応答する。

5.1.3.6. 認証・認可

  • OAuth2の仕組みを使って認証・認可を行う仕組みについては、OAuth 2.0を参照されたい。

5.1.3.7. リソースの条件付き更新の制御

Todo

TBD

HTTPヘッダを使ったリソースの条件付き更新(排他制御)をどのように行うか記載する。

Etag/Last-Modifiedなどのヘッダを使って条件付き更新の仕組みについて、次版以降に記載する予定である。


5.1.3.8. リソースの条件付き取得の制御

Todo

TBD

HTTPヘッダを使ったリソースの条件付き取得(304 Not Modified制御)をどのように行うか記載する。

Etag/Last-Modifiedなどのヘッダを使ったリソースの条件付き取得の仕組みについて、次版以降に記載する予定である。


5.1.3.9. リソースのキャッシュ制御

Todo

TBD

HTTPヘッダを使ったリソースのキャッシュ制御をどのように行うか記載する。

Cache-Control/Pragma/Expiresなどのヘッダを使ったリソースのキャッシュ制御の仕組みについて、次版以降に記載する予定である。


5.1.4. How to use

本節では、RESTful Web Serviceの具体的な作成方法について説明する。

5.1.4.1. Webアプリケーションの構成

RESTful Web Serviceを構築する場合は、以下のいずれかの構成でWebアプリケーション(war)を構築する。
特に理由がない場合は、RESTful Web Service専用のWebアプリケーションとして構築する事を推奨する。
項番 構成 説明
(1)
RESTful Web Service専用のWebアプリケーションとして構築する。
RESTful Web Serviceを利用するクライアントアプリケーション(UI層のアプリケーション)との独立性を確保したい(する必要がある)場合は、RESTful Web Service専用のWebアプリケーション(war)として構築することを推奨する。

RESTful Web Serviceを利用するクライアントアプリケーションが複数になる場合は、この方法でRESTful Web Serviceを生成することになる。
(2)
RESTful Web Service用のDispatcherServletを設けて構築する。
RESTful Web Serviceを利用するクライアントアプリケーション(UI層のアプリケーション)との独立性を確保する必要がない場合は、RESTful Web Serviceとクライアントアプリケーションを一つのWebアプリケーション(war)として構築してもよい。

ただし、RESTful Web Service用のリクエストを受けるDispatcherServletと、クライアントアプリケーション用のリクエストを受け取るDispatcherServletは分割して構築することを強く推奨する。

Note

クライアントアプリケーション(UI層のアプリケーション)とは

ここで言うクライアントアプリケーション(UI層のアプリケーション)とは、HTML, JavaScriptなどのスクリプト, CSS(Cascading Style Sheets)といったクライアント層(UI層)のコンポーネントを応答するアプリケーションの事をさす。 JSPなどのテンプレートエンジンによって生成されるHTMLも対象となる。

Note

DispatcherServletを分割する事を推奨する理由

Spring MVCでは、DispatcherServlet毎にアプリケーションの動作設定を定義することになる。 そのため、RESTful Web Serviceとクライアントアプリケーション(UI層のアプリケーション)のリクエストを同じDispatcherServletで受ける構成にしてしまうと、RESTful Web Service又はクライアントアプリケーション固有の動作設定を定義する事ができなくなったり、設定が煩雑又は複雑になることがある。

本ガイドラインでは、上記の様な問題が起こらないようにするために、RESTful Web Serviceについてクライアントアプリケーションを同じWebアプリケーション(war)として構築する場合は、DispatcherServletを分割することを推奨している。


RESTful Web Service専用のWebアプリケーションとして構築する際の構成イメージは以下の通り。

Constitution of RESTful Web Service

RESTful Web Serviceとクライアントアプリケーションを一つのWebアプリケーションとして構築する際の構成イメージは以下の通り。

Constitution of RESTful Web Service

5.1.4.2. pom.xmlの設定

terasoluna-gfw-common-dependenciesを使用していれば、依存関係の設定は不要である。

5.1.4.3. アプリケーションの設定

RESTful Web Service向けのアプリケーションの設定について説明する。

Warning

StAX(Streaming API for XML)使用時のDoS攻撃対策について

XML形式のデータについてStAXを使用して解析する場合は、DTDを使ったDoS攻撃を受けないように対応する必要がある。

詳細は、CVE-2015-3192 - DoS Attack with XML Inputを参照されたい。


5.1.4.3.1. RESTful Web Serviceで必要となるSpring MVCのコンポーネントを有効化するための設定

RESTful Web Service用のbean定義ファイルを作成する。
以降の説明で示すサンプルを動かす際に必要となる定義を、以下に示す。
  • 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:context="http://www.springframework.org/schema/context"
        xmlns:mvc="http://www.springframework.org/schema/mvc"
        xmlns:util="http://www.springframework.org/schema/util"
        xmlns:aop="http://www.springframework.org/schema/aop"
        xsi:schemaLocation="
            http://www.springframework.org/schema/mvc
            https://www.springframework.org/schema/mvc/spring-mvc.xsd
            http://www.springframework.org/schema/beans
            https://www.springframework.org/schema/beans/spring-beans.xsd
            http://www.springframework.org/schema/util
            https://www.springframework.org/schema/util/spring-util.xsd
            http://www.springframework.org/schema/context
            https://www.springframework.org/schema/context/spring-context.xsd
            http://www.springframework.org/schema/aop
            https://www.springframework.org/schema/aop/spring-aop.xsd
    ">
    
        <!-- Load properties files for placeholder. -->
        <!-- (1) -->
        <context:property-placeholder
            location="classpath*:/META-INF/spring/*.properties" />
    
        <bean id="jsonMessageConverter"
            class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter">
            <property name="objectMapper" ref="objectMapper" />
        </bean>
    
        <bean id="objectMapper" class="org.springframework.http.converter.json.Jackson2ObjectMapperFactoryBean">
            <!-- (2) -->
            <property name="dateFormat">
                <bean class="com.fasterxml.jackson.databind.util.StdDateFormat" />
            </property>
        </bean>
    
        <!-- Register components of Spring MVC. -->
        <!-- (3) -->
         <mvc:annotation-driven>
            <mvc:message-converters register-defaults="false">
                <ref bean="jsonMessageConverter" />
            </mvc:message-converters>
            <!-- (4) -->
            <mvc:argument-resolvers>
                <bean class="org.springframework.data.web.PageableHandlerMethodArgumentResolver" />
            </mvc:argument-resolvers>
        </mvc:annotation-driven>
    
        <!-- Register components of interceptor. -->
        <!-- (5) -->
        <mvc:interceptors>
            <mvc:interceptor>
                <mvc:mapping path="/**" />
                <bean class="org.terasoluna.gfw.web.logging.TraceLoggingInterceptor" />
            </mvc:interceptor>
            <!-- omitted -->
        </mvc:interceptors>
    
        <!-- Scan & register components of RESTful Web Service. -->
        <!-- (6) -->
        <context:component-scan base-package="com.example.project.api" />
    
        <!-- Register components of AOP. -->
        <!-- (7) -->
        <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)
    アプリケーション層のコンポーネントでプロパティファイルに定義されている値を参照する必要がある場合は、<context:property-placeholder>要素を使用してプロパティファイルを読み込む必要がある。
    プロパティファイルから値を取得する方法の詳細については、「プロパティ管理」を参照されたい。
    (2)
    JSONの日付フィールドの形式をISO-8601の拡張形式として扱うための設定を追加する。
    (3)
    RESTful Web Serviceを提供するために必要となるSpring MVCのフレームワークコンポーネントをbean登録する。
    本設定を行うことで、リソースのフォーマットとしてJSONを使用する事ができる。
    上記例では、<mvc:message-converters>要素のregister-defaults属性をfalseにしているので、リソースの形式はJSONに限定される。

    リソースのフォーマットとしてXMLを使用する場合は、XXE 対策が行われているXML用のMessageConverterを指定すること。指定方法は、「XXE 対策の有効化」を参照されたい。
    (4)
    ページ検索機能を有効にするための設定を追加する。
    ページ検索の詳細については、「ページネーション」を参照されたい。
    ページ検索が必要ない場合は、本設定は不要であるが、定義があっても問題はない。
    (5)
    Spring MVCのインターセプタをbean登録する。
    上記例では、共通ライブラリから提供されているTraceLoggingInterceptorを定義している。
    (6)
    RESTful Web Service用のアプリケーション層のコンポーネント(ControllerやHelperクラスなど)をスキャンしてbean登録する。
    com.example.project.apiの部分はプロジェクト毎のパッケージ名となる。
    (7)
    Spring MVCのフレームワークでハンドリングされた例外を、ログ出力するためのAOP定義を指定する。
    HandlerExceptionResolverLoggingInterceptorについては、「例外ハンドリング」を参照されたい。

Note

ObjectMapperのBean定義方法について

Jacksonのcom.fasterxml.jackson.databind.ObjectMapperのBean定義を行う場合は、Springが提供しているJackson2ObjectMapperFactoryBeanを使用するとよい。

Jackson2ObjectMapperFactoryBeanを使用すると、JSR-310 Date and Time APIやJoda Time用の拡張モジュールを自動登録することができ、さらにXMLのBean定義ファイル上で表現が難しかったObjectMapperのコンフィギュレーションも簡単に行うことができる。

なお、ObjectMapperを直接Bean定義するスタイルからJackson2ObjectMapperFactoryBeanを使用するスタイルに変更する場合は、以下のコンフィギュレーションに対するデフォルト値がJacksonのデフォルト値と異なる(無効化されている)点に注意すること。

ObjectMapperの動作をJacksonのデフォルト動作にあわせたい場合は、featuresToEnableプロパティを使用して上記のコンフィギュレーションを有効化する。

<bean id="objectMapper" class="org.springframework.http.converter.json.Jackson2ObjectMapperFactoryBean">
    <!-- ... -->
    <property name="featuresToEnable">
        <array>
            <util:constant static-field="com.fasterxml.jackson.databind.MapperFeature.DEFAULT_VIEW_INCLUSION"/>
            <util:constant static-field="com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES"/>
        </array>
    </property>
</bean>

Jackson2ObjectMapperFactoryBeanの詳細については、Jackson2ObjectMapperFactoryBeanのJavaDocを参照されたい。


5.1.4.3.2. RESTful Web Service用のサーブレットの設定

下記の設定は、RESTful Web Serviceとクライアントアプリケーションを別のWebアプリケーションとして構築する場合の設定例となっている。
RESTful Web Serviceとクライアントアプリケーションを同じWebアプリケーションとして構築する場合は、 「RESTful Web Serviceとクライアントアプリケーションを同じWebアプリケーションとして動かす際の設定」を行う必要がある。
  • web.xml

    <!-- omitted -->
    
    <servlet>
        <!-- (1) -->
        <servlet-name>restAppServlet</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <!-- (2) -->
            <param-value>classpath*:META-INF/spring/spring-mvc-rest.xml</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <!-- (3) -->
    <servlet-mapping>
        <servlet-name>restAppServlet</servlet-name>
        <url-pattern>/api/v1/*</url-pattern>
    </servlet-mapping>
    
    <!-- omitted -->
    
    項番 説明
    (1)
    <servlet-name>要素に、RESTful Web Service用のサーブレットであることを示す名前を指定する。
    上記例では、サーブレット名としてrestAppServletを指定している。
    (2)
    RESTful Web Service用のDispatcherServletを構築する際に使用するSpring MVCのbean定義ファイルを指定する。
    上記例では、Spring MVCのbean定義ファイルとして、クラスパス上にあるMETA-INF/spring/spring-mvc-rest.xmlを指定している。
    (3)
    RESTful Web Service用のDispatcherServletへマッピングするサーブレットパスのパターンの指定を行う。
    上記例では、/api/v1/配下のサーブレットパスをRESTful Web Service用のDispatcherServletにマッピングしている。
    具体的には、
    /api/v1/
    /api/v1/members
    /api/v1/members/xxxxx
    といったサーブレットパスが、RESTful Web Service用のDispatcherServlet(restAppServlet)にマッピングされる。

    Tip

    @RequestMappingアノテーションのvalue属性に指定する値について

    @RequestMappingアノテーションのvalue属性に指定する値は、<url-pattern>要素で指定したワイルドカード(“*”)の部分の値を指定する。

    例えば、@RequestMapping(value = "members")と指定した場合、/api/v1/membersといパスに対する処理を行うメソッドとしてデプロイされる。そのため、@RequestMappingアノテーションのvalue属性には、分割したサーブレットへマッピングするためパス(api/v1)を指定する必要はない。

    @RequestMapping(value = "api/v1/members")と指定すると、/api/v1/api/v1/membersというパスに対する処理を行うメソッドとしてデプロイされてしまうので、注意すること。


5.1.4.4. REST APIの実装

REST APIの実装方法について説明する。
以降の説明では、ショッピングサイトの会員情報(Memberリソース)に対するREST APIの実装例を使用して、説明を行う。

Note

本節では、ドメイン層の実装の説明は行わないが、「REST API実装時に作成したドメイン層のクラスのソースコード」として、添付しておく。

必要に応じて、参照されたい。


まず、説明で使用するREST APIの仕様を以下に示す。


リソースの形式

会員情報のリソースの形式は、以下のようなJSON形式とする。
下記の例では、全フィールドを表示しているが、全てのAPIのリクエストとレスポンスで使用するわけではない。
例えば、passwordはリクエストのみで使用、createdAtlastModifiedAtはレスポンスのみ使用などの違いがある。
{
    "memberId" : "M000000001",
    "firstName" : "Firstname",
    "lastName" : "Lastname",
    "genderCode" : "1",
    "dateOfBirth" : "1977-03-13",
    "emailAddress" : "user1@test.com",
    "telephoneNumber" : "09012345678",
    "zipCode" : "1710051",
    "address" : "Tokyo",
    "credential" : {
        "signId" : "user1@test.com",
        "password" : "zaq12wsx",
        "passwordLastChangedAt" : "2014-03-13T04:39:14.831Z",
        "lastModifiedAt" : "2014-03-13T04:39:14.831Z"
    },
    "createdAt" : "2014-03-13T04:39:14.831Z",
    "lastModifiedAt" : "2014-03-13T04:39:14.831Z"
}

Note

本節では、関連リソースへのハイパーメディアリンクは設けない例となっている。

ハイパーメディアリンクを設ける場合の実装例は、「ハイパーメディアリンクの実装」を参照されたい。


リソースの項目仕様

リソース(JSON)の項目毎の仕様は以下の通りとする。

項番 項目名 I/O仕様 桁数 (min-max) その他の仕様
(1)
memberId String I/O 10-10 POST Membersのリクエスト時は未指定(NULL)であること。
(2)
firstName String I/O 1-128 -
(3)
lastName String I/O 1-128 -
(4)
genderCode
String
(Code)
I/O 1-1
0” : UNKNOWN
1” : MEN
2” : WOMEN
(5)
dateOfBirth Date I/O -
yyyy-MM-dd形式
(ISO-8601拡張形式)
(6)
emailAddress
String
(E-mail)
I/O 1-256 -
(7)
telephoneNumber String I/O 0-20 -
(8)
zipCode String I/O 0-20 -
(9)
address String I/O 0-256 -
(10)
credential
Object
(MemberCredential)
I/O - POST Membersのリクエスト時は指定されていること。
(11)
credential/signId
String
(E-mail)
I/O 0-256 指定がない場合は、emailAddressの値を適用する。
(12)
credential/
password
String I 8-32 -
(13)
credential/
passwordLastChangedAt
Timestamp
with time-zone
O -
yyyy-MM-dd’T’HH:mm:ss.SSS’Z’形式
(ISO-8601拡張形式)
(14)
credential/
lastModifiedAt
Timestamp
with time-zone
O -
yyyy-MM-dd’T’HH:mm:ss.SSS’Z’形式
(ISO-8601拡張形式)
(15)
createdAt
Timestamp
with time-zone
O -
yyyy-MM-dd’T’HH:mm:ss.SSS’Z’形式
(ISO-8601拡張形式)
(16)
lastModifiedAt
Timestamp
with time-zone
O -
yyyy-MM-dd’T’HH:mm:ss.SSS’Z’形式
(ISO-8601拡張形式)

REST API一覧

実装するREST APIは以下の5つのAPIとする。

項番
API名
HTTP
メソッド
リソースパス
ステータス
コード
API概要
(1)
GET Members GET /api/v1/members
200
(OK)
条件に一致するMemberリソースをページ検索する。
(2)
POST Members POST /api/v1/members
201
(Created)
Memberリソースを一件作成する。
(3)
GET Member GET /api/v1/members/{memberId}
200
(OK)
Memberリソースを一件取得する。
(4)
PUT Member PUT /api/v1/members/{memberId}
200
(OK)
Memberリソースを一件更新する。
(5)
DELETE Member DELETE /api/v1/members/{memberId}
204
(No Content)
Memberリソースを一件削除する。

Note

Spring Frameworkにより、HEADメソッドおよびOPTIONSメソッドに対するREST APIが暗黙的に用意されるため、開発者がこれらのREST APIを明示的に実装する必要はない。


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

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

REST API用のクラスを格納するルートパッケージのパッケージ名はapiとして、配下にリソース毎のパッケージ(リソース名の小文字)を作成する事を推奨する。
説明で扱うリソース名はMemberなので、org.terasoluna.examples.rest.api.memberというパッケージとする。

Note

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

  • [リソース名]Resource
  • [リソース名]RestController
  • [リソース名]Validator (必要に応じて作成する)
  • [リソース名]Helper (必要に応じて作成する)

説明で扱うリソースのリソース名はMemberなので、

  • MemberResource
  • MemberRestController
  • MemberValidator
  • MemberHelper

となる。

関連リソースを扱う場合、関連リソース用のクラスも同じパッケージに配置すればよい。


REST API用の共通部品を格納するパッケージは、REST API用のクラスを格納するルートパッケージ直下にcommonという名前で作成し、サブパッケージは機能単位に作成する事を推奨する。
例えば、エラーハンドリングを行う共通部品を格納するサブパッケージの場合、errorという名前でサブパッケージを作成する。
以降の説明で作成する例外ハンドリング用のクラスは、org.terasoluna.examples.rest.api.common.errorというパッケージに格納している。

Note

共通部品が格納されているパッケージという事がわかれば、パッケージ名はcommon以外でもよい。


5.1.4.4.2. Resourceクラスの作成

本ガイドラインでは、Web上に公開するリソースを表現(JSONやXMLを表現)するクラスとして、Resourceクラスを設けることを推奨する。

Note

Resourceクラスを作成する理由

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

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


Resourceクラスの役割は以下の通りである。

項番 役割 説明
(1)
リソースのデータ構造の定義を行う。
Web上に公開するリソースのデータ構造を定義する。
データベースなどの永続層で管理しているデータの構造をそのままWeb上のリソースとして公開する事は、一般的には稀である。
(2)
フォーマットに関する定義を行う。
リソースのフォーマットに関する定義を、アノテーションを使って指定する。
使用するアノテーションは、リソースの形式(JSON/XMLなど)よって異なり、JSON形式の場合はJacksonのアノテーション、XML形式の場合はJAXBのアノテーションを使用する事になる。

Note

Java SE 17環境にてJAXBを使用するにはJAXBの削除を参照されたい。

(3)
入力チェックルールの定義を行う。
項目毎の単項目の入力チェックルールを、Bean Validationのアノテーションを使って指定する。
入力チェックの詳細については、「入力チェック」を参照されたい。

Warning

循環参照への対策

Resourceクラス(JavaBean)をJSONやXML形式にシリアライズする際に、相互参照関係のオブジェクトをプロパティに保持していると、循環参照となりStackOverflowErrorOutOfMemoryErrorなどが発生するので、注意が必要である。

循環参照を回避するためには、

  • Jacksonを使用してJSON形式にシリアライズする場合は、シリアライズ対象から除外するプロパティに@com.fasterxml.jackson.annotation.JsonIgnoreアノテーション
  • JAXBを使用してXML形式にシリアライズする場合は、シリアライズ対象から除外するプロパティにjakarta.xml.bind.annotation.XmlTransientアノテーション

を付与すればよい。

以下にJacksonを使用してJSON形式にシリアライズする際の回避例を示す。

public class Order {
    private String orderId;
    private List<OrderLine> orderLines;
    // omitted
}
public class OrderLine {
    @JsonIgnore
    private Order order;
    private String itemCode;
    private int quantity;
    // omitted
}
項番 説明
(1)
シリアライズ対象から除外するプロパティに対して@JsonIgnoreアノテーションを付与する。

Warning

電文からJava Beanにデシリアライズする際、プロパティにジェネリクスやインターフェイスを使用しているなどの理由で型を特定できない場合は@com.fasterxml.jackson.annotation.JsonTypeInfoアノテーションを付与する。@JsonTypeInfoアノテーションを付与したプロパティをシリアライズするとJSONに型情報が出力され、これを読み取ってデシリアライズが行われる。

ただし、@JsonTypeInfoアノテーションのuse属性にId.CLASSId.MINIMAL_CLASSを使用すると、JSONに出力されたクラス名を元にデシリアライズが行われるため、これにより不正にリモートコードが実行される危険がある。このため、(信頼できない送信元を含み得る)不特定多数からの電文を受け付ける前提のシステムにおいては、Id.CLASSId.MINIMAL_CLASSを指定してはならない。

なお、ObjectMapperdefaultTypingを利用すると、上記のようなデシリアライズ時の型判断をアプリケーション全体に適用することが可能である。

こちらも合わせて注意されたい。


以下にResourceクラスの作成例を示す。

  • MemberResource.java

    package org.terasoluna.examples.rest.api.member;
    
    import java.io.Serializable;
    import java.time.LocalDate;
    import java.time.LocalDateTime;
    
    import org.springframework.format.annotation.DateTimeFormat;
    import org.terasoluna.gfw.common.codelist.ExistInCodeList;
    
    import jakarta.validation.Valid;
    import jakarta.validation.constraints.Email;
    import jakarta.validation.constraints.NotEmpty;
    import jakarta.validation.constraints.NotNull;
    import jakarta.validation.constraints.Null;
    import jakarta.validation.constraints.Past;
    import jakarta.validation.constraints.Size;
    
    // (1)
    public class MemberResource implements Serializable {
    
        private static final long serialVersionUID = 1L;
    
        // (2)
        interface PostMembers {
        }
    
        interface PutMember {
        }
    
        @Null(groups = PostMembers.class)
        @NotEmpty(groups = PutMember.class)
        @Size(min = 10, max = 10, groups = PutMember.class)
        private String memberId;
    
        @NotEmpty
        @Size(max = 128)
        private String firstName;
    
        @NotEmpty
        @Size(max = 128)
        private String lastName;
    
        @NotEmpty
        @ExistInCodeList(codeListId = "CL_GENDER")
        private String genderCode;
    
        @NotNull
        @Past
        private LocalDate dateOfBirth;
    
        @NotEmpty
        @Size(max = 256)
        @Email
        private String emailAddress;
    
        @Size(max = 20)
        private String telephoneNumber;
    
        @Size(max = 20)
        private String zipCode;
    
        @Size(max = 256)
        private String address;
    
        @NotNull(groups = PostMembers.class)
        @Null(groups = PutMember.class)
        @Valid
        // (3)
        private MemberCredentialResource credential;
    
        @Null
        private LocalDateTime createdAt;
    
        @Null
        private LocalDateTime lastModifiedAt;
    
        // omitted setter and getter
    
    }
    
    項番 説明
    (1)
    Memberリソースを表現するJavaBean。
    (2)
    Bean Validationのバリデーショングループを指定するためのインタフェースを定義している。
    実装例では、POSTとPUTで異なる入力チェックを行うため、バリデーションをグループ化して入力チェックを行っている。
    バリデーションのグループ化については、「入力チェック」を参照されたい。
    (3)
    関連リソースをネストしたJavaBeanをフィールドに定義している。
    実装例では、会員の資格情報(サインIDとパスワード)を関連リソースとして扱っている。
    これは、サインIDの変更やパスワードの変更のみ行うという操作を考慮して、関連リソースとして切り出している。
    ただし、関連リソースに対するREST APIの実装例については割愛している。
  • MemberCredentialResource.java

    package org.terasoluna.examples.rest.api.member;
    
    import java.io.Serializable;
    import java.time.LocalDateTime;
    
    import com.fasterxml.jackson.annotation.JsonInclude;
    
    import jakarta.validation.constraints.Email;
    import jakarta.validation.constraints.NotNull;
    import jakarta.validation.constraints.Null;
    import jakarta.validation.constraints.Size;
    
    // (4)
    public class MemberCredentialResource implements Serializable {
    
        private static final long serialVersionUID = 1L;
    
        @Size(max = 256)
        @Email
        private String signId;
    
        // (5)
        @JsonInclude(JsonInclude.Include.NON_NULL)
        @NotNull
        @Size(min = 8, max = 32)
        private String password;
    
        @Null
        private LocalDateTime passwordLastChangedAt;
    
        @Null
        private LocalDateTime lastModifiedAt;
    
        // omitted setter and getter
    
    }
    
    項番 説明
    (4)
    Memberリソースの関連リソースとなるCredentialリソースを表現するJavaBean。
    (5)
    値がnullの時に、JSONにフィールド自体を出力しないようにするためのアノテーションを指定している。
    これは、レスポンスするJSONの中にパスワードのフィールド出力しないようにするために行っている。
    上記例ではNULLの場合(Inclusion.NON_NULL)に限っているが、値が空の場合(Inclusion.NON_EMPTY)という指定も可能である。

5.1.4.4.3. Controllerクラスの作成

Controllerクラスはリソース毎に作成する。
全てのAPIの実装が完了した際のソースコードについては、Appendixを参照されたい。
package org.terasoluna.examples.rest.api.member;

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

// omitted

@RequestMapping("members") // (1)
@RestController // (2)
public class MemberRestController {

    @GetMapping
    @ResponseStatus(HttpStatus.OK)
    public Page<MemberResource> getMembers() {
        // omitted
    }

    // omitted
}
項番 説明
(1)
Controllerに対して、リソースのコレクション用のURI(サーブレットパス)をマッピングする。
具体的には、@RequestMappingアノテーションのvalue属性に、リソースのコレクションを表すサーブレットパスを指定する。
上記例では、 /api/v1/membersというサーブレットパスをマッピングしている。
(2)

Controllerに対して、@RestControllerアノテーションを付与する。

@RestControllerアノテーションを付与することで、

  • クラスにorg.springframework.stereotype.Controllerアノテーションを付与
  • 以降で説明するControllerのメソッドに@org.springframework.web.bind.annotation.ResponseBodyアノテーションを付与

したのと同じ意味となる。

Controllerのメソッドに@ResponseBodyが付与されることで、返却したResourceオブジェクトがJSONやXMLにmarshalされ、レスポンスBODYに設定される。

Tip

@RestControllerアノテーションの詳細については、こちらを参照されたい。

@RestControllerアノテーションを使用せずに、@Controllerアノテーションと@ResponseBodyアノテーションを組み合わせてREST API用のControllerを作成する例を以下に示す。

@RequestMapping("members")
@Controller
public class MemberRestController {

    @GetMapping
    @ResponseStatus(HttpStatus.OK)
    @ResponseBody
    public Page<MemberResource> getMembers() {
        // omitted
    }

    // omitted
}

5.1.4.4.4. リソースのコレクションを取得するREST APIの実装

URIで指定されたMemberリソースのコレクションをページ検索するREST APIの実装例を、以下に示す。

  • 検索条件を受け取るためのJavaBeanの作成
    リソースのコレクションを取得する際に、検索条件が必要な場合は、検索条件を受け取るためのJavaBeanを作成する。
    // (1)
    public class MembersSearchQuery implements Serializable {
        private static final long serialVersionUID = 1L;
    
        // (2)
        @NotEmpty
        private String name;
    
        public String getName() {
            return name;
        }
    
        public void setName(String name) {
            this.name = name;
        }
    
    }
    
    項番 説明
    (1)
    検索条件を受け取るためのJavaBeanを作成する
    検索条件が不要な場合は、JavaBeanの作成は不要である。
    (2)
    プロパティ名は、リクエストパラメータのパラメータ名と一致させる。
    上記例では、/api/v1/members?name=Johnというリクエストの場合、JavaBeanのnameプロパティに Johnという値が設定される。

  • REST APIの実装
    Memberリソースのコレクションをページ検索する処理を実装する。
    @RequestMapping("members")
    @RestController
    public class MemberRestController {
    
        // omitted
    
        @Inject
        MemberService memberService;
    
        // (3)
        @Inject
        MemberRestBeanMapper beanMapper;
    
        // (4)
        @GetMapping
        // (5)
        @ResponseStatus(HttpStatus.OK)
        public Page<MemberResource> getMembers(
                // (6)
                @Validated MembersSearchQuery query,
                // (7)
                Pageable pageable) {
    
            // (8)
            Page<Member> page = memberService.searchMembers(query.getName(), pageable);
    
            // (9)
            List<MemberResource> memberResources = new ArrayList<>();
            for (Member member : page.getContent()) {
                memberResources.add(beanMapper.map(member));
            }
            Page<MemberResource> responseResource = new PageImpl<>(memberResources,
                    pageable, page.getTotalElements());
    
            // (10)
            return responseResource;
        }
    
        // omitted
    
    }
    
    項番 説明
    (3)
    MapStructを用いて定義したマッパーインタフェースBeanMapperをインジェクトする。
    マッパーインタフェースの内容については後述する。
    (4)
    @GetMappingアノテーションを指定する。
    (5)
    メソッドアノテーションとして、@org.springframework.web.bind.annotation.ResponseStatusアノテーションを付与し、応答するステータスコードを指定する。
    @ResponseStatusアノテーションのvalue属性には、200(OK)を設定する。

    Tip

    ステータスコードの指定方法について

    本例では、@ResponseStatusアノテーションを使って応答するステータスコードを固定で指定しているが、Controllerのロジック内で指定する事もできる。

    public ResponseEntity<Page<MemberResource>> getMembers(
            @Validated MembersSearchQuery query,
            Pageable pageable) {
    
        // omitted
    
        return ResponseEntity.ok().body(responseResource);
    }
    

    応答するステータスコードを処理内容や処理結果に応じて変える必要がある場合は、上記実装例の様に、org.springframework.http.ResponseEntityを使用する事になる。

    (6)
    検索条件を受け取るためのJavaBeanを引数に指定する。
    入力チェックが必要な場合は、引数アノテーションとして、@Validatedアノテーションを付与する。入力チェックの詳細については、「入力チェック」を参照されたい。
    (7)
    ページ検索が必要な場合は、org.springframework.data.domain.Pageableを引数に指定する。
    ページ検索の詳細については、「ページネーション」を参照されたい。
    (8)
    ドメイン層のServiceのメソッドを呼び出し、条件に一致するリソースの情報(Entityなど)を取得する。
    ドメイン層の実装については、「ドメイン層の実装」を参照されたい。
    (9)
    条件に一致したリソースの情報(Entityなど)をもとに、Web上に公開する情報を保持するResourceオブジェクトを生成する。
    ページ検索の結果を応答する際は、org.springframework.data.domain.PageImplクラスを使用することで、ページ検索時の応答として必要な項目をクライアントに返却する事ができる。

    上記例では、Beanマッピングライブラリを使用してEntityからResourceオブジェクトを生成している。Beanマッピングライブラリについては、「Beanマッピング(MapStruct)」を参照されたい。
    Resourceオブジェクトを生成するためのコード量が多くなる場合は、HelperクラスにResourceオブジェクトを生成するためのメソッドを作成することを推奨する。
    (10)
    (9)で生成したResourceオブジェクトを返却する。
    ここで返却したオブジェクトがJSONやXMLにmarshalされ、レスポンスBODYに設定される。
PageImplクラスを使用した時のレスポンスは以下の様になる。
ハイライトしている部分が、ページ検索固有の項目となる。
{
  "content" : [ {
    "memberId" : "M000000001",
    "firstName" : "John",
    "lastName" : "Smith",
    "genderCode" : "1",
    "dateOfBirth" : "1977-03-07",
    "emailAddress" : "john.smith@test.com",
    "telephoneNumber" : "09012345678",
    "zipCode" : "1710051",
    "address" : "Tokyo",
    "credential" : {
      "signId" : "john.smit@test.com",
      "passwordLastChangedAt" : "2014-03-13T10:18:08.003Z",
      "lastModifiedAt" : "2014-03-13T10:18:08.003Z"
    },
    "createdAt" : "2014-03-13T10:18:08.003Z",
    "lastModifiedAt" : "2014-03-13T10:18:08.003Z"
  }, {
    "memberId" : "M000000002",
    "firstName" : "Sophia",
    "lastName" : "Smith",
    "genderCode" : "2",
    "dateOfBirth" : "1977-03-07",
    "emailAddress" : "sophia.smith@test.com",
    "telephoneNumber" : "09012345678",
    "zipCode" : "1710051",
    "address" : "Tokyo",
    "credential" : {
      "signId" : "sophia.smith@test.com",
      "passwordLastChangedAt" : "2014-03-13T10:18:08.003Z",
      "lastModifiedAt" : "2014-03-13T10:18:08.003Z"
    },
    "createdAt" : "2014-03-13T10:18:08.003Z",
    "lastModifiedAt" : "2014-03-13T10:18:08.003Z"
  } ],
  "last" : false,
  "totalPages" : 13,
  "totalElements" : 25,
  "size" : 2,
  "number" : 1,
  "sort" : [ {
    "direction" : "DESC",
    "property" : "lastModifiedAt",
    "ignoreCase" : false,
    "nullHandling": "NATIVE",
    "ascending" : false
  } ],
  "numberOfElements" : 2,
  "first" : false
}

  • Controllerに使用するマッパーインタフェースの作成
    上記実装例では、MemberオブジェクトとMemberResourceオブジェクトのマッピングはMapStructを使用している。
    MapStructの説明や使い方についてはBeanマッピング(MapStruct)を参照されたい。
    上記実装例では、Memberオブジェクトの内容をMemberResourceオブジェクトにマッピングする際に、credential.passwordをコピー対象外にする必要がある。
    特定のフィールドをコピー対象外にするためには、@Mappingアノテーションを使用してフィールド除外設定を行う必要がある。
    @Mapper
    public interface BeanMapper {
    
        MemberResource map(Member member);
    
        @Mapping(target = "password", ignore = true) // (1)
        MemberCredentialResource map(MemberCredential memberCredential);
    
        Member map(MemberResource memberResource);
    
    }
    
    項番 説明
    (1)
    targetに対象となるフィールドを、ignoreにtrueを指定することにより、マッピング対象から除外できる。
    なお、このマッピングメソッドは一つ上のMemberからMemberResourceへのマッピングメソッドで呼び出される。

  • リクエスト例

    GET /rest-api-web/api/v1/members?name=Smith&page=0&size=2 HTTP/1.1
    Accept: text/plain, application/json, application/*+json, */*
    User-Agent: Java/1.7.0_51
    Host: localhost:8080
    Connection: keep-alive
    

  • レスポンス例

    HTTP/1.1 200 OK
    Server: Apache-Coyote/1.1
    X-Track: fb63a6d446f849feb8ccaa4c9a794333
    Content-Type: application/json;charset=UTF-8
    Transfer-Encoding: chunked
    Date: Thu, 13 Mar 2014 11:10:43 GMT
    
    {"content":[{"memberId":"M000000001","firstName":"John","lastName":"Smith","genderCode":"1","dateOfBirth":"2013-03-13","emailAddress":"user1394709042120@test.com","telephoneNumber":"09012345678","zipCode":"1710051","address":"Tokyo","credential":{"signId":"user1394709042120@test.com","passwordLastChangedAt":"2014-03-13T11:10:43.066Z","lastModifiedAt":"2014-03-13T11:10:43.066Z"},"createdAt":"2014-03-13T11:10:43.066Z","lastModifiedAt":"2014-03-13T11:10:43.066Z"},{"memberId":"M000000002","firstName":"Sophia","lastName":"Smith","genderCode":"2","dateOfBirth":"2013-03-13","emailAddress":"user1394709043663@test.com","telephoneNumber":"09012345678","zipCode":"1710051","address":"Tokyo","credential":{"signId":"user1394709043663@test.com","passwordLastChangedAt":"2014-03-13T11:10:43.678Z","lastModifiedAt":"2014-03-13T11:10:43.678Z"},"createdAt":"2014-03-13T11:10:43.678Z","lastModifiedAt":"2014-03-13T11:10:43.678Z"}],"last":true,"totalPages":1,"totalElements":2,"size":2,"number":0,"sort":null,"numberOfElements":2,"first":true}
    

Tip

ページ検索が不要な場合は、Resourceクラスのリストを直接扱えばよい。

Resourceクラスのリストを直接扱う場合のControllerのメソッドは以下のような定義となる。

@GetMapping
@ResponseStatus(HttpStatus.OK)
public List<MemberResource> getMembers(
        @Validated MembersSearchQuery query) {
    // omitted
}

Resourceクラスのリストを直接扱った場合、以下のようなJSONとなる。

[ {
    "memberId" : "M000000001",
    "firstName" : "John",
    "lastName" : "Smith",
    "genderCode" : "1",
    "dateOfBirth" : "1977-03-07",
    "emailAddress" : "john.smith@test.com",
    "telephoneNumber" : "09012345678",
    "zipCode" : "1710051",
    "address" : "Tokyo",
    "credential" : {
      "signId" : "john.smit@test.com",
      "passwordLastChangedAt" : "2014-03-13T10:18:08.003Z",
      "lastModifiedAt" : "2014-03-13T10:18:08.003Z"
    },
    "createdAt" : "2014-03-13T10:18:08.003Z",
    "lastModifiedAt" : "2014-03-13T10:18:08.003Z"
}, {
    "memberId" : "M000000002",
    "firstName" : "Sophia",
    "lastName" : "Smith",
    "genderCode" : "2",
    "dateOfBirth" : "1977-03-07",
    "emailAddress" : "sophia.smith@test.com",
    "telephoneNumber" : "09012345678",
    "zipCode" : "1710051",
    "address" : "Tokyo",
    "credential" : {
      "signId" : "sophia.smith@test.com",
      "passwordLastChangedAt" : "2014-03-13T10:18:08.003Z",
      "lastModifiedAt" : "2014-03-13T10:18:08.003Z"
    },
    "createdAt" : "2014-03-13T10:18:08.003Z",
    "lastModifiedAt" : "2014-03-13T10:18:08.003Z"
} ]

5.1.4.4.5. リソースをコレクションに追加するAPI RESTの実装

指定されたMemberリソースを作成し、Memberリソースをコレクションに追加するREST APIの実装例を、以下に示す。

  • REST APIの実装
    指定されたMemberリソースを作成し、Memberリソースをコレクションに追加する処理を実装する。
    @RequestMapping("members")
    @RestController
    public class MemberRestController {
    
        // omitted
    
        // (1)
        @PostMapping
        // (2)
        @ResponseStatus(HttpStatus.CREATED)
        public MemberResource postMember(
                // (3)
                @RequestBody @Validated({ PostMembers.class, Default.class })
                MemberResource requestedResource) {
    
            // (4)
            Member inputMember = beanMapper.map(requestedResource, Member.class);
            Member createdMember = memberService.createMember(inputMember);
    
            MemberResource responseResource = beanMapper.map(createdMember,
                    MemberResource.class);
    
            return responseResource;
        }
    
        // omitted
    
    }
    
    項番 説明
    (1)
    @PostMappingアノテーションを指定する。
    (2)
    メソッドアノテーションとして、@ResponseStatusアノテーションを付与し、応答するステータスコードを指定する。
    @ResponseStatusアノテーションのvalue属性には、201(Created)を設定する。
    (3)
    新規に作成するリソースの情報を受け取るためのJavaBean(Resourceクラス)を引数に指定する。
    引数アノテーションとして、@org.springframework.web.bind.annotation.RequestBodyアノテーションを付与する。
    @RequestBodyアノテーションを付与することで、リクエストBodyに設定されているJSONやXMLのデータがResourceオブジェクトにunmarshalされる。

    入力チェックを有効化するために、引数アノテーションとして、@Validatedアノテーションを付与する。入力チェックの詳細については、「入力チェック」を参照されたい。
    (4)
    ドメイン層のServiceのメソッドを呼び出し、新規にリソースを作成する。
    ドメイン層の実装については、「ドメイン層の実装」を参照されたい。

  • リクエスト例

    POST /rest-api-web/api/v1/members HTTP/1.1
    Accept: text/plain, application/json, application/*+json, */*
    Content-Type: application/json;charset=UTF-8
    User-Agent: Java/1.7.0_51
    Host: localhost:8080
    Connection: keep-alive
    Content-Length: 248
    
    {"firstName":"John","lastName":"Smith","genderCode":"1","dateOfBirth":"2013-03-13","emailAddress":"user1394708306056@test.com","telephoneNumber":"09012345678","zipCode":"1710051","address":"Tokyo","credential":{"signId":null,"password":"zaq12wsx"}}
    

  • レスポンス例

    HTTP/1.1 201 Created
    Server: Apache-Coyote/1.1
    X-Track: c7e9c8a9aa4f40ff87f3acdb77baccdf
    Content-Type: application/json;charset=UTF-8
    Transfer-Encoding: chunked
    Date: Thu, 13 Mar 2014 10:58:26 GMT
    
    {"memberId":"M000000023","firstName":"John","lastName":"Smith","genderCode":"1","dateOfBirth":"2013-03-13","emailAddress":"user1394708306056@test.com","telephoneNumber":"09012345678","zipCode":"1710051","address":"Tokyo","credential":{"signId":"user1394708306056@test.com","passwordLastChangedAt":"2014-03-13T10:58:26.324Z","lastModifiedAt":"2014-03-13T10:58:26.324Z"},"createdAt":"2014-03-13T10:58:26.324Z","lastModifiedAt":"2014-03-13T10:58:26.324Z"}
    

5.1.4.4.6. 指定されたリソースを取得するREST APIの実装

URIで指定されたMemberリソースを取得するREST APIの実装例を、以下に示す。

  • REST APIの実装
    URIで指定されたMemberリソースを取得する処理を実装する。
    @RequestMapping("members")
    @RestController
    public class MemberRestController {
    
        // omitted
    
        // (1)
        @GetMapping("{memberId}")
        // (2)
        @ResponseStatus(HttpStatus.OK)
        public MemberResource getMember(
                // (3)
                @PathVariable("memberId") String memberId) {
    
            // (4)
            Member member = memberService.getMember(memberId);
    
            MemberResource responseResource = beanMapper.map(member,
                    MemberResource.class);
    
            return responseResource;
        }
    
        // omitted
    
    }
    
    項番 説明
    (1)
    @GetMappingアノテーションのvalue属性にパス変数(上記例では{memberId})を指定する。
    {memberId}には、リソースを一意に識別するための値が指定される。
    (2)
    メソッドアノテーションとして、@ResponseStatusアノテーションを付与し、応答するステータスコードを指定する。
    @ResponseStatusアノテーションのvalue属性には、200(OK)を設定する。
    (3)
    リソースを一意に識別するための値を、パス変数から取得する。
    引数アノテーションとして、@PathVariable("memberId")を指定することで、パス変数({memberId})に指定された値をメソッドの引数として受け取ることが出来る。
    パス変数の詳細については、「URLのパスから値を取得する」を参照されたい。
    上記例だと、URIが/api/v1/members/M12345の場合、引数のmemberIdM12345が格納される。
    (4)
    ドメイン層のServiceのメソッドを呼び出し、パス変数から取得したIDに一致するリソースの情報(Entityなど)を取得する。
    ドメイン層の実装については、「ドメイン層の実装」を参照されたい。

  • リクエスト例

    GET /rest-api-web/api/v1/members/M000000003 HTTP/1.1
    Accept: text/plain, application/json, application/*+json, */*
    User-Agent: Java/1.7.0_51
    Host: localhost:8080
    Connection: keep-alive
    

  • レスポンス例

    HTTP/1.1 200 OK
    Server: Apache-Coyote/1.1
    X-Track: 275b4e7a61f946eea47672f272315ac2
    Content-Type: application/json;charset=UTF-8
    Transfer-Encoding: chunked
    Date: Thu, 13 Mar 2014 11:25:13 GMT
    
    {"memberId":"M000000003","firstName":"John","lastName":"Smith","genderCode":"1","dateOfBirth":"2013-03-13","emailAddress":"user1394709913496@test.com","telephoneNumber":"09012345678","zipCode":"1710051","address":"Tokyo","credential":{"signId":"user1394709913496@test.com","passwordLastChangedAt":"2014-03-13T11:25:13.762Z","lastModifiedAt":"2014-03-13T11:25:13.762Z"},"createdAt":"2014-03-13T11:25:13.762Z","lastModifiedAt":"2014-03-13T11:25:13.762Z"}
    

5.1.4.4.7. 指定されたリソースを更新するREST APIの実装

URIで指定されたMemberリソースを更新するREST APIの実装例を、以下に示す。

  • REST APIの実装
    URIで指定されたMemberリソースを更新する処理を実装する。
    @RequestMapping("members")
    @RestController
    public class MemberRestController {
    
        // omitted
    
        // (1)
        @PutMapping("{memberId}")
        // (2)
        @ResponseStatus(HttpStatus.OK)
        public MemberResource putMember(
                @PathVariable("memberId") String memberId,
                // (3)
                @RequestBody @Validated({ PutMember.class, Default.class })
                MemberResource requestedResource) {
    
            // (4)
            Member inputMember = beanMapper.map(
                requestedResource, Member.class);
            Member updatedMember = memberService.updateMember(
                memberId, inputMember);
    
            MemberResource responseResource = beanMapper.map(updatedMember,
                    MemberResource.class);
    
            return responseResource;
        }
    
        // omitted
    
    }
    
    項番 説明
    (1)
    @PutMappingアノテーションのvalue属性にパス変数(上記例では{memberId})を指定する。
    {memberId}には、リソースを一意に識別するための値が指定される。
    (2)
    メソッドアノテーションとして、@ResponseStatusアノテーションを付与し、応答するステータスコードを指定する。
    @ResponseStatusアノテーションのvalue属性には、200(OK)を設定する。
    (3)
    リソースの更新内容を受け取るためのJavaBean(Resourceクラス)を引数に指定する。
    引数アノテーションとして、@RequestBodyアノテーションを付与することで、リクエストBodyに設定されているJSONやXMLのデータがResourceオブジェクトにunmarshalされる。

    入力チェックを有効化するために、引数アノテーションとして、@Validatedアノテーションを付与する。
    入力チェックの詳細については、「入力チェック」を参照されたい。
    (4)
    ドメイン層のServiceのメソッドを呼び出し、パス変数から取得したIDに一致するリソースの情報(Entityなど)を更新する。
    ドメイン層の実装については、「ドメイン層の実装」を参照されたい。

  • リクエスト例

    PUT /rest-api-web/api/v1/members/M000000004 HTTP/1.1
    Accept: text/plain, application/json, application/*+json, */*
    Content-Type: application/json;charset=UTF-8
    User-Agent: Java/1.7.0_51
    Host: localhost:8080
    Connection: keep-alive
    Content-Length: 221
    
    {"memberId":"M000000004","firstName":"John","lastName":"Smith","genderCode":"1","dateOfBirth":"2013-03-08","emailAddress":"user1394710559584@test.com","telephoneNumber":"09012345678","zipCode":"1710051","address":"Tokyo"}
    

  • レスポンス例

    HTTP/1.1 200 OK
    Server: Apache-Coyote/1.1
    X-Track: 5e8fea3aae044e94bf20a293e155af57
    Content-Type: application/json;charset=UTF-8
    Transfer-Encoding: chunked
    Date: Thu, 13 Mar 2014 11:35:59 GMT
    
    {"memberId":"M000000004","firstName":"John","lastName":"Smith","genderCode":"1","dateOfBirth":"2013-03-08","emailAddress":"user1394710559584@test.com","telephoneNumber":"09012345678","zipCode":"1710051","address":"Tokyo","credential":{"signId":"user1394710559584@test.com","passwordLastChangedAt":"2014-03-13T11:35:59.847Z","lastModifiedAt":"2014-03-13T11:35:59.847Z"},"createdAt":"2014-03-13T11:35:59.847Z","lastModifiedAt":"2014-03-13T11:36:00.122Z"}
    

5.1.4.4.8. 指定されたリソースを削除するREST APIの実装

URIで指定されたMemberリソースを削除するREST APIの実装例を、以下に示す。

  • REST APIの実装
    URIで指定されたMemberリソースを削除する処理を実装する。
    @RequestMapping("members")
    @RestController
    public class MemberRestController {
    
        // omitted
    
        // (1)
        @DeleteMapping("{memberId}")
        // (2)
        @ResponseStatus(HttpStatus.NO_CONTENT)
        public void deleteMember(
                @PathVariable("memberId") String memberId) {
    
            // (3)
            memberService.deleteMember(memberId);
    
        }
    
        // omitted
    
    }
    
    項番 説明
    (1)
    @DeleteMappingアノテーションのvalue属性にパス変数(上記例では{memberId})を指定する。
    (2)
    メソッドアノテーションとして、@ResponseStatusアノテーションを付与し、応答するステータスコードを指定する。
    @ResponseStatusアノテーションのvalue属性には、204(NO_CONTENT)を設定する。

    Note

    削除したリソースの情報をレスポンスBODYに設定する場合は、ステータスコードには200(OK)を設定する。

    (3)
    ドメイン層のServiceのメソッドを呼び出し、パス変数から取得したIDに一致するリソースの情報(Entityなど)を削除する。
    ドメイン層の実装については、「ドメイン層の実装」を参照されたい。

  • リクエスト例

    DELETE /rest-api-web/api/v1/members/M000000005 HTTP/1.1
    Accept: text/plain, application/json, application/*+json, */*
    User-Agent: Java/1.7.0_51
    Host: localhost:8080
    Connection: keep-alive
    

  • レスポンス例

    HTTP/1.1 204 No Content
    Server: Apache-Coyote/1.1
    X-Track: e06c5bd40c864a299c48d9be3f12b2c0
    Date: Thu, 13 Mar 2014 11:40:05 GMT
    

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

RESTful Web Serviceで発生した例外のハンドリング方法について説明する。

Spring MVCでは、RESTful Web Service向けの汎用的な例外ハンドリングの仕組みは用意されていない。
代わりに、RESTful Web Service向けの例外ハンドリングの実装を補助してくれるクラスとして、(org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler)が提供されている。
本ガイドラインでは、Spring MVCから提供されているクラスを継承した例外ハンドリング用のクラスを作成し、作成した例外ハンドリング用のクラスに@ControllerAdviceアノテーションを付与する事で、例外ハンドリングを共通的に行う方法を推奨する。
ResponseEntityExceptionHandlerでは、Spring MVCのフレームワーク内で発生する例外を@ExceptionHandlerアノテーションを使ってハンドリングするメソッドが予め実装されている。
そのため、Spring MVCのフレームワーク内で発生する例外のハンドリングを個別に実装する必要がない。
また、ResponseEntityExceptionHandlerでハンドリングされる例外に対応するHTTPステータスコードは、DefaultHandlerExceptionResolverと同様の仕様で設定される。
ハンドリングされる例外と設定されるHTTPステータスコードについては、「DefaultHandlerExceptionResolverで設定されるHTTPレスポンスコードについて」を参照されたい。
ResponseEntityExceptionHandlerのデフォルトの実装ではレスポンスBodyは空で返却されるが、レスポンスBodyにエラー情報を出力する様に拡張する事ができる。
本ガイドラインでは、レスポンスBodyに適切なエラー情報を出力する事を推奨する。
具体的な実装例を説明する前に、ResponseEntityExceptionHandlerを継承した例外ハンドリング用のクラスを作成し、例外ハンドリングを共通的に行う際の処理フローについて説明する。
なお、個別に実装が必要になるのは、赤枠の部分となる。
Image of exception handling by Spring MVC
項番 処理レイヤ 説明
(1)
(2)
Spring MVC
(Framework)
Spring MVCはクライアントからのリクエストを受け付け、REST APIを呼び出す。
(3)


REST APIの処理中に例外が発生する。
発生した例外は、Spring MVCによって捕捉される。
(4)


Spring MVCは、例外ハンドリング用のクラスに処理を委譲する。
(5)
Custom Exception Handler
(Common Component)
例外ハンドリング用のクラスでは、エラー情報を保持するエラーオブジェクトを生成し、Spring MVCに返却する。
(6)
Spring MVC
(Framework)
Spring MVCは、HttpMessageConverterを利用して、エラーオブジェクトをJSON形式の電文に変換する。
(7)


Spring MVCは、JSON形式のエラー電文をレスポンスBODYに設定し、クライアントにレスポンスする。

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

  • エラー情報は以下のJSON形式とする。

    {
      "code" : "e.ex.fw.7001",
      "message" : "Validation error occurred on item in the request body.",
      "details" : [ {
        "code" : "ExistInCodeList",
        "message" : "\"genderCode\" must exist in code list of CL_GENDER.",
        "target" : "genderCode"
      } ]
    }
    

  • エラー情報を保持するJavaBeanを作成する。

    package org.terasoluna.examples.rest.api.common.error;
    
    import java.io.Serializable;
    import java.util.ArrayList;
    import java.util.List;
    
    import com.fasterxml.jackson.annotation.JsonInclude;
    
    // (1)
    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; // (2)
    
        @JsonInclude(JsonInclude.Include.NON_EMPTY)
        private final List<ApiError> details = new ArrayList<>(); // (3)
    
        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);
        }
    
    }
    
    項番 説明
    (1)
    エラー情報を保持するためのクラスを作成する。
    上記例では、エラーコード、エラーメッセージ、エラー対象、エラーの詳細情報のリストを保持するクラスとなっている。
    (2)
    エラーが発生した対象を識別するための値を保持するフィールド。
    入力チェックでエラーが発生した場合、どの項目でエラーが発生したのかを識別できる値をクライアントに返却する事が求められるケースがある。
    そのような場合は、エラーが発生した項目名を保持するフィールドが必要になる。
    (3)
    エラーの詳細情報のリストを保持するためのフィールド。
    入力チェックでエラーが発生した場合、エラー原因が複数存在する場合があるため、すべてのエラー情報をクライアントに返却する事が求められるケースがある。
    そのような場合は、エラーの詳細情報をリストで保持するフィールドが必要になる。

    Tip

    フィールドに@JsonInclude(JsonInclude.Include.NON_EMPTY)を指定することで、値がnullや空の場合にJSONに項目が出力されないようにする事が出来る。 項目を出力させないための条件をnullに限定したい場合は、@JsonInclude(JsonInclude.Include.NON_NULL)を指定すればよい。


  • エラー情報を保持するJavaBeanを生成するためのクラスを作成する。

    全ての例外ハンドリングの実装が完了した際のソースコードについては、Appendixを参照されたい。

    // (4)
    @Component
    public class ApiErrorCreator {
    
        @Inject
        MessageSource messageSource;
    
        public ApiError createApiError(WebRequest request, String errorCode,
                String defaultErrorMessage, Object... arguments) {
            // (5)
            String localizedMessage = messageSource.getMessage(errorCode,
                    arguments, defaultErrorMessage, request.getLocale());
            return new ApiError(errorCode, localizedMessage);
        }
    
        // omitted
    
    }
    
    項番 説明
    (4)
    必要に応じて、エラー情報を生成するためのメソッドを提供するクラスを作成する。
    このクラスの作成は必須ではないが、役割を明確に分担するために作成する事を推奨する。
    (5)
    エラーメッセージは、MessageSourceより取得する。
    メッセージの管理方法については、「メッセージ管理」を参照されたい。

    Tip

    上記例では、メッセージのローカライズをサポートするためにorg.springframework.web.context.request.WebRequestを引数として受け取っている。メッセージのローカライズが必要ない場合は、WebRequestは不要である。

    java.util.LocaleではなくWebRequestを引数にしている理由は、エラーメッセージの中にHTTPリクエストの内容を埋め込むといった要件が追加される事を考慮したためである。エラーメッセージの中にHTTPリクエストの内容を埋め込む要件がない場合は、Localeでもよい。


  • ResponseEntityExceptionHandlerのメソッドを拡張し、レスポンスBodyにエラー情報を出力するための実装を行う。

    全ての例外ハンドリングの実装が完了した際のソースコードについては、Appendixを参照されたい。

    @ControllerAdvice // (6)
    public class ApiGlobalExceptionHandler extends ResponseEntityExceptionHandler {
    
        @Inject
        ApiErrorCreator apiErrorCreator;
    
        @Inject
        ExceptionCodeResolver exceptionCodeResolver;
    
        // (7)
        @Override
        protected ResponseEntity<Object> handleExceptionInternal(Exception ex,
                Object body, HttpHeaders headers, HttpStatus status,
                WebRequest request) {
            final Object apiError;
            // (8)
            if (body == null) {
                String errorCode = exceptionCodeResolver.resolveExceptionCode(ex);
                apiError = apiErrorCreator.createApiError(request, errorCode, ex
                        .getLocalizedMessage());
            } else {
                apiError = body;
            }
            // (9)
            return ResponseEntity.status(status).headers(headers).body(apiError);
        }
    
        // omitted
    
    }
    
    項番 説明
    (6)
    Spring MVCから提供されているResponseEntityExceptionHandlerを継承したクラスを作成し、@ControllerAdviceアノテーションを付与する。
    (7)
    ResponseEntityExceptionHandlerのhandleExceptionInternalメソッドをオーバライドする。
    (8)
    レスポンスBodyに出力するJavaBeanの指定がない場合は、エラー情報を保持するJavaBeanオブジェクトを生成する。
    上記例では、共通ライブラリから提供しているExceptionCodeResolverを使用して、例外クラスをエラーコードへ変換している。
    ExceptionCodeResolverの設定例については、「ExceptionCodeResolverを使ったエラーコードとメッセージの解決」を参照されたい。

    レスポンスBodyに出力するJavaBeanの指定がある場合は、指定されたJavaBeanをそのまま使用する。
    この処理は、例外毎のエラーハンドリング処理にて、個別にエラー情報が生成される事を考慮した実装となっている。
    (9)
    レスポンス用のHTTP EntityのBody部分に、(8)で生成したエラー情報を設定し返却する。
    返却したエラー情報は、フレームワークによってJSONに変換されレスポンスされる。

    ステータスコードには、Spring MVCから提供されているResponseEntityExceptionHandlerによって適切な値が設定される。
    設定されるステータスコードについては、「DefaultHandlerExceptionResolverで設定されるHTTPレスポンスコードについて」を参照されたい。

    Tip

    @ControllerAdviceアノテーションの属性について

    @ControllerAdviceアノテーションの属性を指定することで、@ControllerAdviceが付与されたクラスで実装したメソッドを適用するControllerを柔軟に指定できる。 属性の詳細については、@ControllerAdviceの属性を参照されたい。

    Note

    @ControllerAdviceアノテーションの属性使用時の注意点

    @ControllerAdviceアノテーションの属性を使用することで、さまざまな粒度で例外ハンドリングを共通化することができるようになるが、 アプリケーション共通の例外ハンドラクラス(上記例のApiGlobalExceptionHandlerクラスに相当するクラス)に対しては、@ControllerAdviceアノテーションの属性を指定しない方がよい。

    ApiGlobalExceptionHandlerに付与する@ControllerAdviceアノテーションに属性を指定した場合、Spring MVCが提供するフレームワーク処理の中で発生する一部の例外をハンドリングできないケースがある。

    具体的には、リクエストに対応するREST API(Controllerのハンドラメソッド)が見つからない時に発生する例外をApiGlobalExceptionHandlerクラスでハンドリングする事ができないため、 「405 Method Not Allowed」などのエラーを正しく応答する事が出来なくなってしまう。


  • レスポンス例

    HTTP/1.1 400 Bad Request
    Server: Apache-Coyote/1.1
    X-Track: e60b3b6468194e22852c8bfc7618e625
    Content-Type: application/json;charset=UTF-8
    Transfer-Encoding: chunked
    Date: Thu, 13 Mar 2014 12:16:55 GMT
    Connection: close
    
    {"code":"e.ex.fw.7001","message":"Validation error occurred on item in the request body.","details":[{"code":"ExistInCodeList","message":"\"genderCode\" must exist in code list of CL_GENDER.","target":"genderCode"}]}
    

5.1.4.5.2. 入力エラー例外のハンドリング実装

入力エラー(電文不正、単項目チェックエラー、相関項目チェックエラー)を応答するための実装例について説明する。

入力エラーを応答するためには、以下の3つの例外をハンドリングする必要がある。

項番 例外 説明
(1)
org.springframework.web.bind.
MethodArgumentNotValidException
リクエストBODYに指定されたJSONやXMLに対する入力チェックでエラーが発生した場合、本例外が発生する。
具体的には、リソースのPOST又はPUT時に指定するリソースに不正な値が指定されている場合に発生する。
(2)
org.springframework.validation.
BindException
リクエストパラメータ(key=value形式のクエリ文字列)に対する入力チェックでエラーが発生した場合、本例外が発生する。
具体的には、リソースコレクションのGET時に指定する検索条件に不正な値が指定されている場合に発生する。
(3)
org.springframework.http.converter.
HttpMessageNotReadableException
JSONやXMLからResourceオブジェクトを生成する際にエラーが発生した場合は、本例外が発生する。
具体的には、JSONやXMLの構文不正やスキーマ定義に違反などがあった場合に発生する。

Note

Spring Frameworkから提供されているアノテーションを使用してリクエストパラメータ、リクエストヘッダ、パス変数から値を取得する際に、値の型変換エラーが発生した場合、org.springframework.beans.TypeMismatchExceptionが発生する。

Controllerのハンドラメソッドの引数(String以外の引数)に、以下のアノテーションを指定した場合、TypeMismatchExceptionが発生する可能性がある。

  • @org.springframework.web.bind.annotation.RequestParam
  • @org.springframework.web.bind.annotation.RequestHeader
  • @org.springframework.web.bind.annotation.Pathvariable
  • @org.springframework.web.bind.annotation.MatrixVariable

TypeMismatchExceptionは、ResponseEntityExceptionHandlerによって例外がハンドリングされ、400(Bad Request)となるので個別にハンドリングしなくてもよい。

エラー情報に設定するエラーコードとエラーメッセージの解決方法については、「ExceptionCodeResolverを使ったエラーコードとメッセージの解決」を参照されたい。


  • 入力チェックエラー用のエラー情報を生成するためのメソッドを作成する。

    @Component
    public class ApiErrorCreator {
    
        @Inject
        MessageSource messageSource;
    
        // omitted
    
        // (1)
        public ApiError createBindingResultApiError(WebRequest request,
                String errorCode, BindingResult bindingResult,
                String defaultErrorMessage) {
            ApiError apiError = createApiError(request, errorCode,
                    defaultErrorMessage);
            for (FieldError fieldError : bindingResult.getFieldErrors()) {
                apiError.addDetail(createApiError(request, fieldError, fieldError
                        .getField()));
            }
            for (ObjectError objectError : bindingResult.getGlobalErrors()) {
                apiError.addDetail(createApiError(request, objectError, objectError
                        .getObjectName()));
            }
            return apiError;
        }
    
        // (2)
        private ApiError createApiError(WebRequest request,
                DefaultMessageSourceResolvable messageResolvable, String target) {
            String localizedMessage = messageSource.getMessage(messageResolvable,
                    request.getLocale());
            return new ApiError(messageResolvable.getCode(), localizedMessage, target);
        }
    
        // omitted
    
    }
    
    項番 説明
    (1)
    入力チェック用のエラー情報を生成するためのメソッドを作成する。
    上記例では、単項目チェックエラー(FieldError)と相関項目チェックエラー(ObjectError)を、エラーの詳細情報に追加している。
    項目毎のエラー情報を出力する必要がない場合は、本メソッドを用意する必要はない。
    (2)
    単項目チェックエラー(FieldError)と相関項目チェックエラー(ObjectError)で同じ処理を実装する事になるので、共通メソッドとして本メソッドを作成している。

  • ResponseEntityExceptionHandlerのメソッドを拡張し、レスポンスBodyに入力チェック用のエラー情報を出力するための実装を行う。

    @ControllerAdvice
    public class ApiGlobalExceptionHandler extends ResponseEntityExceptionHandler {
    
        @Inject
        ApiErrorCreator apiErrorCreator;
    
        @Inject
        ExceptionCodeResolver exceptionCodeResolver;
    
        // omitted
    
        // (3)
        @Override
        protected ResponseEntity<Object> handleMethodArgumentNotValid(
                MethodArgumentNotValidException ex, HttpHeaders headers,
                HttpStatus status, WebRequest request) {
            return handleBindingResult(ex, ex.getBindingResult(), headers, status,
                    request);
        }
    
        // (4)
        @Override
        protected ResponseEntity<Object> handleBindException(BindException ex,
                HttpHeaders headers, HttpStatus status, WebRequest request) {
            return handleBindingResult(ex, ex.getBindingResult(), headers, status,
                    request);
        }
    
        // (5)
        @Override
        protected ResponseEntity<Object> handleHttpMessageNotReadable(
                HttpMessageNotReadableException ex, HttpHeaders headers,
                HttpStatus status, WebRequest request) {
            if (ex.getCause() instanceof Exception) {
                return handleExceptionInternal((Exception) ex.getCause(), null,
                        headers, status, request);
            } else {
                return handleExceptionInternal(ex, null, headers, status, request);
            }
        }
    
        // omitted
    
        // (6)
        protected ResponseEntity<Object> handleBindingResult(Exception ex,
                BindingResult bindingResult, HttpHeaders headers,
                HttpStatus status, WebRequest request) {
            String code = exceptionCodeResolver.resolveExceptionCode(ex);
            String errorCode = exceptionCodeResolver.resolveExceptionCode(ex);
            ApiError apiError = apiErrorCreator.createBindingResultApiError(
                    request, errorCode, bindingResult, ex.getMessage());
            return handleExceptionInternal(ex, apiError, headers, status, request);
        }
    
        // omitted
    
    }
    
    項番 説明
    (3)
    ResponseEntityExceptionHandlerのhandleMethodArgumentNotValidメソッドをオーバライドし、MethodArgumentNotValidExceptionのエラーハンドリングを拡張する。
    上記例では、入力チェックエラーをハンドリングするための共通メソッド(6)に処理を委譲している。
    項目毎のエラー情報を出力する必要がない場合は、オーバライドする必要はない。

    ステータスコードには400(Bad Request)が設定され、指定されたリソースの項目値に不備がある事を通知する。
    (4)
    ResponseEntityExceptionHandlerのhandleBindExceptionメソッドをオーバライドし、BindExceptionのエラーハンドリングを拡張する。
    上記例では、入力チェックエラーをハンドリングするための共通メソッド(6)に処理を委譲している。
    項目毎のエラー情報を出力する必要がない場合は、オーバライドする必要はない。

    ステータスコードには400(Bad Request)が設定され、指定されたリクエストパラメータに不備がある事を通知する。
    (5)
    ResponseEntityExceptionHandlerのhandleHttpMessageNotReadableメソッドをオーバライドし、HttpMessageNotReadableExceptionのエラーハンドリングを拡張する。
    上記例では、細かくエラーハンドリングを行うために、原因例外を使ってエラーハンドリングしている。
    細かくエラーハンドリングをしなくてもよい場合は、オーバライドする必要はない。

    ステータスコードには400(Bad Request)が設定され、指定されたリソースのフォーマットなどに不備がある事を通知する。
    (6)
    入力チェックエラーのエラー情報を保持するJavaBeanオブジェクトを生成する。
    上記例では、handleMethodArgumentNotValidとhandleBindExceptionで同じ処理を実装する事になるので、共通メソッドとして本メソッドを作成している。

    Tip

    JSON使用時のエラーハンドリングについて

    リソースのフォーマットとしてJSONを使用する場合、以下の例外がHttpMessageNotReadableExceptionの原因例外として格納される。

    項番 例外クラス 説明
    (1)
    com.fasterxml.jackson.core.
    JsonParseException
    JSONとして不正な構文が含まれる場合に発生する。
    (2)
    com.fasterxml.jackson.databind.exc.
    UnrecognizedPropertyException
    Resourceオブジェクトに存在しないフィールドがJSONに指定されている場合に発生する。
    (3)
    com.fasterxml.jackson.databind.
    JsonMappingException
    JSONからResourceオブジェクトへ変換する際に、値の型変換エラーが発生した場合に発生する。

  • 入力チェックエラー(単項目チェック、相関項目チェックエラー)が発生した場合、以下のようなエラー応答が行われる。

    HTTP/1.1 400 Bad Request
    Server: Apache-Coyote/1.1
    X-Track: 13522b3badf2432ba4cad0dc7aeaee80
    Content-Type: application/json;charset=UTF-8
    Transfer-Encoding: chunked
    Date: Wed, 19 Feb 2014 05:08:28 GMT
    Connection: close
    
    {"code":"e.ex.fw.7002","message":"Validation error occurred on item in the request parameters.","details":[{"code":"NotEmpty","message":"\"{0}\" may not be empty.","target":"name"}]}
    

  • JSONエラー(フォーマットエラーなど)が発生した場合、以下のようなエラー応答が行われる。

    HTTP/1.1 400 Bad Request
    Server: Apache-Coyote/1.1
    X-Track: ca4c742a6bfd49e5bc01cd0b124738a1
    Content-Type: application/json;charset=UTF-8
    Transfer-Encoding: chunked
    Date: Wed, 19 Feb 2014 13:32:24 GMT
    Connection: close
    
    {"code":"e.ex.fw.7003","message":"Request body format error occurred."}
    

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

リソースが存在しない場合に、リソース未検出エラーを応答するための実装例について説明する。

パス変数から取得したIDに一致するリソースが見つからない場合は、リソースが見つからない事を通知する例外を発生させる。
リソースが見つからなかった事を通知する例外として、共通ライブラリよりorg.terasoluna.gfw.common.exception.ResourceNotFoundExceptionを用意している。
以下に実装例を示す。

  • パス変数から取得したIDに一致するリソースが見つからない場合は、ResourceNotFoundExceptionを発生させる。

    public Member getMember(String memberId) {
        Member member = memberRepository.findByMemberId(memberId);
        if (member == null) {
            throw new ResourceNotFoundException(ResultMessages.error().add(
                    "e.ex.mm.5001", memberId));
        }
        return member;
    }
    

  • ResultMessages用のエラー情報を生成するためのメソッドを作成する。

    @Component
    public class ApiErrorCreator {
    
        // omitted
    
        // (1)
        public ApiError createResultMessagesApiError(WebRequest request,
                String rootErrorCode, ResultMessages resultMessages,
                String defaultErrorMessage) {
            ApiError apiError;
            if (resultMessages.getList().size() == 1) {
                ResultMessage resultMessage = resultMessages.iterator().next();
                String errorCode = resultMessage.getCode();
                String errorText = resultMessage.getText();
                if (errorCode == null && errorText == null) {
                    errorCode = rootErrorCode;
                }
                apiError = createApiError(request, errorCode, errorText,
                        resultMessage.getArgs());
            } else {
                apiError = createApiError(request, rootErrorCode,
                        defaultErrorMessage);
                for (ResultMessage resultMessage : resultMessages.getList()) {
                    apiError.addDetail(createApiError(request, resultMessage
                            .getCode(), resultMessage.getText(), resultMessage
                            .getArgs()));
                }
            }
            return apiError;
        }
    
        // omitted
    
    }
    
    項番 説明
    (1)
    処理結果からエラー情報を生成するためのメソッドを作成する。
    上記例では、ResultMessagesが保持しているメッセージ情報を、エラー情報に設定している。

    Note

    上記例では、ResultMessagesが複数のメッセージを保持する事ができるため、格納されているメッセージが1件の時と複数件の時で処理をわけている。

    複数件のメッセージをサポートする必要がない場合は、先頭の1件をエラー情報として生成する処理にすればよい。


  • エラーハンドリングを行うクラスに、リソースが見つからない事を通知する例外をハンドリングするためのメソッドを作成する。

    @ControllerAdvice
    public class ApiGlobalExceptionHandler extends ResponseEntityExceptionHandler {
    
        @Inject
        ApiErrorCreator apiErrorCreator;
    
        @Inject
        ExceptionCodeResolver exceptionCodeResolver;
    
        // omitted
    
        // (2)
        @ExceptionHandler(ResourceNotFoundException.class)
        public ResponseEntity<Object> handleResourceNotFoundException(
                ResourceNotFoundException ex, WebRequest request) {
            return handleResultMessagesNotificationException(ex, new HttpHeaders(),
                    HttpStatus.NOT_FOUND, request);
        }
    
        // omitted
    
        // (3)
        private ResponseEntity<Object> handleResultMessagesNotificationException(
                ResultMessagesNotificationException ex, HttpHeaders headers,
                HttpStatus status, WebRequest request) {
            String errorCode = exceptionCodeResolver.resolveExceptionCode(ex);
            ApiError apiError = apiErrorCreator.createResultMessagesApiError(
                    request, errorCode, ex.getResultMessages(), ex.getMessage());
            return handleExceptionInternal(ex, apiError, headers, status, request);
        }
    
        // omitted
    
    }
    
項番 説明
(2)
ResourceNotFoundExceptionをハンドリングするためのメソッドを追加する。
メソッドアノテーションとして@ExceptionHandler(ResourceNotFoundException.class)を指定すると、ResourceNotFoundExceptionの例外をハンドリングする事ができる。
上記例では、ResourceNotFoundExceptionの親クラス(ResultMessagesNotificationException)の例外をハンドリングするメソッドに処理を委譲している。

ステータスコードには404(Not Found)を設定し、指定されたリソースがサーバに存在しない事を通知する。
(3)
リソース未検出エラー及び業務エラーのエラー情報を保持するJavaBeanオブジェクトを生成する。
上記例では、以降で説明する業務エラーのハンドリング処理と同じ処理となるので、共通メソッドとして本メソッドを作成している。

  • リソースが見つからない場合、以下のようなエラー応答が行われる。

    HTTP/1.1 404 Not Found
    Server: Apache-Coyote/1.1
    X-Track: 5ee563877f3140fd904d0acf52eba398
    Content-Type: application/json;charset=UTF-8
    Transfer-Encoding: chunked
    Date: Wed, 19 Feb 2014 08:46:18 GMT
    
    {"code":"e.ex.mm.5001","message":"Specified member not found. member id : M000000001"}
    

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

ビジネスルールの違反を検知した場合に、業務エラーを応答するための実装例について説明する。

ビジネスルールのチェックはServiceの処理として行い、ビジネスルールの違反を検知した場合は、業務例外を発生させる。 業務エラーの検知方法については、「業務エラーを通知する」を参照されたい。

  • エラーハンドリングを行うクラスに、業務例外をハンドリングするためのメソッドを作成する。

    @ControllerAdvice
    public class ApiGlobalExceptionHandler extends ResponseEntityExceptionHandler {
    
        // omitted
    
        // (1)
        @ExceptionHandler(BusinessException.class)
        public ResponseEntity<Object> handleBusinessException(BusinessException ex,
                WebRequest request) {
            return handleResultMessagesNotificationException(ex, new HttpHeaders(),
                    HttpStatus.CONFLICT, request);
        }
    
        // omitted
    
    }
    
    項番 説明
    (1)
    BusinessExceptionをハンドリングするためのメソッドを追加する。
    メソッドアノテーションとして@ExceptionHandler(BusinessException.class)を指定すると、BusinessExceptionの例外をハンドリングする事ができる。
    上記例では、BusinessExceptionの親クラス(ResultMessagesNotificationException)の例外をハンドリングするメソッドに処理を委譲している。

    ステータスコードには409(Conflict)を設定し、クライアントから指定されたリソース自体には不備はないが、サーバで保持しているリソースを操作するための条件が全て整っていない事を通知する。

  • 業務エラーが発生した場合、以下のようなエラー応答が行われる。

    HTTP/1.1 409 Conflict
    Server: Apache-Coyote/1.1
    X-Track: 37c1a899d5f74e7a9c24662292837ef7
    Content-Type: application/json;charset=UTF-8
    Transfer-Encoding: chunked
    Date: Wed, 19 Feb 2014 09:03:26 GMT
    
    {"code":"e.ex.mm.8001","message":"Cannot use specified sign id. sign id : user1@test.com"}
    

5.1.4.5.5. 排他エラー例外のハンドリング実装

排他エラーが発生した場合に、排他エラーを応答するための実装例について説明する。
排他制御を行う場合は、排他エラーのハンドリングが必要となる。
排他制御の詳細については、「排他制御」を参照されたい。
  • エラーハンドリングを行うクラスに、排他エラーをハンドリングするためのメソッドを作成する。

    @ControllerAdvice
    public class ApiGlobalExceptionHandler extends ResponseEntityExceptionHandler {
    
        // omitted
    
        // (1)
        @ExceptionHandler({ OptimisticLockingFailureException.class,
                PessimisticLockingFailureException.class })
        public ResponseEntity<Object> handleLockingFailureException(Exception ex,
                WebRequest request) {
            return handleExceptionInternal(ex, null, new HttpHeaders(),
                    HttpStatus.CONFLICT, request);
        }
    
        // omitted
    
    }
    
    項番 説明
    (1)
    排他エラー(OptimisticLockingFailureExceptionPessimisticLockingFailureException)をハンドリングするためのメソッドを追加する。
    メソッドアノテーションとして@ExceptionHandler({ OptimisticLockingFailureException.class, PessimisticLockingFailureException.class })を指定すると、排他エラー(OptimisticLockingFailureExceptionPessimisticLockingFailureException)の例外をハンドリングする事ができる。

    ステータスコードには409(Conflict)を設定し、クライアントから指定されたリソース自体には不備はないが、処理が競合したためリソースを操作するための条件を満たすことが出来なかった事を通知する。

  • 排他エラーが発生した場合、以下のようなエラー応答が行われる。

    HTTP/1.1 409 Conflict
    Server: Apache-Coyote/1.1
    X-Track: 85200b5a51be42b29840e482ee35087f
    Content-Type: application/json;charset=UTF-8
    Transfer-Encoding: chunked
    Date: Wed, 19 Feb 2014 16:32:45 GMT
    
    {"code":"e.ex.fw.8002","message":"Conflict with other processing occurred."}
    

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

システム異常を検知した場合に、システムエラーを応答するための実装例について説明する。

システム異常の検知した場合は、システム例外を発生させる。 システムエラーの検知方法については、「システムエラーを通知する」を参照されたい。

  • エラーハンドリングを行うクラスに、システム例外をハンドリングするためのメソッドを作成する。

    @ControllerAdvice
    public class ApiGlobalExceptionHandler extends ResponseEntityExceptionHandler {
    
        // omitted
    
        // (1)
        @ExceptionHandler(Exception.class)
        public ResponseEntity<Object> handleSystemError(Exception ex,
                WebRequest request) {
            return handleExceptionInternal(ex, null, new HttpHeaders(),
                    HttpStatus.INTERNAL_SERVER_ERROR, request);
        }
    
        // omitted
    
    }
    
    項番 説明
    (1)
    Exceptionをハンドリングするためのメソッドを追加する。
    メソッドアノテーションとして@ExceptionHandler(Exception.class)を指定すると、Exceptionの例外をハンドリングする事ができる。
    上記例では、使用している依存ライブラリから発生するシステム例外もハンドリング対象としている。

    ステータスコードには500(Internal Server Error)を設定する。

  • システムエラーが発生した場合、以下のようなエラー応答が行われる。

    HTTP/1.1 500 Internal Server Error
    Server: Apache-Coyote/1.1
    X-Track: 3625d5a040a744e49b0a9b3763a24e9c
    Content-Type: application/json;charset=UTF-8
    Transfer-Encoding: chunked
    Date: Wed, 19 Feb 2014 12:22:33 GMT
    Connection: close
    
    {"code":"e.ex.fw.9003","message":"System error occurred."}
    

    Warning

    システムエラー時のエラーメッセージについて

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

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


5.1.4.5.7. ExceptionCodeResolverを使ったエラーコードとメッセージの解決

共通ライブラリより提供しているExceptionCodeResolverを使用すると、例外クラスからエラーコードを解決する事ができる。
特に、エラー原因がクライアント側にある場合は、エラー原因に応じたエラーメッセージを設定する事が求められるケースがあるため、そのような場合に便利な機能である。
  • applicationContext.xml
    例外クラスとエラーコード(例外コード)のマッピングを行う。
    <!-- omitted -->
    
    <bean id="exceptionCodeResolver"
        class="org.terasoluna.gfw.common.exception.SimpleMappingExceptionCodeResolver">
        <property name="exceptionMappings">
            <map>
                <!-- omitted -->
                <entry key="ResourceNotFoundException"              value="e.ex.fw.5001" />
                <entry key="HttpRequestMethodNotSupportedException" value="e.ex.fw.6001" />
                <entry key="MediaTypeNotAcceptableException"        value="e.ex.fw.6002" />
                <entry key="HttpMediaTypeNotSupportedException"     value="e.ex.fw.6003" />
                <entry key="MethodArgumentNotValidException"        value="e.ex.fw.7001" />
                <entry key="BindException"                          value="e.ex.fw.7002" />
                <entry key="JsonParseException"                     value="e.ex.fw.7003" />
                <entry key="UnrecognizedPropertyException"          value="e.ex.fw.7004" />
                <entry key="JsonMappingException"                   value="e.ex.fw.7005" />
                <entry key="TypeMismatchException"                  value="e.ex.fw.7006" />
                <entry key="BusinessException"                      value="e.ex.fw.8001" />
                <entry key="OptimisticLockingFailureException"      value="e.ex.fw.8002" />
                <entry key="PessimisticLockingFailureException"     value="e.ex.fw.8002" />
                <entry key="DataAccessException"                    value="e.ex.fw.9002" />
                <!-- omitted -->
            </map>
        </property>
        <property name="defaultExceptionCode" value="e.ex.fw.9001" />
    </bean>
    
    <!-- omitted -->
    

エラーコードに対応するメッセージの設定例を以下に示す。
メッセージの管理方法については、「メッセージ管理」を参照されたい。
  • xxx-web/src/main/resources/i18n/application-messages.properties
    アプリケーション層で発生するエラーに対して、エラーコード(例外コード)に対応するメッセージの設定を行う。
    # ---
    # Application common messages
    # ---
    e.ex.fw.5001 = Resource not found.
    
    e.ex.fw.6001 = Request method not supported.
    e.ex.fw.6002 = Specified representation format not supported.
    e.ex.fw.6003 = Specified media type in the request body not supported.
    
    e.ex.fw.7001 = Validation error occurred on item in the request body.
    e.ex.fw.7002 = Validation error occurred on item in the request parameters.
    e.ex.fw.7003 = Request body format error occurred.
    e.ex.fw.7004 = Unknown field exists in JSON.
    e.ex.fw.7005 = Type mismatch error occurred in JSON field.
    e.ex.fw.7006 = Type mismatch error occurred in request parameter or header or path variable.
    
    e.ex.fw.8001 = Business error occurred.
    e.ex.fw.8002 = Conflict with other processing occurred.
    
    e.ex.fw.9001 = System error occurred.
    e.ex.fw.9002 = System error occurred.
    e.ex.fw.9003 = System error occurred.
    
    # omitted
    
  • xxx-web/src/main/resources/ValidationMessages.properties
    Bean Validationを使った入力チェックで発生するエラーに対して、エラーコードに対応するメッセージの設定を行う。
    ここでは、Hibernate Validatorが用意するデフォルトメッセージを利用する。
    デフォルトメッセージは、メッセージの中に項目名が含まれないため、{0}(フィールド名)を追加している。
    # ---
    # Bean Validation common messages
    # ---
    
    # for bean validation of standard
    jakarta.validation.constraints.AssertFalse.message     = "{0}" must be false.
    jakarta.validation.constraints.AssertTrue.message      = "{0}" must be true.
    jakarta.validation.constraints.DecimalMax.message      = "{0}" must be less than ${inclusive == true ? 'or equal to ' : ''}{value}.
    jakarta.validation.constraints.DecimalMin.message      = "{0}" must be greater than ${inclusive == true ? 'or equal to ' : ''}{value}.
    jakarta.validation.constraints.Digits.message          = "{0}" numeric value out of bounds (<{integer} digits>.<{fraction} digits> expected).
    jakarta.validation.constraints.Email.message           = "{0}" must be a well-formed email address.
    jakarta.validation.constraints.Future.message          = "{0}" must be a future date.
    jakarta.validation.constraints.FutureOrPresent.message = "{0}" must be a date in the present or in the future.
    jakarta.validation.constraints.Max.message             = "{0}" must be less than or equal to {value}.
    jakarta.validation.constraints.Min.message             = "{0}" must be greater than or equal to {value}.
    jakarta.validation.constraints.Negative.message        = "{0}" must be less than 0.
    jakarta.validation.constraints.NegativeOrZero.message  = "{0}" must be less than or equal to 0.
    jakarta.validation.constraints.NotBlank.message        = "{0}" must not be blank.
    jakarta.validation.constraints.NotEmpty.message        = "{0}" must not be empty.
    jakarta.validation.constraints.NotNull.message         = "{0}" must not be null.
    jakarta.validation.constraints.Null.message            = "{0}" must be null.
    jakarta.validation.constraints.Past.message            = "{0}" must be a past date.
    jakarta.validation.constraints.PastOrPresent.message   = "{0}" must be a date in the past or in the present.
    jakarta.validation.constraints.Pattern.message         = "{0}" must match "{regexp}".
    jakarta.validation.constraints.Positive.message        = "{0}" must be greater than 0.
    jakarta.validation.constraints.PositiveOrZero.message  = "{0}" must be greater than or equal to 0.
    jakarta.validation.constraints.Size.message            = "{0}" size must be between {min} and {max}.
    
    # for bean validation of hibernate
    org.hibernate.validator.constraints.CreditCardNumber.message        = "{0}" invalid credit card number.
    org.hibernate.validator.constraints.Currency.message                = "{0}" invalid currency (must be one of {value}).
    org.hibernate.validator.constraints.EAN.message                     = "{0}" invalid {type} barcode.
    org.hibernate.validator.constraints.Email.message                   = "{0}" not a well-formed email address.
    org.hibernate.validator.constraints.ISBN.message                    = "{0}" invalid ISBN.
    org.hibernate.validator.constraints.Length.message                  = "{0}" length must be between {min} and {max}.
    org.hibernate.validator.constraints.CodePointLength.message         = "{0}" length must be between {min} and {max}.
    org.hibernate.validator.constraints.LuhnCheck.message               = "{0}" the check digit for ${validatedValue} is invalid, Luhn Modulo 10 checksum failed.
    org.hibernate.validator.constraints.Mod10Check.message              = "{0}" the check digit for ${validatedValue} is invalid, Modulo 10 checksum failed.
    org.hibernate.validator.constraints.Mod11Check.message              = "{0}" the check digit for ${validatedValue} is invalid, Modulo 11 checksum failed.
    org.hibernate.validator.constraints.ModCheck.message                = "{0}" the check digit for ${validatedValue} is invalid, {modType} checksum failed.
    org.hibernate.validator.constraints.NotBlank.message                = "{0}" may not be empty.
    org.hibernate.validator.constraints.NotEmpty.message                = "{0}" may not be empty.
    org.hibernate.validator.constraints.ParametersScriptAssert.message  = "{0}" script expression "{script}" didn't evaluate to true.
    org.hibernate.validator.constraints.Range.message                   = "{0}" must be between {min} and {max}.
    org.hibernate.validator.constraints.SafeHtml.message                = "{0}" may have unsafe html content.
    org.hibernate.validator.constraints.ScriptAssert.message            = "{0}" script expression "{script}" didn't evaluate to true.
    org.hibernate.validator.constraints.UniqueElements.message          = "{0}" must only contain unique elements.
    org.hibernate.validator.constraints.URL.message                     = "{0}" must be a valid URL.
    
    org.hibernate.validator.constraints.br.CNPJ.message                 = "{0}" invalid Brazilian corporate taxpayer registry number (CNPJ).
    org.hibernate.validator.constraints.br.CPF.message                  = "{0}" invalid Brazilian individual taxpayer registry number (CPF).
    org.hibernate.validator.constraints.br.TituloEleitoral.message      = "{0}" invalid Brazilian Voter ID card number.
    
    org.hibernate.validator.constraints.pl.REGON.message                = "{0}" invalid Polish Taxpayer Identification Number (REGON).
    org.hibernate.validator.constraints.pl.NIP.message                  = "{0}" invalid VAT Identification Number (NIP).
    org.hibernate.validator.constraints.pl.PESEL.message                = "{0}" invalid Polish National Identification Number (PESEL).
    
    org.hibernate.validator.constraints.time.DurationMax.message        = "{0}" must be shorter than${inclusive == true ? ' or equal to' : ''}${days == 0 ? '' : days == 1 ? ' 1 day' : ' ' += days += ' days'}${hours == 0 ? '' : hours == 1 ? ' 1 hour' : ' ' += hours += ' hours'}${minutes == 0 ? '' : minutes == 1 ? ' 1 minute' : ' ' += minutes += ' minutes'}${seconds == 0 ? '' : seconds == 1 ? ' 1 second' : ' ' += seconds += ' seconds'}${millis == 0 ? '' : millis == 1 ? ' 1 milli' : ' ' += millis += ' millis'}${nanos == 0 ? '' : nanos == 1 ? ' 1 nano' : ' ' += nanos += ' nanos'}.
    org.hibernate.validator.constraints.time.DurationMin.message        = "{0}" must be longer than${inclusive == true ? ' or equal to' : ''}${days == 0 ? '' : days == 1 ? ' 1 day' : ' ' += days += ' days'}${hours == 0 ? '' : hours == 1 ? ' 1 hour' : ' ' += hours += ' hours'}${minutes == 0 ? '' : minutes == 1 ? ' 1 minute' : ' ' += minutes += ' minutes'}${seconds == 0 ? '' : seconds == 1 ? ' 1 second' : ' ' += seconds += ' seconds'}${millis == 0 ? '' : millis == 1 ? ' 1 milli' : ' ' += millis += ' millis'}${nanos == 0 ? '' : nanos == 1 ? ' 1 nano' : ' ' += nanos += ' nanos'}.
    
    # for common library
    org.terasoluna.gfw.common.codelist.ExistInCodeList.message   = "{0}" must exist in code list of {codeListId}.
    
    # omitted
    
  • xxx-domain/src/main/resources/i18n/domain-messages.properties
    ドメイン層で発生するエラーに対して、エラーコード(例外コード)に対応するメッセージの設定を行う。
    # omitted
    
    e.ex.mm.5001 = Specified member not found. member id : {0}
    e.ex.mm.8001 = Cannot use specified sign id. sign id : {0}
    
    # omitted
    

5.1.4.6. サーブレットコンテナに通知されたエラーのハンドリング実装

Filterでエラーが発生した場合やHttpServletResponse#sendErrorを使ってエラーレスポンスが行われた場合は、Spring MVCの例外ハンドリングの仕組みを使ってハンドリングできないため、 これらのエラーはサーブレットコンテナに通知される。

本節では、サーブレットコンテナに通知されたエラーをハンドリングする方法について説明する。

Image of error handling processing by servlet container
項番 処理レイヤ 説明
(1)
Servlet Container
(AP Server)
Servlet Containerはクライアントからのリクエストを受け付け、処理を行う。
Servlet Containerは処理中にエラーを検知する。
(2)

Servlet Containerはweb.xmlのerror-pageの定義に従って、エラー処理を行う。
致命的なエラーでない場合は、エラーハンドリングを行うControllerを呼び出し、エラー処理を行う。
(2’)

致命的なエラーの場合は、予め用意してある静的なJSONファイルを取得し、クライアントへ応答する。
(3)
Spring MVC
(Framework)
Spring MVCは、エラーハンドリングを行うControllerを呼び出す。
(4)
Controller
(Common Component)
エラーハンドリングを行うControllerでは、エラー情報を保持するエラーオブジェクトを生成し、Spring MVCに返却する。
(5)
Spring MVC
(Framework)
Spring MVCは、HttpMessageConverterを利用して、エラーオブジェクトをJSON形式の電文に変換する。
(6)

Spring MVCは、JSON形式のエラー電文をレスポンスBODYに設定し、クライアントにレスポンスする。

5.1.4.6.1. エラー応答を行うためのControllerの実装

サーブレットコンテナに通知されたエラーのエラー応答を行うControllerを作成する。

package org.terasoluna.examples.rest.api.common.error;

import java.util.HashMap;
import java.util.Map;

import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.WebRequest;

import jakarta.inject.Inject;
import jakarta.servlet.RequestDispatcher;

// (1)
@RequestMapping("error")
@RestController
public class ApiErrorPageController {

    @Inject
    ApiErrorCreator apiErrorCreator; // (2)

    // (3)
    private final Map<HttpStatus, String> errorCodeMap = new HashMap<HttpStatus, String>();

    // (4)
    public ApiErrorPageController() {
        errorCodeMap.put(HttpStatus.NOT_FOUND,
                "e.ex.fw.5001");
    }

    // (5)
    @GetMapping
    public ResponseEntity<ApiError> handleErrorPage(WebRequest request) {
        // (6)
        HttpStatus httpStatus = HttpStatus.valueOf((Integer) request
                .getAttribute(RequestDispatcher.ERROR_STATUS_CODE,
                        RequestAttributes.SCOPE_REQUEST));
        // (7)
        String errorCode = errorCodeMap.get(httpStatus);
        // (8)
        ApiError apiError = apiErrorCreator.createApiError(request, errorCode,
                httpStatus.getReasonPhrase());
        // (9)
        return ResponseEntity.status(httpStatus).body(apiError);
    }

}
項番 説明
(1)
エラー応答を行うためのControllerクラスを作成する。
上記例では、「/api/v1/error」というサーブレットパスにマッピングしている。
(2)
エラー情報を作成するクラスをInjectする。
(3)
HTTPステータスコードとエラーコードをマッピングするための Mapを作成する。
(4)
HTTPステータスコードとエラーコードとのマッピングを登録する。
(5)
エラー応答を行うハンドラメソッドを作成する。
上記例では、レスポンスコード(<error-code>)を使ってエラーページのハンドリングを行うケースのみを考慮した実装になっている。
例外タイプ(<exception-type>)を使ってエラーページのハンドリングを行う場合は、別途考慮が必要である。
(6)
リクエストスコープに格納されているステータスコードを取得する。
(7)
取得したステータスコードに対応するエラーコードを取得する。
(8)
取得したエラーコードに対応するエラー情報を生成する。
(9)
(8)で生成したエラー情報を応答する。

5.1.4.6.2. 致命的なエラーが発生した際に応答する静的なJSONファイルの作成

致命的なエラーが発生した際に応答する静的なJSONファイルを作成する。

  • unhandledSystemError.json

    {"code":"e.ex.fw.9999","message":"Unhandled system error occurred."}
    

5.1.4.6.3. サーブレットコンテナに通知されたエラーをハンドリングするための設定

ここでは、サーブレットコンテナに通知されたエラーをハンドリングするための設定について説明する。

  • web.xml

    <!-- omitted -->
    
    <!-- (1) -->
    <error-page>
        <error-code>404</error-code>
        <location>/api/v1/error</location>
    </error-page>
    
    <!-- (2) -->
    <error-page>
        <exception-type>java.lang.Exception</exception-type>
        <location>/WEB-INF/views/common/error/unhandledSystemError.json</location>
    </error-page>
    
    <!-- (3) -->
    <mime-mapping>
        <extension>json</extension>
        <mime-type>application/json;charset=UTF-8</mime-type>
    </mime-mapping>
    
    <!-- omitted -->
    
    項番 説明
    (1)
    必要に応じてレスポンスコードによるエラーページの定義を追加する。
    上記例では、404 Not Foundが発生した際に、「/api/v1/error」というリクエストにマッピングされているController(ApiErrorPageController)を呼び出してエラー応答を行っている。
    (2)
    致命的なエラーをハンドリングするための定義を追加する。
    致命的なエラーが発生していた場合、レスポンス情報を作成する処理で二重障害が発生する可能性があるため、予め用意している静的なJSONを応答する事を推奨する。
    上記例では、「/WEB-INF/views/common/error/unhandledSystemError.json」に定義されている固定のJSONを応答している。
    (3)
    jsonのMIMEタイプを指定する。
    (2)で作成するJSONファイルの中にマルチバイト文字を含める場合は、charset=UTF-8を指定しないと、クライアント側で文字化けする可能性がある。
    JSONファイルにマルチバイト文字を含めない場合は、この設定は必須ではないが、設定しておいた方が無難である。

    Note

    Servletの仕様では、 <error-page><location>にクエリパラメータを付与したパスを指定した場合の挙動について、定義していない。そのため、APサーバによって挙動が異なる可能性がある。

    よって、クエリパラメータを使用してエラー時の遷移先に情報を渡すことは推奨しない。


  • 存在しないパスへリクエストを送った場合、以下のようなエラー応答が行われる。

    HTTP/1.1 404 Not Found
    Server: Apache-Coyote/1.1
    X-Track: 2ad50fb5ba2441699c91a5b01edef83f
    Content-Type: application/json;charset=UTF-8
    Transfer-Encoding: chunked
    Date: Wed, 19 Feb 2014 23:24:20 GMT
    
    {"code":"e.ex.fw.5001","message":"Resource not found."}
    

  • 致命的なエラーが発生した場合、以下のようなエラー応答が行われる。

    HTTP/1.1 500 Internal Server Error
    Server: Apache-Coyote/1.1
    X-Track: 69db3854a19f439781584321d9ce8336
    Content-Type: application/json
    Content-Length: 68
    Date: Thu, 20 Feb 2014 00:13:43 GMT
    Connection: close
    
    {"code":"e.ex.fw.9999","message":"Unhandled system error occurred."}
    

5.1.4.7. セキュリティ対策

RESTful Web Serviceに対するセキュリティ対策の実現方法について説明する。
本ガイドラインでは、セキュリティ対策の実現方法として、Spring Securityを使用する事を推奨している。

5.1.4.7.1. 認証・認可

  • OAuth2(Spring Security OAuth2)を使用して認証・認可を実現する方法については、OAuth 2.0を参照されたい。

5.1.4.7.2. CSRF対策

  • RESTful Web Serviceに対してCSRF対策を行う場合の設定方法については、CSRF対策を参照されたい。
  • RESTful Web Serviceに対してCSRF対策を行わない場合の設定方法については、CSRF対策の無効化を参照されたい。

5.1.4.8. リソースの条件付き操作

Todo

TBD

Etagなどのヘッダを使った条件付き処理の制御の実現方法について、次版以降に記載する予定である。


5.1.4.9. リソースのキャッシュ制御

Todo

TBD

Cache-Control/Expires/Pragmaなどのヘッダを使ったキャッシュ制御の実現方法について、次版以降に記載する予定である。


5.1.5. How to extend

5.1.5.1. @JsonViewを使用したレスポンスの出力制御

@JsonViewを使用することによって、Resourceオブジェクト内のプロパティーをグループ分けすることができる。
この機能はSpring FrameworkがJacksonの機能をサポートすることにより実現している。
詳細は、JacksonJsonViewsを参照されたい。
Controllerにてグループを指定することで、指定したグループに所属するプロパティーのみ出力することができる。
1つのプロパティーは、複数のグループに所属することも可能である。

以下は、Memberリソースを「概要」と「詳細」の2つのフォーマットで扱う際の実装例である。
「概要フォーマット」はMemberリソースの主要項目を、「詳細フォーマット」はMemberリソースの全項目を出力する。
  • MemberResource.java

    package org.terasoluna.examples.rest.api.member;
    
    import java.io.Serializable;
    import java.time.LocalDate;
    import java.time.LocalDateTime;
    
    import com.fasterxml.jackson.annotation.JsonView;
    
    public class MemberResource implements Serializable {
    
        private static final long serialVersionUID = 1L;
    
        // (1)
        interface Summary {
        }
    
        // (2)
        interface Detail {
        }
    
        // (3)
        @JsonView({Summary.class, Detail.class})
        private String memberId;
    
        @JsonView({Summary.class, Detail.class})
        private String firstName;
    
        @JsonView({Summary.class, Detail.class})
        private String lastName;
    
        // (4)
        @JsonView(Detail.class)
        private String genderCode;
    
        @JsonView(Detail.class)
        private LocalDate dateOfBirth;
    
        @JsonView(Detail.class)
        private String emailAddress;
    
        @JsonView(Detail.class)
        private String telephoneNumber;
    
        @JsonView(Detail.class)
        private String zipCode;
    
        @JsonView(Detail.class)
        private String address;
    
        // (5)
        private LocalDateTime createdAt;
    
        private LocalDateTime lastModifiedAt;
    
        // omitted setter and getter
    
    }
    
    項番 説明
    (1)
    出力制御するグループを指定するためのマーカーインターフェースを定義している。
    上記例では、概要出力時に指定するグループを定義している。
    (2)
    出力制御するグループを指定するためのマーカーインターフェースを定義している。
    上記例では、詳細出力時に指定するグループを定義している。
    (3)
    複数のグループで出力したい項目には、引数を配列にして複数のマーカーインターフェースを渡すことで、複数のグループに所属させることができる。
    上記例の場合、概要と詳細の両方のグループに所属させたい項目であるため、2つのマーカーインターフェースを引数にしている。
    (4)
    単一のグループで出力したい項目には、マーカーインターフェースを引数にすることで、
    該当のグループに所属させることができる。
    この場合は要素が1つため、配列にする必要はない。
    上記例の場合、詳細のみのグループに所属させたい項目であるため、1つのマーカーインターフェースを引数にしている。
    (5)
    グループに所属しない項目には@JsonViewを設定しない。
    グループに所属しない項目を出力するかどうかは設定によって変えることができる。
    設定方法については後述する。

  • MemberRestController.java

    package org.terasoluna.examples.rest.api.member;
    
    import java.util.ArrayList;
    import java.util.List;
    
    import org.springframework.http.HttpStatus;
    import org.springframework.web.bind.annotation.GetMapping;
    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.ResponseStatus;
    import org.springframework.web.bind.annotation.RestController;
    import org.terasoluna.examples.rest.domain.model.Member;
    import org.terasoluna.examples.rest.domain.service.member.MemberService;
    
    import com.fasterxml.jackson.annotation.JsonView;
    
    import jakarta.inject.Inject;
    
    @RequestMapping("members")
    @RestController
    public class MemberRestController {
    
        @Inject
        MemberService memberService;
    
        @Inject
        BeanMapper beanMapper;
    
        // (1)
        @JsonView(Summary.class)
        @GetMapping(value = "{memberId}", params = "format=summary")
        @ResponseStatus(HttpStatus.OK)
        public MemberResource getMemberSummary(@PathVariable("memberId") String memberId) {
    
            Member member = memberService.getMember(memberId);
    
            MemberResource responseResource = beanMapper.map(member,
                    MemberResource.class);
    
            return responseResource;
        }
    
        // (2)
        @JsonView(Detail.class)
        @GetMapping(value = "{memberId}", params = "format=detail")
        @ResponseStatus(HttpStatus.OK)
        public MemberResource getMemberDetail(@PathVariable("memberId") String memberId) {
    
            Member member = memberService.getMember(memberId);
    
            MemberResource responseResource = beanMapper.map(member,
                    MemberResource.class);
    
            return responseResource;
        }
    }
    
    項番 説明
    (1)
    @JsonViewを付けて、出力したいグループのマーカーインターフェースを設定する。
    概要を出力するメソッドにSummaryマーカーインターフェースを設定する。
    (2)
    詳細を出力するメソッドにDetailマーカーインターフェースを設定する。

出力されるボディは、Controllerで指定したグループに所属するプロパティーのみ出力される。
出力例は以下の通りとなる。
  • Summary

    {
      "memberId" : "M000000001",
      "firstName" : "John",
      "lastName" : "Smith",
      "createdAt" : "2014-03-14T11:02:41.477Z",
      "lastModifiedAt" : "2014-03-14T11:02:41.477Z"
    }
    

  • Detail

    {
      "memberId" : "M000000001",
      "firstName" : "John",
      "lastName" : "Smith",
      "genderCode" : "1",
      "dateOfBirth" : "2013-03-14",
      "emailAddress" : "user1394794959984@test.com",
      "telephoneNumber" : "09012345678",
      "zipCode" : "1710051",
      "address" : "Tokyo",
      "createdAt" : "2014-03-14T11:02:41.477Z",
      "lastModifiedAt" : "2014-03-14T11:02:41.477Z"
    }
    

@JsonViewを付けなかったプロパティーは、MapperFeature.DEFAULT_VIEW_INCLUSIONの設定を有効にすれば出力され、無効にすれば出力されない。
上記の出力例は、MapperFeature.DEFAULT_VIEW_INCLUSIONを有効にした場合の出力例である。
MapperFeature.DEFAULT_VIEW_INCLUSIONを有効にする場合は、以下のように設定する。
<bean id="objectMapper" class="org.springframework.http.converter.json.Jackson2ObjectMapperFactoryBean">
    <!-- ... -->

    <!-- (1) -->
    <property name="featuresToEnable">
        <array>
            <util:constant static-field="com.fasterxml.jackson.databind.MapperFeature.DEFAULT_VIEW_INCLUSION"/>
        </array>
    </property>
</bean>
項番 説明
(1)
featuresToEnable要素にMapperFeature.DEFAULT_VIEW_INCLUSIONを定義することで設定が有効となる。

MapperFeature.DEFAULT_VIEW_INCLUSIONを無効にする場合は、以下のように設定する。
<bean id="objectMapper" class="org.springframework.http.converter.json.Jackson2ObjectMapperFactoryBean">
    <!-- ... -->

    <!-- (1) -->
    <property name="featuresToDisable">
        <array>
            <util:constant static-field="com.fasterxml.jackson.databind.MapperFeature.DEFAULT_VIEW_INCLUSION"/>
        </array>
    </property>
</bean>
項番 説明
(1)
featuresToDisable要素にMapperFeature.DEFAULT_VIEW_INCLUSIONを定義することで設定が無効となる。
MapperFeature.DEFAULT_VIEW_INCLUSIONが無効の場合、先ほどの出力例は、以下のように出力内容が変更される。
  • Summary

    {
      "memberId" : "M000000001",
      "firstName" : "John",
      "lastName" : "Smith"
    }
    

  • Detail

    {
      "memberId" : "M000000001",
      "firstName" : "John",
      "lastName" : "Smith",
      "genderCode" : "1",
      "dateOfBirth" : "2013-03-14",
      "emailAddress" : "user1394794959984@test.com",
      "telephoneNumber" : "09012345678",
      "zipCode" : "1710051",
      "address" : "Tokyo"
    }
    

Warning

MapperFeature.DEFAULT_VIEW_INCLUSIONを指定しない場合のデフォルト値は、ObjectMapperの設定方法によって異なるデフォルト値となるため注意が必要である。

RESTful Web Serviceで必要となるSpring MVCのコンポーネントを有効化するための設定でも記述しているが、ObjectMapperのBean定義方法をObjectMapperを直接Bean定義するスタイルにすると、デフォルト値が有効になる。Jackson2ObjectMapperFactoryBeanを利用すると、デフォルト値は無効になる。設定を明示するため、どちらのスタイルで設定する場合においても、MapperFeature.DEFAULT_VIEW_INCLUSIONの指定を記述することを推奨する。

Note

@JsonViewは以下の2つの機能を使用して作成されている。これらは、Controller内のハンドラメソッドで、Objectとのマッピング前後に共通的な処理を実装したい場合に、使用することができる機能である。

  • org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdvice
  • org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice

@ControllerAdviceをこれらのインタフェースの実装クラスにつけることで適用することができる。@ControllerAdviceの詳細は、@ControllerAdviceの実装を参照されたい。

RequestBodyAdviceは下記のメソッドを実装することができる。

  • org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdvice

    項番 メソッド名 概要
    (1)
    supports
    このAdviceが送信されたリクエストに対して適用されるかどうか決定する。trueだと適用される。
    (2)
    handleEmptyBody
    リクエストボディの内容をControllerで使用するオブジェクトに反映する前かつ、ボディが空の場合に呼び出される。
    (3)
    beforeBodyRead
    リクエストボディの内容をControllerで使用するオブジェクトに反映する前に呼び出される。
    (4)
    afterBodyRead
    リクエストボディの内容をControllerで使用するオブジェクトに反映した後に呼び出される。

上記すべてのタイミングで処理を記述する必要がない場合は、上記のsupports以外のメソッドが何もしない状態で実装されたorg.springframework.web.servlet.mvc.method.annotation.RequestBodyAdviceAdapterを継承し、必要な部分だけオーバーライドすることで、簡単に実装することができる。

ResponseBodyAdviceは下記のメソッドを実装することができる。

  • org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice

    項番 メソッド名 概要
    (1)
    supports
    このAdviceが送信されたリクエストに対して適用されるかどうか決定する。trueだと適用される。
    (2)
    beforeBodyWrite
    Contorollerでの処理終了後、レスポンスに返り値を反映する前に呼び出される。

5.1.6. Appendix

5.1.6.1. RESTful Web Serviceとクライアントアプリケーションを同じWebアプリケーションとして動かす際の設定

5.1.6.1.1. RESTful Web Service用のDispatcherServletを設ける方法

RESTful Web Serviceとクライアントアプリケーションを同じWebアプリケーションとして構築する場合、RESTful Web Service用のリクエストを受けるDispatcherServletと、クライアントアプリケーション用のリクエストを受け取るDispatcherServletを分割する事を推奨する。
DispatcherServletを分割する方法について、以下に説明する。
  • web.xml

    <!-- omitted -->
    
    <!-- (1) -->
    <servlet>
        <servlet-name>appServlet</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <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>
    
    <!-- (2) -->
    <servlet>
        <servlet-name>restAppServlet</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <!-- (3) -->
            <param-value>classpath*:META-INF/spring/spring-mvc-rest.xml</param-value>
        </init-param>
        <load-on-startup>2</load-on-startup>
    </servlet>
    <!-- (4) -->
    <servlet-mapping>
        <servlet-name>restAppServlet</servlet-name>
        <url-pattern>/api/v1/*</url-pattern>
    </servlet-mapping>
    
    <!-- omitted -->
    
    項番 説明
    (1)
    クライアントアプリケーション用のリクエストを受け取るDispatcherServletとリクエストマッピング。
    (2)
    RESTful Web Service用のリクエストを受けるServlet(DispatcherServlet)を追加する。
    <servlet-name>要素に、RESTful Web Service用サーブレットであることを示す名前を指定する。
    上記例では、サーブレット名としてrestAppServletを指定している。
    (3)
    RESTful Web Service用のDispatcherServletを構築する際に使用するSpring MVCのbean定義ファイルを指定する。
    上記例では、Spring MVCのbean定義ファイルとして、クラスパス上にあるMETA-INF/spring/spring-mvc-rest.xmlを指定している。
    (4)
    RESTful Web Service用のDispatcherServletへマッピングするサーブレットパスのパターンの指定を行う。
    上記例では、/api/v1/配下のサーブレットパスをRESTful Web Service用のDispatcherServletにマッピングしている。
    具体的には、
    /api/v1/
    /api/v1/members
    /api/v1/members/xxxxx
    といったサーブレットパスが、RESTful Web Service用のDispatcherServlet(restAppServlet)にマッピングされる。

    Tip

    @RequestMappingアノテーションのvalue属性に指定する値について

    @RequestMappingアノテーションのvalue属性に指定する値は、<url-pattern>要素で指定したワイルドカード(“*”)の部分の値を指定する。

    例えば、@RequestMapping(value = "members")と指定した場合、/api/v1/membersといパスに対する処理を行うメソッドとしてデプロイされる。 そのため、@RequestMappingアノテーションのvalue属性には、分割したサーブレットへマッピングするためパス(api/v1)を指定する必要はない。

    @RequestMapping(value = "api/v1/members")と指定すると、/api/v1/api/v1/membersというパスに対する処理を行うメソッドとしてデプロイされてしまうので、注意すること。


5.1.6.3. HTTPの仕様に準拠したRESTful Web Serviceの作成

本編で説明したREST APIの実装では、HTTPの仕様に準拠していない箇所がある。
本節では、HTTPの仕様に準拠したRESTful Web Serviceにするための実装例について説明する。

5.1.6.3.1. POST時のLocationヘッダの設定

HTTPの仕様に準拠する場合、POST時のレスポンスヘッダー(「Locationヘッダ」)には、作成したリソースのURIを設定する必要がある。
POST時のレスポンスヘッダ(「Locationヘッダ」)に、作成したリソースのURIを設定するための実装方法について説明する。

5.1.6.3.2. リソース毎の実装

  • REST APIの処理で、作成したリソースのURIをLocationヘッダに設定する。

    @RequestMapping("members")
    @RestController
    public class MemberRestController {
    
        // omitted
    
        @PostMapping
        public ResponseEntity<MemberResource> postMembers(
                @RequestBody @Validated({ PostMembers.class, Default.class })
                MemberResource requestedResource,
                // (1)
                UriComponentsBuilder uriBuilder) {
    
            Member creatingMember = beanMapper.map(requestedResource, Member.class);
    
            Member createdMember = memberService.createMember(creatingMember);
    
            MemberResource responseResource = beanMapper.map(createdMember,
                    MemberResource.class);
    
            // (2)
            URI createdUri = uriBuilder.path("/members/{memberId}")
                    .buildAndExpand(responseResource.getMemberId()).toUri();
    
            // (3)
            return ResponseEntity.created(createdUri).body(responseResource);
        }
    
        // omitted
    
    }
    
    項番 説明
    (1)
    作成したリソースのURIを組み立てるため、Spring MVCから提供されているorg.springframework.web.util.UriComponentsBuilderクラスをメソッドの引数に指定する。
    UriComponentsBuilderクラスをControllerのメソッドの引数に指定すると、メソッド実行時に、Spring MVCによりUriComponentsBuilderクラスを継承した
    org.springframework.web.servlet.support.ServletUriComponentsBuilderクラスのインスタンスが渡される。
    (2)
    作成したリソースのURIを組み立てる。
    上記例では、引数として渡されたServletUriComponentsBuilderのインスタンスにpathメソッドで、URI Template Patternsを用いたパスを追加し、
    buildAndExpandメソッドを呼び出して、作成したリソースのIDをバインドすることで、作成したリソースのURIを組み立てている。

    Controllerのメソッドの引数として渡されたServletUriComponentsBuilderのインスタンスは、web.xmlに記載の<servlet-mapping>要素の情報を元に初期化されており、リソースには依存しない。
    そのため、Spring Frameworkから提供されるURI patterns等を利用し、
    リクエスト情報をベースにURIを組み立てる事により、リソースに依存しない汎用的な組み立て処理を実装することが可能となる。

    例えば、上記例においてhttp://example.com/api/v1/membersに対してPOSTした場合、組み立てられるURIは、「リクエストされたURI + “/” + 作成したリソースのID」となる。
    具体的には、IDにM000000001を指定した場合、http://example.com/api/v1/members/M000000001となる。

    必要に応じてリンク情報に設定するURIを組み立てるためのメソッドを実装すること。
    (3)
    以下のパラメータを使用してorg.springframework.http.ResponseEntityを生成し返却する。
    • ステータスコード : 201(Created)
    • Locationヘッダ : 作成したリソースのURI
    • レスポンスBODY : 作成したResourceオブジェクト

    Tip

    ServletUriComponentsBuilderでは、URIを組み立てる際に「X-Forwarded-Host」ヘッダを参照することで、クライアントとアプリケーションサーバの間にロードバランサやWebサーバがある構成を考慮している。 ただし、パスの構成を合わせておかないと期待通りのURIにならないので注意が必要である。

  • レスポンス例
    実際に動かすと、以下のようなレスポンスヘッダとなる。
    HTTP/1.1 201 Created
    Server: Apache-Coyote/1.1
    X-Track: 693e132312d64998a7d8d6cabf3d13ef
    Location: http://localhost:8080/rest-api-web/api/v1/members/M000000001
    Content-Type: application/json;charset=UTF-8
    Transfer-Encoding: chunked
    Date: Fri, 14 Mar 2014 12:34:31 GMT
    

5.1.6.4. CSRF対策の無効化

RESTful Web Service向けのリクエストに対して、CSRF対策を行わないようにするための設定方法について説明する。

Tip

CSRF対策を行わない場合は、セッションを利用する必要がなくなる。

下記設定例では、Spring Securityの処理でセッションが使用されなくなる様にしている。


Blankプロジェクトのデフォルトの設定では、CSRF対策が有効化されているため、以下の設定を追加し、RESTful Web Service向けのリクエストに対して、CSRF対策の処理が行われないようにする。

  • spring-security.xml

    <!-- omitted -->
    
    <!-- (1) -->
    <sec:http pattern="/api/v1/**" request-matcher="ant" create-session="stateless">
        <sec:http-basic/>
        <sec:csrf disabled="true"/>
    </sec:http>
    
    <sec:http request-matcher="ant">
        <sec:access-denied-handler ref="accessDeniedHandler"/>
        <sec:custom-filter ref="userIdMDCPutFilter" after="ANONYMOUS_FILTER"/>
        <sec:form-login/>
        <sec:logout/>
        <sec:session-management />
    </sec:http>
    
    <!-- omitted -->
    
    項番 説明
    (1)
    REST API用のSpring Securityの定義を追加する。
    <sec:http>要素のpattern属性に、REST API用のリクエストパスのURLパターンを指定している。
    上記例では、/api/v1/で始まるリクエストパスをREST API用のリクエストパスとして扱う。
    また、create-session属性をstatelessとする事で、Spring Securityの処理でセッションが使用されなくなる。

    CSRF対策を無効化するために、<sec:csrf>要素に disabled="true"を指定している。

5.1.6.5. XXE 対策の有効化

RESTful Web ServiceでXML形式のデータを扱う場合は、XXE(XML External Entity)対策を行う必要がある。

Note

XXE(XML External Entity) 対策について

Macchinetta Server Framework (1.x)では、XXE 対策が行われているSpring MVC(3.2.10.RELEASE以上)に依存しているため、個別に対策を行う必要はない。


5.1.6.6. アプリケーション層のソースコード

How to useの説明で使用したアプリケーション層のソースコードのうち、断片的に貼りつけていたソースコードの完全版を添付しておく。

以下のファイルは、除外している。

  • JavaBean
  • 設定ファイル

5.1.6.6.1. MemberRestController.java

java/org/terasoluna/examples/rest/api/member/MemberRestController.java

package org.terasoluna.examples.rest.api.member;

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

import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
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.RequestMethod;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.terasoluna.examples.rest.api.member.MemberResource.PostMembers;
import org.terasoluna.examples.rest.api.member.MemberResource.PutMember;
import org.terasoluna.examples.rest.domain.model.Member;
import org.terasoluna.examples.rest.domain.service.member.MemberService;

import jakarta.inject.Inject;
import jakarta.validation.groups.Default;

@RequestMapping("members")
@RestController
public class MemberRestController {

    @Inject
    MemberService memberService;

    @Inject
    BeanMapper beanMapper;

    @GetMapping
    @ResponseStatus(HttpStatus.OK)
    public Page<MemberResource> getMembers(@Validated MembersSearchQuery query,
            Pageable pageable) {

        Page<Member> page = memberService.searchMembers(query.getName(), pageable);

        List<MemberResource> memberResources = new ArrayList<>();
        for (Member member : page.getContent()) {
            memberResources.add(beanMapper.map(member));
        }
        Page<MemberResource> responseResource =
            new PageImpl<>(memberResources, pageable, page.getTotalElements());

        return responseResource;
    }

    @GetMapping
    @ResponseStatus(HttpStatus.OK)
    public List<MemberResource> getMembers() {
        List<Member> members = memberService.findAll();

        List<MemberResource> memberResources = new ArrayList<>();
        for (Member member : members) {
            memberResources.add(beanMapper.map(member));
        }
        return memberResources;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public MemberResource postMembers(@RequestBody @Validated({
            PostMembers.class, Default.class }) MemberResource requestedResource) {

        Member creatingMember = beanMapper.map(requestedResource);

        Member createdMember = memberService.createMember(creatingMember);

        MemberResource responseResource = beanMapper.map(createdMember);

        return responseResource;
    }

    @GetMapping("{memberId}")
    @ResponseStatus(HttpStatus.OK)
    public MemberResource getMember(@PathVariable("memberId") String memberId) {

        Member member = memberService.getMember(memberId);

        MemberResource responseResource = beanMapper.map(member);

        return responseResource;
    }

    @PutMapping("{memberId}")
    @ResponseStatus(HttpStatus.OK)
    public MemberResource putMember(
            @PathVariable("memberId") String memberId,
            @RequestBody @Validated({
            PutMember.class, Default.class }) MemberResource requestedResource) {

        Member updatingMember = beanMapper.map(requestedResource);

        Member updatedMember = memberService.updateMember(memberId,
                updatingMember);

        MemberResource responseResource = beanMapper.map(updatedMember);

        return responseResource;
    }

    @DeleteMapping("{memberId}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteMember(@PathVariable("memberId") String memberId) {

        memberService.deleteMember(memberId);

    }

}

5.1.6.6.2. ApiErrorCreator.java

java/org/terasoluna/examples/rest/api/common/error/ApiErrorCreator.java

package org.terasoluna.examples.rest.api.common.error;

import org.springframework.context.MessageSource;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.stereotype.Component;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.web.context.request.WebRequest;
import org.terasoluna.gfw.common.message.ResultMessage;
import org.terasoluna.gfw.common.message.ResultMessages;

import jakarta.inject.Inject;

@Component
public class ApiErrorCreator {

    @Inject
    MessageSource messageSource;

    public ApiError createApiError(WebRequest request, String errorCode,
            String defaultErrorMessage, Object... arguments) {
        String localizedMessage = messageSource.getMessage(errorCode,
                arguments, defaultErrorMessage, request.getLocale());
        return new ApiError(errorCode, localizedMessage);
    }

    public ApiError createBindingResultApiError(WebRequest request,
            String errorCode, BindingResult bindingResult,
            String defaultErrorMessage) {
        ApiError apiError = createApiError(request, errorCode,
                defaultErrorMessage);
        for (FieldError fieldError : bindingResult.getFieldErrors()) {
            apiError.addDetail(createApiError(request, fieldError, fieldError
                    .getField()));
        }
        for (ObjectError objectError : bindingResult.getGlobalErrors()) {
            apiError.addDetail(createApiError(request, objectError, objectError
                    .getObjectName()));
        }
        return apiError;
    }

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

    public ApiError createResultMessagesApiError(WebRequest request,
            String rootErrorCode, ResultMessages resultMessages,
            String defaultErrorMessage) {
        ApiError apiError;
        if (resultMessages.getList().size() == 1) {
            ResultMessage resultMessage = resultMessages.iterator().next();
            String errorCode = resultMessage.getCode();
            String errorText = resultMessage.getText();
            if (errorCode == null && errorText == null) {
                errorCode = rootErrorCode;
            }
            apiError = createApiError(request, errorCode, errorText,
                    resultMessage.getArgs());
        } else {
            apiError = createApiError(request, rootErrorCode,
                    defaultErrorMessage);
            for (ResultMessage resultMessage : resultMessages.getList()) {
                apiError.addDetail(createApiError(request, resultMessage
                        .getCode(), resultMessage.getText(), resultMessage
                        .getArgs()));
            }
        }
        return apiError;
    }

}

5.1.6.6.3. ApiGlobalExceptionHandler.java

java/org/terasoluna/examples/rest/api/common/error/ApiGlobalExceptionHandler.java

package org.terasoluna.examples.rest.api.common.error;

import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.dao.PessimisticLockingFailureException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult;
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.ExceptionCodeResolver;
import org.terasoluna.gfw.common.exception.ResourceNotFoundException;
import org.terasoluna.gfw.common.exception.ResultMessagesNotificationException;

import jakarta.inject.Inject;

@ControllerAdvice
public class ApiGlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Inject
    ApiErrorCreator apiErrorCreator;

    @Inject
    ExceptionCodeResolver exceptionCodeResolver;

    @Override
    protected ResponseEntity<Object> handleExceptionInternal(Exception ex,
            Object body, HttpHeaders headers, HttpStatus status,
            WebRequest request) {
        final Object apiError;
        if (body == null) {
            String errorCode = exceptionCodeResolver.resolveExceptionCode(ex);
            apiError = apiErrorCreator.createApiError(request, errorCode, ex
                    .getLocalizedMessage());
        } else {
            apiError = body;
        }
        return ResponseEntity.status(status).headers(headers).body(apiError);
    }

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatus status, WebRequest request) {
        return handleBindingResult(ex, ex.getBindingResult(), headers, status,
                request);
    }

    @Override
    protected ResponseEntity<Object> handleBindException(BindException ex,
            HttpHeaders headers, HttpStatus status, WebRequest request) {
        return handleBindingResult(ex, ex.getBindingResult(), headers, status,
                request);
    }

    private ResponseEntity<Object> handleBindingResult(Exception ex,
            BindingResult bindingResult, HttpHeaders headers,
            HttpStatus status, WebRequest request) {
        String errorCode = exceptionCodeResolver.resolveExceptionCode(ex);
        ApiError apiError = apiErrorCreator.createBindingResultApiError(
                request, errorCode, bindingResult, ex.getMessage());
        return handleExceptionInternal(ex, apiError, headers, status, request);
    }

    @Override
    protected ResponseEntity<Object> handleHttpMessageNotReadable(
            HttpMessageNotReadableException ex, HttpHeaders headers,
            HttpStatus status, WebRequest request) {
        if (ex.getCause() instanceof Exception) {
            return handleExceptionInternal((Exception) ex.getCause(), null,
                    headers, status, request);
        } else {
            return handleExceptionInternal(ex, null, headers, status, request);
        }
    }

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

    @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) {
        String errorCode = exceptionCodeResolver.resolveExceptionCode(ex);
        ApiError apiError = apiErrorCreator.createResultMessagesApiError(
                request, errorCode, ex.getResultMessages(), ex.getMessage());
        return handleExceptionInternal(ex, apiError, headers, status, request);
    }

    @ExceptionHandler({ OptimisticLockingFailureException.class,
            PessimisticLockingFailureException.class })
    public ResponseEntity<Object> handleLockingFailureException(Exception ex,
            WebRequest request) {
        return handleExceptionInternal(ex, null, new HttpHeaders(),
                HttpStatus.CONFLICT, request);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<Object> handleSystemError(Exception ex,
            WebRequest request) {
        return handleExceptionInternal(ex, null, new HttpHeaders(),
                HttpStatus.INTERNAL_SERVER_ERROR, request);
    }

}

5.1.6.7. REST API実装時に作成したドメイン層のクラスのソースコード

How to useで説明したREST APIから呼び出しているドメイン層のクラスのソースコードを添付しておく。
なお、インフラストラクチャ層は、MyBatis3を使って実装している。

以下のファイルは、除外している。

  • Entity以外のJavaBean
  • 設定ファイル

5.1.6.7.1. Member.java

java/org/terasoluna/examples/rest/domain/model/Member.java

package org.terasoluna.examples.rest.domain.model;

import java.io.Serializable;
import java.time.LocalDate;
import java.time.LocalDateTime;

public class Member implements Serializable {

    private static final long serialVersionUID = 1L;

    private String memberId;

    private String firstName;

    private String lastName;

    private Gender gender;

    private LocalDate dateOfBirth;

    private String emailAddress;

    private String telephoneNumber;

    private String zipCode;

    private String address;

    private LocalDateTime createdAt;

    private LocalDateTime lastModifiedAt;

    private long version;

    private MemberCredential credential;

    public String getMemberId() {
        return memberId;
    }

    public void setMemberId(String memberId) {
        this.memberId = memberId;
    }

    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    public Gender getGender() {
        return gender;
    }

    public void setGender(Gender gender) {
        this.gender = gender;
    }

    public String getGenderCode() {
        if (gender == null) {
            return null;
        } else {
            return gender.getCode();
        }
    }

    public void setGenderCode(String genderCode) {
        this.gender = Gender.getByCode(genderCode);
    }

    public LocalDate getDateOfBirth() {
        return dateOfBirth;
    }

    public void setDateOfBirth(LocalDate dateOfBirth) {
        this.dateOfBirth = dateOfBirth;
    }

    public String getEmailAddress() {
        return emailAddress;
    }

    public void setEmailAddress(String emailAddress) {
        this.emailAddress = emailAddress;
    }

    public String getTelephoneNumber() {
        return telephoneNumber;
    }

    public void setTelephoneNumber(String telephoneNumber) {
        this.telephoneNumber = telephoneNumber;
    }

    public String getZipCode() {
        return zipCode;
    }

    public void setZipCode(String zipCode) {
        this.zipCode = zipCode;
    }

    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }

    public LocalDateTime getCreatedAt() {
        return createdAt;
    }

    public void setCreatedAt(LocalDateTime createdAt) {
        this.createdAt = createdAt;
    }

    public LocalDateTime getLastModifiedAt() {
        return lastModifiedAt;
    }

    public void setLastModifiedAt(LocalDateTime lastModifiedAt) {
        this.lastModifiedAt = lastModifiedAt;
    }

    public long getVersion() {
        return version;
    }

    public void setVersion(long version) {
        this.version = version;
    }

    public MemberCredential getCredential() {
        return credential;
    }

    public void setCredential(MemberCredential credential) {
        this.credential = credential;
    }

}

5.1.6.7.2. MemberCredentia.java

java/org/terasoluna/examples/rest/domain/model/MemberCredential.java

package org.terasoluna.examples.rest.domain.model;

import java.io.Serializable;
import java.time.LocalDateTime;

public class MemberCredential implements Serializable {

    private static final long serialVersionUID = 1L;

    private String memberId;

    private String signId;

    private String password;

    private String previousPassword;

    private LocalDateTime passwordLastChangedAt;

    private LocalDateTime lastModifiedAt;

    private long version;

    public String getMemberId() {
        return memberId;
    }

    public void setMemberId(String memberId) {
        this.memberId = memberId;
    }

    public String getSignId() {
        return signId;
    }

    public void setSignId(String signId) {
        this.signId = signId;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String getPreviousPassword() {
        return previousPassword;
    }

    public void setPreviousPassword(String previousPassword) {
        this.previousPassword = previousPassword;
    }

    public LocalDateTime getPasswordLastChangedAt() {
        return passwordLastChangedAt;
    }

    public void setPasswordLastChangedAt(LocalDateTime passwordLastChangedAt) {
        this.passwordLastChangedAt = passwordLastChangedAt;
    }

    public LocalDateTime getLastModifiedAt() {
        return lastModifiedAt;
    }

    public void setLastModifiedAt(LocalDateTime lastModifiedAt) {
        this.lastModifiedAt = lastModifiedAt;
    }

    public long getVersion() {
        return version;
    }

    public void setVersion(long version) {
        this.version = version;
    }

}

5.1.6.7.3. Gender.java

java/org/terasoluna/examples/rest/domain/model/Gender.java

package org.terasoluna.examples.rest.domain.model;

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

import org.springframework.util.Assert;

public enum Gender {

    UNKNOWN("0"), MEN("1"), WOMEN("2");

    private static final Map<String, Gender> genderMap;

    static {
        Map<String, Gender> map = new HashMap<>();
        for (Gender gender : values()) {
            map.put(gender.code, gender);
        }
        genderMap = Collections.unmodifiableMap(map);
    }

    private final String code;

    private Gender(String code) {
        this.code = code;
    }

    public static Gender getByCode(String code) {
        Gender gender = genderMap.get(code);
        Assert.notNull(gender, "gender code is invalid. code : " + code);
        return gender;
    }

    public String getCode() {
        return code;
    }

}

5.1.6.7.4. MemberRepository.java

java/org/terasoluna/examples/rest/domain/repository/member/MemberRepository.java

package org.terasoluna.examples.rest.domain.repository.member;

import java.util.List;
import org.apache.ibatis.session.RowBounds;

import org.terasoluna.examples.rest.domain.model.Member;

public interface MemberRepository {

    Member findByMemberId(String memberId);

    List<Member> findAll();

    long countByContainsName(String name);

    List<Member> findPageByContainsName(String name, RowBounds rowBounds);

    void createMember(Member creatingMember);

    void createCredential(Member creatingMember);

    boolean updateMember(Member updatingMember);

    void deleteMemberByMemberId(String memberId);

    void deleteCredentialByMemberId(String memberId);

}

5.1.6.7.5. MemberService.java

java/org/terasoluna/examples/rest/domain/service/member/MemberService.java

package org.terasoluna.examples.rest.domain.service.member;

import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.terasoluna.examples.rest.domain.model.Member;

public interface MemberService {

    List<Member> findAll();

    Page<Member> searchMembers(String name, Pageable pageable);

    Member getMember(String memberId);

    Member createMember(Member creatingMember);

    Member updateMember(String memberId, Member updatingMember);

    void deleteMember(String memberId);

}

5.1.6.7.6. MemberServiceImpl.java

java/org/terasoluna/examples/rest/domain/service/member/MemberServiceImpl.java

package org.terasoluna.examples.rest.domain.service.member;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

import org.apache.ibatis.session.RowBounds;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.terasoluna.examples.rest.domain.message.DomainMessageCodes;
import org.terasoluna.examples.rest.domain.model.Member;
import org.terasoluna.examples.rest.domain.model.MemberCredential;
import org.terasoluna.examples.rest.domain.repository.member.MemberRepository;
import org.terasoluna.gfw.common.exception.BusinessException;
import org.terasoluna.gfw.common.exception.ResourceNotFoundException;
import org.terasoluna.gfw.common.message.ResultMessages;
import org.terasoluna.gfw.common.time.ClockFactory;

import jakarta.inject.Inject;

@Transactional
@Service
public class MemberServiceImpl implements MemberService {

    @Inject
    MemberRepository memberRepository;

    @Inject
    ClockFactory clockFactory;

    @Inject
    PasswordEncoder passwordEncoder;

    @Inject
    MemberBeanMapper beanMapper;

    @Override
    @Transactional(readOnly = true)
    public List<RestMember> findAll() {
        return restMemberRepository.findAll();
    }

    @Override
    @Transactional(readOnly = true)
    public Page<Member> searchMembers(String name, Pageable pageable) {
        List<Member> members = null;
        // Count Members by search criteria
        long total = memberRepository.countByContainsName(name);
        if (0 < total) {
             RowBounds rowBounds = new RowBounds((int) pageable.getOffset(), pageable.getPageSize());
             members = memberRepository.findPageByContainsName(name, rowBounds);
        } else {
            members = new ArrayList<Member>();
        }
        return new PageImpl<Member>(members, pageable, total);
    }

    @Override
    @Transactional(readOnly = true)
    public Member getMember(String memberId) {
        // find member
        Member member = memberRepository.findByMemberId(memberId);
        if (member == null) {
            // If member is not exists
            throw new ResourceNotFoundException(ResultMessages.error().add(
                            DomainMessageCodes.E_EX_MM_5001, memberId));
        }
        return member;
    }

    @Override
    public Member createMember(Member creatingMember) {
        MemberCredential creatingCredential = creatingMember
                            .getCredential();

        // get processing current date time
        LocalDateTime currentDate = LocalDateTime.now(clockFactory.fixed());

        creatingMember.setCreatedAt(currentDate);
        creatingMember.setLastModifiedAt(currentDate);

        // decide sign id(email-address)
        String signId = creatingCredential.getSignId();
        if (!StringUtils.hasLength(signId)) {
            signId = creatingMember.getEmailAddress();
            creatingCredential.setSignId(signId.toLowerCase());
        }

        // encrypt password
        String rawPassword = creatingCredential.getPassword();
        creatingCredential.setPassword(passwordEncoder.encode(rawPassword));
        creatingCredential.setPasswordLastChangedAt(currentDate);
        creatingCredential.setLastModifiedAt(currentDate);

        // save member & member credential
        try {

            // Registering member details
            memberRepository.createMember(creatingMember);
            // //Registering credential details
            memberRepository.createCredential(creatingMember);
            return creatingMember;
        } catch (DuplicateKeyException e) {
            // If sign id is already used
            throw new BusinessException(ResultMessages.error().add(
                            DomainMessageCodes.E_EX_MM_8001,
                            creatingCredential.getSignId()), e);
        }
    }

    @Override
    public Member updateMember(String memberId, Member updatingMember) {
        // get member
        Member member = getMember(memberId);

        // override updating member attributes
        beanMapper.map(updatingMember, member);

        // get processing current date time
        LocalDateTime currentDate = LocalDateTime.now(clockFactory.fixed());
        member.setLastModifiedAt(currentDate);

        // save updating member
        boolean updated = memberRepository.updateMember(member);
        if (!updated) {
                throw new ObjectOptimisticLockingFailureException(Member.class,
                                member.getMemberId());
        }
        return member;
    }

    @Override
    public void deleteMember(String memberId) {

        // First Delete from credential (Child)
        memberRepository.deleteCredentialByMemberId(memberId);
        // Delete member
        memberRepository.deleteMemberByMemberId(memberId);
    }

}

5.1.6.7.7. DomainMessageCodes.java

java/org/terasoluna/examples/rest/domain/message/DomainMessageCodes.java

package org.terasoluna.examples.rest.domain.message;

/**
 * Message codes of domain layer message.
 * @author DomainMessageCodesGenerator
 */
public class DomainMessageCodes {

    private DomainMessageCodes() {
        // NOP
    }

    /** e.ex.mm.5001=Specified member not found. member id : {0} */
    public static final String E_EX_MM_5001 = "e.ex.mm.5001";

    /** e.ex.mm.8001=Cannot use specified sign id. sign id : {0} */
    public static final String E_EX_MM_8001 = "e.ex.mm.8001";
}

5.1.6.7.8. GenderTypeHandler.java

Enum型のコード値をマッピングするためのタイプハンドラー。

java/org/terasoluna/examples/infra/mybatis/typehandler/GenderTypeHandler.java

package org.terasoluna.examples.infra.mybatis.typehandler;

import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import org.terasoluna.examples.domain.model.Gender;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.BaseTypeHandler;

public class GenderTypeHandler extends BaseTypeHandler<Gender> {

    @Override
    public Gender getNullableResult(ResultSet rs, String columnName) throws SQLException {
            return getByCode(rs.getString(columnName));
    }

    @Override
    public Gender getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
            return getByCode(rs.getString(columnIndex));
    }

    @Override
    public Gender getNullableResult(CallableStatement cs, int columnIndex)
                    throws SQLException {
            return getByCode(cs.getString(columnIndex));
    }

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i,
                    Gender parameter, JdbcType jdbcType) throws SQLException {
            ps.setString(i, parameter.getCode());
    }

    private Gender getByCode(String byCode) {
            if (byCode == null) {
                return null;
            } else {
                return Gender.getByCode(byCode);
            }
    }
}

5.1.6.7.9. MemberBeanMapper.java

実装したServiceクラスでは、クライアントから指定された値をMemberオブジェクトにマッピングする際に、「Beanマッピング(MapStruct)」を使用している。
実装例では、更新対象外の項目(memberIdcredentialcreatedAtversion)をマッピング対象外にする必要がある。
マッピングメソッドに@Mappingアノテーションを付与し、設定を行う。

java/org/terasoluna/examples/rest/domain/service/member/MemberBeanMapper.java

package org.terasoluna.examples.rest.domain.service.member;

import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;

import org.terasoluna.examples.rest.domain.model.Member;

@Mapper
public interface MemberBeanMapper {

    @Mapping(target = "memberId", ignore = true)
    @Mapping(target = "credential", ignore = true)
    @Mapping(target = "createdAt", ignore = true)
    @Mapping(target = "version", ignore = true)
    void map(Member sourceMember, @MappingTarget Member targetMember);

}

5.1.6.7.10. mybatis-config.xml

MyBatis3の動作をカスタマイズする場合は、MyBatis設定ファイルに設定値を追加する。MyBatis3では、Joda-Timeのクラス(org.joda.time.DateTime、org.joda.time.LocalDateTime、org.joda.time.LocalDateなど)はサポートされていない。
そのため、EntityクラスのフィールドにJoda-Timeのクラスを使用する場合は、Joda-Time用のTypeHandler を用意する必要がある。
org.joda.time.DateTimeとjava.sql.TimestampをマッピングするためのTypeHandler の実装例、「Joda-Time用のTypeHandlerの実装」を使って行っている。

resources/META-INF/mybatis/mybatis-config.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org/DTD Config 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>

    <settings>
        <setting name="jdbcTypeForNull" value="NULL" />
        <setting name="mapUnderscoreToCamelCase" value="true" />
    </settings>

    <typeAliases>
        <package name="org.terasoluna.examples.infra.mybatis.typehandler" />
    </typeAliases>

    <typeHandlers>
       <package name="org.terasoluna.examples.infra.mybatis.typehandler" />
    </typeHandlers>

</configuration>

5.1.6.7.11. MemberRepository.xml

resources/org/terasoluna/examples/rest/domain/repository/member/MemberRepository.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper
    namespace="org.terasoluna.examples.rest.domain.repository.member.MemberRepository">

    <resultMap id="MemberResultMap" type="Member">
        <id property="memberId" column="member_id" />
        <result property="firstName" column="first_name" />
        <result property="lastName" column="last_name" />
        <result property="gender" column="gender" />
        <result property="dateOfBirth" column="date_of_birth" />
        <result property="emailAddress" column="email_address" />
        <result property="telephoneNumber" column="telephone_number" />
        <result property="zipCode" column="zip_code" />
        <result property="address" column="address" />
        <result property="createdAt" column="created_at" />
        <result property="lastModifiedAt" column="last_modified_at" />
        <result property="version" column="version" />
        <result property="credential.memberId" column="member_id" />
        <result property="credential.signId" column="sign_id" />
        <result property="credential.password" column="password" />
        <result property="credential.previousPassword" column="previous_password" />
        <result property="credential.passwordLastChangedAt" column="password_last_changed_at" />
        <result property="credential.lastModifiedAt" column="credential_last_modified_at" />
        <result property="credential.version" column="credential_version" />
    </resultMap>

    <sql id="selectMember">
        SELECT
         member.member_id as member_id
         ,member.first_name as first_name
         ,member.last_name as last_name
         ,member.gender as gender
         ,member.date_of_birth as date_of_birth
         ,member.email_address as email_address
         ,member.telephone_number as telephone_number
         ,member.zip_code as zip_code
         ,member.address as address
         ,member.created_at as created_at
         ,member.last_modified_at as last_modified_at
         ,member.version as version
         ,credential.sign_id as sign_id
         ,credential.password as password
         ,credential.previous_password as previous_password
         ,credential.password_last_changed_at as password_last_changed_at
         ,credential.last_modified_at as credential_last_modified_at
         ,credential.version as credential_version
        FROM
         t_member member
         INNER JOIN t_member_credential credential ON credential.member_id = member.member_id
    </sql>

    <sql id="whereMember">
        WHERE
            member.first_name LIKE #{nameContainingCondition} ESCAPE '~'
            OR member.last_name LIKE #{nameContainingCondition} ESCAPE '~'
    </sql>

    <select id="findAll" resultMap="RestMemberResultMap">
        <include refid="selectRestMember" />
        ORDER BY member_id ASC
    </select>

    <select id="findByMemberId" parameterType="string" resultMap="MemberResultMap">
        <include refid="selectMember" />
        WHERE
        member.member_id = #{memberId}
    </select>

    <select id="countByContainsName" parameterType="string" resultType="_long">
        <bind name="nameContainingCondition"
        value="@org.terasoluna.gfw.common.query.QueryEscapeUtils@toStartingWithCondition(_parameter)" />
        SELECT
        COUNT(*)
        FROM
        t_member member
        <include refid="whereMember" />
    </select>

    <select id="findPageByContainsName" parameterType="string"
        resultMap="MemberResultMap">
        <bind name="nameContainingCondition"
        value="@org.terasoluna.gfw.common.query.QueryEscapeUtils@toStartingWithCondition(_parameter)" />
        <include refid="selectMember" />
        <include refid="whereMember" />
        ORDER BY member_id ASC
    </select>

    <insert id="createMember" parameterType="Member">
        <selectKey keyProperty="memberId" resultType="string" order="BEFORE">
            SELECT 'M'||TO_CHAR(NEXTVAL('s_member'),'FM000000000')
        </selectKey>
        INSERT INTO
        t_member
        (
        member_id
        ,first_name
        ,last_name
        ,gender
        ,date_of_birth
        ,email_address
        ,telephone_number
        ,zip_code
        ,address
        ,created_at
        ,last_modified_at
        ,version
        )
        VALUES
        (
        #{memberId}
        ,#{firstName}
        ,#{lastName}
        ,#{gender}
        ,#{dateOfBirth}
        ,#{emailAddress}
        ,#{telephoneNumber}
        ,#{zipCode}
        ,#{address}
        ,#{createdAt}
        ,#{lastModifiedAt}
        ,1
        )
    </insert>

    <insert id="createCredential" parameterType="Member">
        INSERT INTO
        t_member_credential
        (
        member_id
        ,sign_id
        ,password
        ,previous_password
        ,password_last_changed_at
        ,last_modified_at
        ,version
        )
        VALUES
        (
        #{memberId}
        ,#{credential.signId}
        ,#{credential.password}
        ,#{credential.previousPassword}
        ,#{credential.passwordLastChangedAt}
        ,#{credential.lastModifiedAt}
        ,1
        )
    </insert>

    <update id="updateMember" parameterType="Member">
        UPDATE
            t_member
        SET
            first_name = #{firstName}
            ,last_name = #{lastName}
            ,gender = #{gender}
            ,date_of_birth = #{dateOfBirth}
            ,email_address = #{emailAddress}
            ,telephone_number = #{telephoneNumber}
            ,zip_code = #{zipCode}
            ,address = #{address}
            ,created_at = #{createdAt}
            ,last_modified_at = #{lastModifiedAt}
            ,version = version + 1
        WHERE
            member_id = #{memberId}
            AND version = #{version}
    </update>

    <delete id="deleteCredentialByMemberId" parameterType="string">
        DELETE FROM t_member_credential
        WHERE
        member_id = #{memberId}
    </delete>

    <delete id="deleteMemberByMemberId" parameterType="string">
        DELETE FROM t_member
        WHERE
        member_id = #{memberId}
    </delete>

</mapper>