定まったリモートAPI呼び出し (RemoteApi Call)

LastaFluteの特徴の一つです。

概要

リモートAPI呼び出しの複雑な要件

外部のWebサービスの利用や、マイクロサービスアーキテクチャ寄りの構成が多くなってきていることで、自分のシステムから別のシステムのAPIを気軽に呼べることが大切になってきています。

しかも、単なる呼べればいいだけではなく、様々なインターフェース形式に対応できる柔軟性や、通信周りにまつわるエラーのハンドリングなど、非機能的な要件も重要です。 単に HTTP Client をベタッと利用してやり取りをすれば良いというものでもありません。

定型的なライブラリの提供

そこで、LastaFlute ではリモートAPI呼び出しの定型的なライブラリ Lasta RemoteApi を提供しています。こちらを利用してリモートAPIと付き合うと良いでしょう。(この後、RemoteApiと書くと、このライブラリを指すことが多いです。リモートAPIは相手のサーバーのことを指すことが多いです)

RemoteApiのアーキテクチャ

アーキテクチャの基本コンセプト

ざっくりこのようになっています。

HTTP Client
apache の HTTP Client を利用 (基本的にディベロッパーは意識しない)
アプリの実装クラス
Behaviorという名前のファサードクラスを用意 (DBFluteライク)
HTTP Methodの決定
Behaviorでの呼び出しメソッドで決定 e.g. doRequestGet(), doRequestPost()
データの形式の決定
Sender / Receiver を指定して決定 e.g. JsonSender, XmlReceiver
リモートAPIルールの設定
データ変換の仕方や、接続にまつわるオプションなどのルールを設定
デフォルト&メソッドごと
ルールをBehaviorごとにデフォルト設定、個別のメソッドごとに変更可能
e.g. set h2 to database settings @databaseInfoMap.dfprop
 +---------------------+       +----------------+       +-------------+
 | LastaRemoteBehavior |  -->  | LastaRemoteApi |  -->  | HTTP Client |
 +---------------------+       +----------------+       +-------------+
        A        |                  | use
        |        |                  V
        |        |             +--------------------+
        |        +--setup----> | LastaRemoteApiRule |<>-----+
        |        |             +--------------------+       |
     extends     |                                          |
        |        |                                          |
        |        |                                          |
        |        |                                          |
        |        |                                        +-----------------------------+
        |        |    +-----------------------new-------> | LaJsonSender/LaJsonReceiver |
        |        |    |                                   +-----------------------------+
        |        |    |
 +-------------------------+      +----------------+
 |    RemoteHarborBhv      |      | remote_api.xml |
 |    (your component)     |----<>| (your DI xml)  |
 +-------------------------+      +----------------+
                                          A
                                          | include
                                     +---------+
                                     | app.xml |
                                     +---------+

上記のモデルで言うと、アプリの実装クラスは RemoteHarborBhv となります。Harborという別のシステムに対してアクセスをする Behavior というニュアンスになります。基本的に、一つの相手先のサーバーに付き、一つのBehaviorが想定されます。(ただ、厳密に同じでなくてもOKではあります)

POST のときに JSON で送信するのか?そもそも受け取り形式は JSON か?などのデータ形式を、Sender, Receiver という概念で表現しています。例えば、JSONで送信するのであれば JsonSender を指定します。

Sender や Receiver などのリモートAPIごとのルールを、Behavior (リモートAPIサーバー) ごとにデフォルト設定をし、個々のメソッドで少し違う場合はオーバーライドできます。 デフォルトを一切設定せずに、すべて個々のメソッドで設定することもできますが、多くの場合 "このリモートAPIはすべてJSONで渡してJSONでもらう" もしくは、"だいたいJSONで渡して、時々GETパラメーター" というようにデータ形式が "ほぼ" 統一されていることが多いので、デフォルトと個別の二段で設定できるようにしています。

コンセプトの基本実装

