プレゼンテーション層の実装ルール
プレゼンテーション層は、HTTP とアプリケーションの境目を受け持ちます。 この層に業務判断が混ざると、同じ処理をバッチジョブから呼びたくなったときに再利用できません。
この層に置くのは四種類のクラスと、ステータスコードを決める例外です。
| パッケージ | クラス | 役割 |
|---|---|---|
presentation.endpoint | {リソース名}Endpoint、{リソース名}EndpointFactory | REST API の入口 |
BusinessErrorException | 業務として成立しない要求を 422 で返す例外 | |
presentation.request | {操作名}Request | リクエストの受け皿 |
presentation.response | {操作名}Response | レスポンスの組み立て |
presentation.validator | {操作名}Validator | 入力の形式検証 |
ValidationException | 形式の不備を 400 で返す例外 |
エンドポイントの構成
REST API は Web API Maker で公開します。 必要なのはファクトリとエンドポイントの二クラスです。
package jp.co.example.foo.presentation.endpoint;
import jp.co.intra_mart.foundation.web_api_maker.annotation.ProvideFactory;
import jp.co.intra_mart.foundation.web_api_maker.annotation.ProvideService;
import jp.co.intra_mart.foundation.web_api_maker.annotation.WebAPIMaker;
/**
* 発注エンドポイントのファクトリクラスです。
*/
@WebAPIMaker
public class OrderEndpointFactory {
@ProvideFactory
public static OrderEndpointFactory getFactory() {
return new OrderEndpointFactory();
}
@ProvideService
public OrderEndpoint getEndpoint() {
return new OrderEndpoint();
}
}
エンドポイントには、パスとメソッドと認証の指定、入力検証、ユースケースの呼び出し、例外の置き換えだけを書きます。
package jp.co.example.foo.presentation.endpoint;
import java.util.List;
import jp.co.example.foo.application.exception.OrderAppException;
import jp.co.example.foo.application.usecase.GetOrderUseCase;
import jp.co.example.foo.presentation.response.OrderResponse;
import jp.co.example.foo.presentation.validator.GetOrderValidator;
import jp.co.example.foo.presentation.validator.ValidationException;
import jp.co.intra_mart.foundation.authz.annotation.Authz;
import jp.co.intra_mart.foundation.web_api_maker.annotation.GET;
import jp.co.intra_mart.foundation.web_api_maker.annotation.IMAuthentication;
import jp.co.intra_mart.foundation.web_api_maker.annotation.Path;
import jp.co.intra_mart.foundation.web_api_maker.annotation.Required;
import jp.co.intra_mart.foundation.web_api_maker.annotation.Variable;
/**
* 発注情報を提供する REST API エンドポイントです。
*/
@IMAuthentication
@Authz(uri = "service://foo/web/tenant", action = "execute")
public class OrderEndpoint {
private final GetOrderUseCase useCase = new GetOrderUseCase();
@Path("/api/foo/order/{orderId}")
@GET(summary = "発注取得", description = "発注IDを指定して発注情報を取得します。")
public OrderResponse get(
@Required @Variable(name = "orderId", description = "発注ID") final String orderId)
throws BusinessErrorException {
final List<String> errors = GetOrderValidator.validate(orderId);
if (!errors.isEmpty()) {
throw new ValidationException(errors);
}
try {
return useCase.execute(orderId);
} catch (final OrderAppException e) {
throw new BusinessErrorException(e.getMessage(), e);
}
}
}
エンドポイントのクラスと、レスポンスやリクエストに使うクラスは、すべて public にします。
パッケージの登録
エンドポイントのパッケージは、src/main/resources/META-INF/im_web_api_maker/packages に1行ずつ書きます。
jp.co.example.foo.presentation.endpoint
書くのはファクトリクラスではなく、エンドポイントのクラスを置いたパッケージです。 登録を忘れると、アノテーションを正しく付けていても API として認識されず、アクセスは 404 になります。 エンドポイントを複数のパッケージに分けて置く場合は、パッケージごとに1行ずつ追記します。
認証と認可
認証と認可はアノテーションで宣言します。
@IMAuthentication はログイン済みであることを、@Authz は認可リソースに対する権限を要求します。
認証のアノテーション(@IMAuthentication、@BasicAuthentication、@OAuth)は、一つのクラスに一つだけ付けます。
@Authz の uri に指定する認可リソースは、あらかじめ IM-Authz に登録しておきます。
@Authz を付けるだけでは、認可は機能しません。
認証や認可で拒否されたときの応答は次のとおりです(@IMAuthentication の場合)。
| 状況 | ステータス | レスポンス |
|---|---|---|
| ログインしていない | 404 | HTML のエラーページ。401 にはならない |
| ログイン済みだが権限がない | 403 | error と data を持つオブジェクト |
@Authz の uri のリソースが登録されていない | 500 | HTML のエラーページ(ResourceNotFoundException) |
ログインしていないアクセスが 404 になるのは、認証の判定がエンドポイントの呼び出しより手前で行われるためです。 レスポンスの形についてはレスポンスの形式で扱います。
認可リソースの登録と、@BasicAuthentication や @OAuth を組み合わせる方法は、Web API Maker と認可の機能そのものの話であり、本ガイドでは扱いません。
セキュアトークン
@Secured はセキュアトークンを検証し、CSRF(クロスサイトリクエストフォージェリ)を防ぎます。
「誰としてアクセスするか」を決める認証のアノテーションとは役割が違います。
ブラウザから呼ばれる状態変更系の API(POST、PUT、DELETE)では、両方を付けます。
参照だけの GET には付けません。
@Secured を付けた API を呼ぶクライアントは、リクエストヘッダ X-Intramart-Secure-Token にトークンを載せます。
JSSP の画面から呼ぶ場合は、<meta name="im_secure_token"> から取得したトークンをそのまま使えます。
トークンのないリクエストは 403 になります。
@BasicAuthentication や @OAuth を使う外部システム向けの API には、通常 @Secured を付けません。
担うことと担わないこと
エンドポイントは、受け取ったパラメータをユースケースへ渡すことに徹します。
担うのは次の四つです。
- リクエストの受け取りとパース
- 入力の形式検証
- ユースケースの呼び出し
- レスポンスの返却とステータスコードの決定
次の四つは担いません。
- 業務ルールの判定
- ドメインモデルの操作
- DBアクセス
- トランザクション制御
SessionTemplate や DAOFactory がこの層に現れたら、境界を越えています。
リクエストとレスポンスの DTO
外部とやり取りするデータは、専用のクラスで受け渡します。
package jp.co.example.foo.presentation.response;
import java.math.BigDecimal;
import jp.co.example.foo.domain.model.Order;
/**
* 発注取得APIのレスポンスです。
*/
public class OrderResponse {
private String orderId;
private String customerName;
private BigDecimal amount;
/**
* ドメインモデルからレスポンスを組み立てます。
* @param order 発注
* @return レスポンス
*/
public static OrderResponse fromDomainModel(final Order order) {
final OrderResponse response = new OrderResponse();
response.orderId = order.getOrderId();
response.customerName = order.getCustomerName();
response.amount = order.getAmount();
return response;
}
// getter と setter は省略
}
DTO には、引数なしのコンストラクタと、プロパティごとの getter と setter を用意します。
Web API Maker が入出力の対象にするのは、getter と setter がそろったプロパティだけです。
値が null のプロパティは、レスポンスに出力されません。
変換メソッドをレスポンス側の static メソッドとして持たせると、ドメインモデルは DTO の存在を知らずに済みます。
逆にドメインモデルへ toResponse() を生やすと、ドメイン層がプレゼンテーション層を参照することになり、依存の向きが反転します。
エンティティをそのまま返さない理由は全体アーキテクチャで扱いました。 ドメインモデルを直接返すのも避けます。 ドメインモデルはビジネスルールを持つクラスであり、その形を API の互換性に縛られると、業務側の都合で変更できなくなります。
リクエスト側に DTO が要るのは、POST や PUT でリクエストボディを受け取るときです。
パスやクエリで渡される値は @Variable や @Parameter で引数として直接受けられるため、上の get メソッドのように DTO を作る必要はありません。
package jp.co.example.foo.presentation.request;
import java.math.BigDecimal;
/**
* 発注登録APIのリクエストです。
*/
public class RegisterOrderRequest {
private String customerName;
private BigDecimal amount;
// getter と setter は省略
}
ボディを受け取るエンドポイントは、@Body を付けた引数で DTO を受け取ります。
状態を変える API なので、@Secured を付けます。
@Path("/api/foo/order")
@POST(summary = "発注登録", description = "発注情報を登録します。")
@Secured
public OrderResponse register(@Body final RegisterOrderRequest request) throws BusinessErrorException {
final List<String> errors = RegisterOrderValidator.validate(request);
if (!errors.isEmpty()) {
throw new ValidationException(errors);
}
try {
return registerUseCase.execute(request);
} catch (final OrderAppException e) {
throw new BusinessErrorException(e.getMessage(), e);
}
}
レスポンスの組み立てが OrderResponse 側の static メソッドだったのに対し、リクエスト DTO からドメインモデルへの変換はユースケースが行います。
リクエスト DTO に toDomainModel() を持たせると、プレゼンテーション層のクラスがドメインモデルを組み立てることになり、業務ルールの入口が二か所に増えます。
入力検証
検証はエンドポイントに直接書かず、Validator に切り出します。
package jp.co.example.foo.presentation.validator;
import java.util.ArrayList;
import java.util.List;
/**
* 発注取得APIの入力検証を行います。
*/
public final class GetOrderValidator {
private GetOrderValidator() {
}
/**
* 発注IDを検証します。
* @param orderId 発注ID
* @return エラーメッセージの一覧。問題がなければ空のリスト
*/
public static List<String> validate(final String orderId) {
final List<String> errors = new ArrayList<>();
if (orderId == null || orderId.isEmpty()) {
errors.add("orderId は必須です");
} else if (orderId.length() > 15) {
errors.add("orderId は15文字以内で指定してください");
}
return errors;
}
}
エラーを見つけた時点で例外を投げるのではなく、リストに溜めて返します。 複数の項目に不備があるとき、一度の応答ですべてを返せます。
エンドポイントは、エラーが一つでもあれば ValidationException を投げます。
package jp.co.example.foo.presentation.validator;
import java.util.List;
import jp.co.intra_mart.foundation.web_api_maker.annotation.Response;
import jp.co.intra_mart.foundation.web_api_maker.annotation.ReturnValue;
/**
* 入力の形式の不備を表す例外です。
*/
@Response(code = 400)
public class ValidationException extends RuntimeException {
private final List<String> errors;
public ValidationException(final List<String> errors) {
super("入力内容に誤りがあります");
this.errors = errors;
}
/**
* 検証で見つかったエラーの一覧を返します。
* @return エラーメッセージの一覧
*/
@ReturnValue
public List<String> getErrors() {
return errors;
}
}
@Response(code = 400) がステータスコードを、@ReturnValue を付けた getter がレスポンスの data に載せる内容を決めます。
ここで見るのはリクエストの形式だけです。 金額が業務として妥当かどうかは、ドメイン層のサービスが判定します。 プレゼンテーション層を通らないバッチジョブからもサービスは呼ばれるため、両方に検証が必要になります。 役割の線引きはインフラストラクチャ層の実装ルールで扱います。
検証で弾いた入力について、ログは出力しません。
利用者の入力誤りであってシステムの障害ではないため、ERROR として記録すると、実際の障害がログに埋もれます。
認可エラーも同じ理由でログを出力しません。
認可の失敗は認可の機能が処理します。
例外から HTTP への変換
ステータスコードは、エンドポイントから投げた例外のクラスに付けた @Response(code = ...) で決まります。
@Response を付けていない例外は 500 になります。
| 例外 | ステータス | 意味 |
|---|---|---|
ValidationException | 400 | リクエストの形式が不正 |
BusinessErrorException | 422 | 形式は正しいが業務として成立しない |
@Response を付けていない例外 | 500 | 想定していない失敗 |
アプリケーション層の {アプリ名}Exception には @Response を付けません。
付けると、アプリケーション層が Web API Maker に依存します。
エンドポイントで捕捉し、@Response(code = 422) を付けたこの層の例外に置き換えます。
package jp.co.example.foo.presentation.endpoint;
import jp.co.intra_mart.foundation.web_api_maker.annotation.Response;
/**
* 業務として成立しない要求を表す例外です。
*/
@Response(code = 422)
public class BusinessErrorException extends Exception {
public BusinessErrorException(final String message, final Throwable cause) {
super(message, cause);
}
}
400 と 422 を分けるのは、呼び出し側の対処が違うためです。 400 はリクエストを直せば成功する可能性があり、422 はリクエストを直しても成立しません。
該当するデータがないことを 404 として伝えたい場合は、@Response(code = 404) を付けた例外を投げます。
ログの扱いも例外ごとに変えます。
業務エラーは WARN で、想定外の失敗は ERROR でスタックトレースごと記録します。
入力検証のエラーと認可のエラーは記録しません。
レスポンスの形式
エンドポイントの戻り値は、そのままレスポンスのボディにはなりません。
Web API Maker が error と data を持つオブジェクトで包んでから返します。
成功したときのボディです(Accept: application/json)。
{
"error": false,
"data": {
"orderId": "ORDER001",
"customerName": "株式会社サンプル",
"amount": 10000
}
}
例外が発生したときのボディです。
{
"error": true,
"errorMessage": "入力内容に誤りがあります",
"data": {
"errors": ["orderId は15文字以内で指定してください"]
}
}
| プロパティ | 内容 |
|---|---|
error | 例外が発生したかどうか。成功時は false |
data | 成功時はエンドポイントの戻り値。例外のときは @ReturnValue を付けた getter の値(なければ出力されない) |
errorMessage | 例外のときだけ出力される、例外のメッセージ |
クライアントは data から業務データを取り出します。
包みを解かずにプロパティを参照すると、API が正しく動いていても値は undefined になります。
リクエストには Accept: application/json を付けます。
付けないと、JSON で返る保証がありません。
包まれるのは、エンドポイントのメソッドまで処理が届いた場合です。
- 包まれる:成功時、エンドポイントから投げた例外(
@Responseの有無を問わない)、@Requiredの未指定(400)、@Securedと@Authzによる拒否(403) - 包まれない:ログインしていないアクセス(404)、URL が見つからない場合(404)、
Acceptの不正(406)、エンドポイントより手前で起きた例外(500)。HTML やプレーンテキストで返る
そのため、クライアントは次の順で判定します。
- ボディを JSON としてパースする。
@Responseを付けた例外は 200 以外でも包んで返るため、ステータスコードだけで打ち切らない - パースできて
errorがfalseなら、dataを業務データとして使う errorがtrueなら、errorMessageとdataをエラーの情報として扱う- パースに失敗した場合や
errorがない場合は、想定外のエラーとして扱う
関連ドキュメント
- アプリケーション層の実装ルール:エンドポイントが呼び出すユースケースの実装
- 全レイヤ縦断の実装例:エンドポイントから DB までの一式
- 全体アーキテクチャ:レイヤの責務、例外の階層、パッケージ構成
- アンチパターン集:エンドポイントから DAO を直接呼ぶなどの典型例