3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

API設計 - REST APIのエラーレスポンス設計

3
Posted at

はじめに

APIのエラーレスポンスを設計するとき、HTTPステータスコードとmessageだけ返せば十分だと思っていませんか。
シンプルな設計に見えますが、フロントエンドの実装が増えるにつれて「このエラーはどう処理すればいいか」という問題が表面化します。
本記事ではmessageだけのエラーレスポンスが抱える問題と、codeフィールドを追加することで何が解決するかを解説します。

messageだけ返す設計の問題

よくある設計です。

{
  "status": 409,
  "message": "在庫不足: 商品[シャツA] 在庫=3, 注文数=5"
}

フロントエンドがこのエラーを受け取ったとき、「在庫不足の場合は特定のUIを表示したい」という要件があると困ります。messageの文字列を解析して判断するしかないからです。

// ❌ message の文字列に依存した分岐
if (error.message.includes('在庫不足')) {
  showStockErrorUI();
}

この実装には2つのリスクがあります。
1つ目は、サーバ側でメッセージの文言を変更したとたんにフロントの分岐が壊れることです。
2つ目は、多言語対応でメッセージが日本語・英語で切り替わる場合、文字列比較が完全に機能しなくなることです。

codeフィールドを追加する

エラーの種別を表すcodeフィールドを追加します。

{
  "code": "INSUFFICIENT_STOCK",
  "status": 409,
  "message": "在庫不足: 商品[シャツA] 在庫=3, 注文数=5"
}

codeは変更しない安定した識別子です。フロントエンドはcodeを根拠に分岐します。

// ✅ code を根拠にした分岐
if (error.code === 'INSUFFICIENT_STOCK') {
  showStockErrorUI();
}

messageは人間向けの説明文として扱います。多言語化や文言変更が自由にできます。codeさえ変えなければフロントの実装は壊れません。

detailsフィールドの設計

バリデーションエラーのように、フィールドごとにエラー内容を伝えたい場合はdetailsを追加します。

{
  "code": "VALIDATION_ERROR",
  "status": 400,
  "message": "バリデーションエラー",
  "details": {
    "name": "商品名は必須です",
    "price": "価格は0より大きい必要があります"
  }
}

detailsはバリデーションエラー専用フィールドです。
それ以外のエラーではdetailsフィールドごとJSONから省略します。nullを返すと「このフィールドは常に存在する」という誤解を与えるため、@JsonInclude(NON_NULL)で存在ごと消します。

// exception/ErrorResponse.java
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ErrorResponse(
    String code,
    int status,
    String message,
    Map<String, String> details
) {
    public ErrorResponse(String code, int status, String message) {
        this(code, status, message, null);
    }
}

エラーコード一覧を管理する

codeの値はプロジェクト内で一覧管理します。命名はSCREAMING_SNAKE_CASEで統一します。

code HTTPステータス 発生ケース
VALIDATION_ERROR 400 リクエストボディのバリデーション失敗
BAD_REQUEST 400 パスパラメータの型不正など
UNAUTHORIZED 401 未認証
FORBIDDEN 403 権限不足
NOT_FOUND 404 リソースが存在しない
CONFLICT 409 楽観ロック競合
INSUFFICIENT_STOCK 409 在庫不足
TOO_MANY_REQUESTS 429 レートリミット超過
INTERNAL_SERVER_ERROR 500 予期しないサーバエラー

GlobalExceptionHandlerでの実装

Spring Bootでは@RestControllerAdviceを使って例外を一元処理します。

@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {

    private final MessageHelper messageHelper;

    // ビジネス例外を一本で処理する
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusiness(BusinessException ex) {
        log.warn("{}: {}", ex.getClass().getSimpleName(), ex.getMessage());
        return buildError(ex.getStatus(), ex.getCode(), ex.getMessage());
    }

    // バリデーションエラー
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> details = ex.getBindingResult().getFieldErrors().stream()
            .collect(Collectors.toMap(
                FieldError::getField,
                FieldError::getDefaultMessage,
                (existing, duplicate) -> existing));
        return buildError(HttpStatus.BAD_REQUEST, "VALIDATION_ERROR",
            messageHelper.get("error.validation"), details);
    }

    // 予期しない例外(詳細は隠してログに残す)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
        log.error("Unexpected exception occurred", ex);
        return buildError(HttpStatus.INTERNAL_SERVER_ERROR,
            "INTERNAL_SERVER_ERROR", messageHelper.get("error.system"));
    }

    private ResponseEntity<ErrorResponse> buildError(
            HttpStatus status, String code, String message) {
        return buildError(status, code, message, null);
    }

    private ResponseEntity<ErrorResponse> buildError(
            HttpStatus status, String code, String message,
            Map<String, String> details) {
        return ResponseEntity.status(status)
            .body(new ErrorResponse(code, status.value(), message, details));
    }
}

Spring Securityの401/403は別途設定が必要

Spring Securityの認証・認可エラーはFilterレベルで処理されるため、@RestControllerAdviceには届きません。
SecurityConfigのexceptionHandling()で直接レスポンスを書く必要があります。このときErrorResponseと同じJSON構造を維持することが重要です。

.exceptionHandling(ex -> ex
    .authenticationEntryPoint((request, response, e) -> {
        response.setStatus(401);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(
            """
            {"code":"UNAUTHORIZED","status":401,"message":"認証が必要です"}
            """);
    })
    .accessDeniedHandler((request, response, e) -> {
        response.setStatus(403);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(
            """
            {"code":"FORBIDDEN","status":403,"message":"権限がありません"}
            """);
    })
)

まとめ

  • messageの文字列比較でエラー分岐するのは文言変更・多言語化で壊れる
  • codeフィールドが安定した機械判別用の識別子として機能する
  • detailsはバリデーションエラー専用。それ以外では@JsonInclude(NON_NULL)でフィールドごと省略する
  • timestampはレスポンスに含めない
  • Spring Securityの401/403はSecurityConfigErrorResponseと同じ構造で返す
3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?