Behaviorクラスは、このようになっています。

e.g. RemoteApi behavior for harbor @Java
/**
 * @author jflute
 */
public class RemoteHarborBhv extends LastaRemoteBehavior {

    // ===================================================================================
    //                                                                         Constructor
    //                                                                         ===========
    public RemoteHarborBhv(RequestManager requestManager) {
        super(requestManager);
    }

    // ===================================================================================
    //                                                                          Initialize
    //                                                                          ==========
    @Override
    protected void yourDefaultRule(FlutyRemoteApiRule rule) {
        rule.sendQueryBy(new LaQuerySender(new FlVacantRemoteMappingPolicy()));

        JsonMappingOption jsonMappingOption = new JsonMappingOption();
        rule.sendBodyBy(new LaJsonSender(requestManager, jsonMappingOption));
        rule.receiveBodyBy(new LaJsonReceiver(requestManager, jsonMappingOption));

        rule.handleFailureResponseAs(FaicliUnifiedFailureResult.class);
    }

    @Override
    protected String getUrlBase() {
        return "http://localhost:8090/harbor";
    }

    // ===================================================================================
    //                                                                             Execute
    //                                                                             =======
    public RemoteMypageReturn requestMypage(RemoteMypageParam param) {
        return doRequestGet(RemoteMypageReturn.class, "/lido/mypage", noMoreUrl(), query(param), rule -> {});
    }

    public RemoteSearchPagingReturn<RemoteProductRowReturn> requestProductList(RemoteProductSearchParam param) {
        return doRequestPost(new ParameterizedRef<RemoteSearchPagingReturn<RemoteProductRowReturn>>() {
        }.getType(), "/lido/product/list", moreUrl(1), param, rule -> {});
    }
}
  • sendQueryBy(): QueryのSenderを指定。ここでは、個別のメソッドで指定
  • sendBodyBy(): BodyのSenderを指定。ここでは、このBehaviorデフォルトとしてJSON送信
  • receiveBodyBy(): Receiverを指定。ここでは、このBehaviorデフォルトとしてJSON受信
  • handleFailureResponseAs(): エラー時のレスポンスを受け取る型を指定。発生した例外から取得できる

yourDefaultRule() で、Behaviorのデフォルトルールを設定しています。ここでは、Sender と Receiver ともに JSON (LaJsonSender, LaJsonReceiver) を指定しています。一方で、Queryパラメーターに関する Sender は指定していないので、GETでリクエストを送るときはメソッドのルールとして LaQuerySender を指定する必要があります。

それぞれの "requestメソッド" (requestXxx()) では、doRequestGet() や doRequestPost() などの HTTP Method ごとのprotectedメソッドを呼び出して、具体的な呼び出し情報などを引数で指定しています。 requestメソッドの名前や引数は任意で、業務的に適した名前を付けましょう。

まずは、RemoteApiの環境準備

lasta-remoteapiの依存ライブラリ定義

pom.xml に lasta-remoteapi を定義します。

e.g. Lasta RemoteApi dependency @pom.xml
<lasta.remoteapi.version>0.4.1</lasta.remoteapi.version>

...

<dependency>
    <groupId>org.lastaflute.remoteapi</groupId>
    <artifactId>lasta-remoteapi</artifactId>
    <version>${lasta.remoteapi.version}</version>
</dependency>

最新版の確認はアップグレードのページにて。

Behavior を定義する DI xml を定義

app.xml で remote_api.xml を include します。ただし、remote_api.xml は手動で作成します。 まず最初の段階では何もコンポーネントのない状態で作成します。

e.g. include RemoteApi Di xml @app.xml
<components>
	<include path="convention.xml"/>
	<include path="dbflute.xml"/>
	<include path="lastaflute.xml"/>
	<include path="remote_api.xml"/>
</components>

Behaviorの実装の仕方

Behaviorのパッケージをクラス名

定型的な慣習があります。

パッケージ
[サービスのパッケージ].remote.[相手先のサービス名].[相手先のアプリ名]
クラス名
Remote[相手先のサービス名][相手先のアプリ名]Bhv
e.g. RemoteApi's behavior package, to harbor, maihama-dockside @Package
org.docksidestage
 |-app
 |-dbflute
 |-mylasta
 |-remote
 |  |-harbor            // サービス名 (シングルプロジェクト)
 |  |  |-RemoteHarborBhv.java
 |  |
 |  |-maihama           // サービス名 (マルチプロジェクト)
 |  |  |-dockside       // アプリ名 (マルチプロジェクト)
 |  |  |  |-RemoteMaihamaDocksideBhv.java

Behaviorクラスの定義

LastaRemoteBehavior を継承して、コンストラクタで RequestManager を受け付けます。

そして、以下の二つのメソッドを定義します。

  • yourDefaultRule() を実装
  • getBaseUrl() を実装
e.g. RemoteApi behavior for harbor @Java
/**
 * @author jflute
 */
public class RemoteHarborBhv extends LastaRemoteBehavior {

    // ===================================================================================
    //                                                                         Constructor
    //                                                                         ===========
    public RemoteHarborBhv(RequestManager requestManager) {
        super(requestManager);
    }

    // ===================================================================================
    //                                                                          Initialize
    //                                                                          ==========
    @Override
    protected void yourDefaultRule(FlutyRemoteApiRule rule) {
        rule.sendQueryBy(new LaQuerySender(new FlVacantRemoteMappingPolicy()));

        JsonMappingOption jsonMappingOption = new JsonMappingOption();
        rule.sendBodyBy(new LaJsonSender(requestManager, jsonMappingOption));
        rule.receiveBodyBy(new LaJsonReceiver(requestManager, jsonMappingOption));

        rule.handleFailureResponseAs(FaicliUnifiedFailureResult.class);
    }

    @Override
    protected String getUrlBase() {
        return "http://localhost:8090/harbor";
    }

    // ===================================================================================
    //                                                                             Execute
    //                                                                             =======
    public RemoteMypageProductReturn requestMypage(RemoteMypageParam param) {
        return doRequestGet(RemoteMypageProductReturn.class, "/lido/mypage", noMoreUrl(), query(param), rule -> {});
    }

    public RemoteSearchPagingReturn<RemoteProductRowReturn> requestProductList(RemoteProductSearchParam param) {
        return doRequestPost(new ParameterizedRef<RemoteSearchPagingReturn<RemoteProductRowReturn>>() {
        }.getType(), "/lido/product/list", moreUrl(1), param, rule -> {});
    }
}

yourDefaultRule()の実装

ここで、Sender, Receiver, FailureResponseなどを設定します。 Behavior内でのすべてのrequestメソッドで有効になるので、統一的なルール、もしくは、多くのrequestメソッドで必要となるルールを設定しましょう。

e.g. RemoteApi yourDefaultRule() @Java
    @Override
    protected void yourDefaultRule(FlutyRemoteApiRule rule) {
        rule.sendQueryBy(new LaQuerySender(new FlVacantRemoteMappingPolicy()));

        JsonMappingOption jsonMappingOption = new JsonMappingOption();
        rule.sendBodyBy(new LaJsonSender(requestManager, jsonMappingOption));
        rule.receiveBodyBy(new LaJsonReceiver(requestManager, jsonMappingOption));

        rule.handleFailureResponseAs(FaicliUnifiedFailureResult.class);
    }

(だいたい)必須のルールは以下の三つ。

sendQueryBy()
GET/DELETEリクエストを使うなら必須
sendBodyBy()
POST/DELETEリクエストを使うなら必須
receiveBodyBy()
どんなリクエストでも必須

デフォルトで指定しなかった場合は、メソッドのルールで設定する必要があります。

handleFailureResponseAs()は、RemoteApiから戻ってくる(かもしれない)エラーのレスポンスを受け取るクラスを指定します。 RemoteApiHttpClientErrorException などから取得できます。 必須ではありませんが、エラー情報を使って何かハンドリングをする場合は必要です。(大抵、必要だと想定されます)

getBaseUrl()の実装

その Behavior の request で共通部分の基底URLを return します。Behaviorの粒度が1サーバー1Behaviorであれば、コンテキストパスまでを指定します。 (e.g. http://localhost:8090/harbor)

実際には、ローカル開発用URLと検証環境用URLと本番用URLと、テスト環境都合でデプロイ環境ごとにURLは変わる可能性があるので、[app]_env.properties に定義して、configから取得して環境切り替えできるようにするのが現実的でしょう。(本番でもlocalhostなんてことはあまりないかと)

e.g. RemoteApi harbor URL from configuration @Java
    @Resource
    private FortressConfig config;

    @Override
    protected String getUrlBase() {
        return config.getRemoteApiBaseUrlHarbor();
    }

requestメソッドの実装

たっぷりこの後、独立セクションでお送りいたします。

requestメソッドの実装の仕方

requestメソッドのメソッド名

厳密には任意ですが、request[業務名]() という名前が慣習です。 通信してるっぽい名前を付けて、プログラム上で意識させたいためです。(パフォーマンス上、重要ポイントであることを示したい)

e.g. request methods @Java
public RemoteMypageReturn requestMypage(RemoteMypageParam param) {
    ...
}

public RemoteSearchPagingReturn<RemoteProductRowReturn> // 都合改行
        requestProductList(RemoteProductSearchParam param) {
    ...
}

アプリプロジェクトで、requestではない別の名前を付けて統一するのは特に問題ありません。フレームワークにおける概念名が request というだけであって、find...(), update...() など付けてもいいでしょう。 (その代わり、しっかりプロジェクトで統一性をキープしましょう)

requestメソッドの引数と戻り値

"Paramクラス" と "Returnクラス" を用意してデータのやり取りをします。

Paramクラス
クエリーパラメータ、リクエストボディ(RequestBody)のデータ
Returnクラス
レスポンスボディ(ResponseBody)のデータ (ときに Header なども含む)

JSON や XML など、様々な形式のデータを受け渡しするクラスです。

引数のParamクラスや戻り値のReturnクラスの名前は任意です。デフォルトの慣習として、意味を持たない Param や Return を使っていますが、リクエストやレスポンスの形式は千差万別なので、アプリのポリシーに合わせます。 (特にポリシーとか決めてないのであれば、みんなでブレまくるよりかは Param, Return で良いでしょう)

利用されるBehaviorの近くに業務ごとにパッケージ配下に作成すると良いでしょう。(厳密には任意)

e.g. param and return classes location @Package
org.docksidestage
 |-app
 |-dbflute
 |-mylasta
 |-remote
 |  |-harbor            // サービス名 (シングルプロジェクト)
 |  |  |-base           // 抽象クラスや共通のクラスを置く場所
 |  |  |  |-RemoteSearchPagingReturn.java
 |  |  |
 |  |  |-mypage
 |  |  |  |-RemoteMypageParam.java
 |  |  |  |-RemoteMypageReturn.java
 |  |  |
 |  |  |-product
 |  |  |  |-RemoteProductRowReturn.java
 |  |  |  |-RemoteProductSearchParam.java
 |  |  |
 |  |  |-RemoteHarborBhv.java

requestメソッドで、引数と戻り値に定義します。Paramクラス以外の引数が入っても構いません。 オプションなどを呼び出し側からもらいたい場合などは、第二引数以降で受け取ると良いでしょう。

e.g. param and return definition @Java
public RemoteMypageReturn requestMypage(RemoteMypageParam param) {
    ...
}

public RemoteSearchPagingReturn<RemoteProductRowReturn> // 都合改行
        requestProductList(RemoteProductSearchParam param) {
    ...
}

中の実装は、Actionクラスで使う、Form や Body, Result などと形式はほぼ同じです。 Validatorアノテーションも利用できるので、@Required などは積極的に付けていきましょう。 少しAPIならではの機能が付け足されています。

doRequest...()の呼び出し

HTTP Methodごとにメソッドが分かれています。

doRequestGet()
GETで送信、クエリーパラメーターのデータを引数で指定
doRequestPost()
POSTで送信、リクエストボディのデータを引数で指定
doRequestPut()
PUTで送信、あとは doRequestPost()と同じ
doRequestDelete()
DELETEで送信、あとは doRequestGet()と同じ
e.g. call doRequestGet(), doRequestPost() @Java
public RemoteMypageReturn requestMypage(RemoteMypageParam param) {
    return doRequestGet(RemoteMypageReturn.class, "/lido/mypage", noMoreUrl(), query(param), rule -> {});
}

public RemoteSearchPagingReturn<RemoteProductRowReturn> requestProductList(RemoteProductSearchParam param) {
    return doRequestPost(new ParameterizedRef<RemoteSearchPagingReturn<RemoteProductRowReturn>>() {
    }.getType(), "/lido/product/list", moreUrl(1), param, rule -> {});
}

指定する引数はこのようになっています。

Returnクラスの型
Class型とParameterizedType型を指定 (戻りのないAPIなら、"void.class" を指定)
アプリ部分のURL
getBaseUrl()の続きの固定のURLを指定 e.g. /product/list
追加の動的URL
Pathパラメーター部分のURLを指定、moreUrl()を使うと良い
クエリーパラメーター
(GET/DELETEのみ) ParamクラスをOptionalThingで指定 e.g. query(), noQuery()
リクエストボディ
(POST/PUTのみ) Paramクラスを指定 (必須)
メソッドごとのルール
yourDefaultRule()のルールを上書きしたいときに利用

ネストしたGenericsはParameterizedType

ネストしたGenericsのParamクラスを指定するときは、ParameterizedRefクラスを利用して、指定された型の ParameterizedType を導出します。これは、Javaの文法上どうしてもこういった書き方が必要になります。

e.g. how to derive ParameterizedType of RemoteSearchPagingReturn @Java
new ParameterizedRef<RemoteSearchPagingReturn<RemoteProductRowReturn>>() {
}.getType()
  • 最後、getType()を忘れずに

クエリーパラメーターはOptionalThing

クエリーパラメーターは、GET/DELETEでも必須ではないので、OptionalThing型で指定します。 (OptionalThingは、DBFluteが提供する Optional の拡張クラスです。でも継承関係はありません)

業務的に必ず存在するの場合は、requestメソッドの引数はそのままParamクラスで、query(param) メソッドで指定すると良いでしょう。

業務的に必ず存在しないのであれば、noQuery() メソッドで固定的に指定しましょう(nullは不可)

業務的に存在するかしないか動的に変わる場合は、requestメソッドの引数自体を OptionalThing で受けるようにして、呼び出し側でクエリーパラメーターの有無を制御すると良いでしょう。

※この辺は、コントリビュートを頂いたときの互換性キープの都合もあり、こうなっています。

FormやJSONのキー名の翻訳

TODO jflute キャメルケースの一括変換 (JsonMappingOption) や、ピンポイントでの名前の変更 (SerializedName) など

リモートAPIのエラーハンドリング

エラーと例外クラスの対応

エラーと例外クラスの対応はこちらの通り。

クライアントエラー
RemoteApiHttpClientErrorException *A
サーバーエラー
RemoteApiHttpServerErrorException
正常時のレスポンス解析エラー
RemoteApiResponseParseFailureException
エラー時のレスポンス解析エラー
RemoteApiResponseParseFailureException *B
リクエストのバリデーションエラー
RemoteApiRequestValidationErrorException
レスポンスのバリデーションエラー
RemoteApiResponseValidationErrorException
I/Oエラー
RemoteApiIOException

*A: 一番、アプリが意識する例外となります。catchして制御することも多いでしょう。

*B: 自動的にthrowされる例外ではなく、RemoteApiHttpClientErrorException や RemoteApiHttpServerErrorException の getFailureResponse() の戻り値である OptionalThing に内包される例外となります。alwaysPresent() や orElseTranslatingThrow() などでお目にかかります。 (エラー時のレスポンスは、brokenなことがよくあると想定されるので、元のクライアントエラーやサーバーエラーをキープするために、即時throw例外にしていません)

そもそも、クライアントエラーやサーバーエラーとはなんぞや?という場合はこちらのページを。

エラーレスポンスの受け取り

ルールの設定で、rule.handleFailureResponseAs([受け取りのクラス]) を指定していれば、クライアントエラーやサーバーエラーのときのレスポンスデータを指定したクラスで受け取ることができます。 (あまりサーバーエラーのレスポンスを意識して受け取ることはないと思うので、主にはクライアントエラーのときでしょう)

yourDefaultRule() で、handleFailureResponseAs() で指定して...

e.g. set failure response type in yourDefaultRule() @Java
@Override
protected void yourDefaultRule(FlutyRemoteApiRule rule) {
    ...
    rule.handleFailureResponseAs(FaicliUnifiedFailureResult.class);
}

例外をキャッチして、getFailureResponse()で受け取ります。

e.g. translate 400 and login failure response to LoginFailureException @Java
try {
    ... // calling remote API
} catch (RemoteApiHttpClientErrorException e) { // クライアントエラー
    if (e.getHttpStatus() == 400) { // 大抵は、HTTP Statusを指定してから
        e.getFailureResponse().alwaysPresent(response -> { // 解析エラーなら例外
            FaicliUnifiedFailureResult result = (FaicliUnifiedFailureResult)response;
            ... // エラーレスポンスのデータを使って業務的な処理
        }
    }
    throw e; // 別のエラーのときのthrowを忘れずに
}
  • エラーレスポンス自体の解析エラーのときは、getFailureResponse() の戻りが empty になる
  • なので、もし解析エラーをシステム例外にしたくなければ、orElseTranslatingThrow()などで制御
  • フレームワーク組み込みの例外クラスなので、どうしてもObject型からのダウンキャストは必要
  • 実際は、failureResponseを取り扱う共通クラスなどを用意したほうが良い

主に、ユーザー入力項目のバリデーションエラーなどで、よく利用されるでしょう。

例外の翻訳をするのは?

主にクライアントエラーのときは、内容次第で例外を翻訳したいことがあるでしょう。 例えば、とあるクライアントエラーを、呼び出し側で都合の良い業務例外に翻訳するなど。

RemoteApiHttpClientErrorExceptionから HTTP Status とエラーレスポンスが取得できるので、単純な try/catch で処理しましょう。

HTTP Statusだけで例外翻訳するなら...

e.g. translate 401 response to LoginRequiredException @Java
try {
    ... // calling remote API
} catch (RemoteApiHttpClientErrorException e) { // クライアントエラー
    if (e.getHttpStatus() == 401) {
        throw new LoginRequiredException("...your message", e);
    }
    throw e; // 別のエラーのときのthrowを忘れずに
}

HTTP Statusに加えてエラーレスポンスの中身も見て例外翻訳するなら... (ふぁいくらパターンなど)

e.g. translate 400 and login required response to LoginRequiredException in your action @Java
try {
    ... // calling remote API
} catch (RemoteApiHttpClientErrorException e) { // クライアントエラー
    if (e.getHttpStatus() == 400) {
        e.getFailureResponse().alwaysPresent(response -> {
            if (errorResponseAgent.isBusinessErrorLoginFailure(response)) {
                throw new LoginRequiredException("...your message", e);
            }
        });
    }
    throw e; // 別のエラーのときのthrowを忘れずに
}
  • rule.handleFailureResponseAs() で、エラーレスポンスの受け取りクラスを指定していることが前提
  • alwaysPresent()で、エラーレスポンス自体のパースエラーのときはシステム例外としてthrowしている
  • errorResponseAgentはただのExampleだが、どうせ他でも使うことが多いので判定は共通化した方が良い

統一的な例外の翻訳は?

同じような例外の翻訳を複数の Action でやるのは大変なので、RemoteApi からthrowされる例外が、すでにアプリに適した翻訳された例外になっていたほうがいいかもしれません。

rule.translateClientError() で統一的に翻訳できます。

e.g. translate 400 and login required response to LoginRequiredException in your rule @Java
@Override
protected void yourDefaultRule(FlutyRemoteApiRule rule) {
    ...
    rule.translateClientError(resource -> {
        RemoteApiHttpClientErrorException clientError = resource.getClientError();
        if (clientError.getHttpStatus() == 400) { // controlled client error
            RemoteUnifiedFailureResult result = (RemoteUnifiedFailureResult) clientError.getFailureResponse().get();
            if (RemoteUnifiedFailureType.LOGIN_REQUIRED.equals(result.cause)) {
                return new LoginRequiredException("...your message", e);
            }
        }
        return null; // no translation
    });
}

ユーザー入力項目バリデーションをリモートAPI側で行う場合は、呼び出し側のバリデーションエラーに翻訳する必要があります。 よくあるパターンなので、resource.asHtmlValidationError(messages) という専用のメソッドが用意されています。

TODO jflute クライアントメッセージ方式とサーバーメッセージ方式の両方の asHtmlValidationError() の Example コード

catchせず、そのままthrowしっぱなしだと?

どの例外でも、catchせず、そのままthrowしっぱなしにするとシステム例外として扱われます。

クライアントエラー (ここでは業務例外も含む) も、こちらでcatchして何か処理をしないということは、こちらの不備ということでリカバリ不能なのでシステム例外となります。 (システム例外として翻訳されるというニュアンス)

RemoteApiの例外に対して、何かしら業務的な処理をするのであれば、呼び出し側で try/catch をするか、rule.translateClientError() で統一的な翻訳をするか、何かしらの対処をしましょう。

UnitTestのやり方

MockHttpClientがあります

MockHttpClientクラスを使ってテストをすると良いでしょう。

e.g. RemoteApi behavior for harbor @Java
/**
 * @author jflute
 */
public class RemoteHarborBhvTest extends UnitFortressTestCase {

    @Resource
    private RequestManager requestManager;

    public void test_requestProductList_basic() {
        // ## Arrange ##
        RemoteProductSearchParam param = new RemoteProductSearchParam();
        param.productName = "S";
        String json = "{pageSize=4, currentPageNumber=1, allRecordCount=20, allPageCount=5, rows=[]}";
        MockHttpClient client = MockHttpClient.create(resopnse -> {
            resopnse.peekRequest(request -> {
                assertContainsAll(request.getBody().get(), "productName", param.productName);
            });
            resopnse.asJsonDirectly(json, request -> true);
        });
        registerMock(client);
        RemoteHarborBhv bhv = new RemoteHarborBhv(requestManager);
        inject(bhv);

        // ## Act ##
        RemoteSearchPagingReturn<RemoteProductRowReturn> ret = bhv.requestProductList(param);

        // ## Assert ##
        assertEquals(4, ret.pageSize);
        assertEquals(5, ret.allPageCount);
        assertEquals(20, ret.allRecordCount);
        assertEquals(5, ret.allPageCount);
        assertEquals(0, ret.rows.size());
    }

    public void test_validationError_basic() {
        // ## Arrange ##
        RemoteProductSearchParam param = new RemoteProductSearchParam();
        String json = "{cause=VALIDATION_ERROR, errors : [{field=productName, code=LENGTH, data={min:0,max:10}}]}";
        MockHttpClient client = MockHttpClient.create(resopnse -> {
            resopnse.asJsonDirectly(json, request -> true).httpStatus(400);
        });
        registerMock(client);
        RemoteHarborBhv bhv = new RemoteHarborBhv(requestManager);
        inject(bhv);

        // ## Act ##
        assertException(RemoteApiHttpClientErrorException.class, () -> bhv.requestProductList(param)).handle(cause -> {
            // ## Assert ##
            FaicliUnifiedFailureResult result = (FaicliUnifiedFailureResult) cause.getFailureResponse().get();
            log(result);
            assertEquals(FaicliUnifiedFailureType.VALIDATION_ERROR, result.cause);
            assertHasOnlyOneElement(result.errors);
            FaicliFailureErrorPart errorPart = result.errors.get(0);
            assertEquals("productName", errorPart.field);
            assertEquals("LENGTH", errorPart.code);
            assertEquals(0, toInteger(errorPart.data.get("min"))); // because it may be decimal type
            assertEquals(10, toInteger(errorPart.data.get("max"))); // me too
        });
    }
}
  • peekRequest()で、想定通りのリクエストを送っているかのアサートもすると良いでしょう
  • ここでは、一つのRemoteApiしか呼ばないので、asJson()のリクエストのマッチングは固定でtrue

実際には、JSONはハードコードではなく、ファイル取得の方が良いでしょう。

e.g. asJson() by path to classpath resource @Java
    resopnse.asJson("/mock/harbor/sea.json", request -> true));

また、期待値である JSON は、リモートAPI側のインターフェース仕様に従ったものから抽出して定義しましょう。 ここを自分たちサーバー本位で決めてしまうと意味のないテストになってしまいます。 (リモートAPI側のドキュメントやコードから機械的に抽出できるとHappyでしょう)

RemoteApiを使うActionのUnitTest

RemoteApi を呼び出している Action の UnitTest を書くときも、同じように MockHttpClient を使うと良いでしょう。(Logicでも同じ話です) TODO jflute たぶん、peekRequest()はあまり使わない。複数のRemoteApiを呼ぶときは、asJson()の第二引数で RemoteApi を識別すべし

複数のRemoteApiを呼ぶときは、asJson()の第二引数で RemoteApi を識別しましょう。

e.g. asJson() for specified request @Java
    resopnse.asJson(path, request -> request.getUrl().contains("/harbor/"));

ActionのUnitTestのときは peekRequest() はあまり使わない想定です。想定通りのリクエストを投げることができているかどうかは、Behavior の UnitTest に任せる方が良いでしょう。

RemoteApiGen, 自動生成

自動生成できます。

RemoteApiGenのアーキテクチャ

LastaFlute RemoteApiGen Architecture

RemoteApiGenのセットアップ

RemoteApiGenの設定ファイルやExampleは、もろもろ dbflute-example-with-non-rdb リポジトリに入っています。 こちらを参考に環境構築をしていきましょう。

Exampleプロジェクト

lastaflute-test-fortressプロジェクト (テスト用のプロジェクト) に、RemoteHarborBhv という RemoteApi の Example コードもあります。

RemoteHarborBhv
手動作成、単一プロジェクト (harbor)
RemoteMaihamaHangarBhv
手動作成、マルチプロジェクト (maihama - hangar)
RemoteMaihamaShowbaseBhv
自動生成, マルチプロジェクト (maihama - showbase)
RemoteSwaggerPetstoreBhv
自動生成, petstoreを使った動作確認用
RemoteSwaggerTrickyBhv
自動生成, トリッキーな動作確認用

Great Thanks

Lasta RemoteApiは、"U-NEXTさん" より、コントリビュート頂きました。

RemoteApiGenは、"LUXAさん" より、コントリビュート頂きました。

ありがとうございます!