- JAVA
- 技術 💻
Spring BootでREST APIを作る|入力チェック・エラー処理・テストまで
Spring Bootは、JavaでWebアプリやREST APIを作るためのフレームワークです。@RestController を付けたクラスにメソッドを書くだけで、JSONを返すAPIが動きます。
ただ、「動くAPI」と「実務で使えるAPI」の間には、入力チェック、エラーの返し方、テストという大きな差があります。入門記事の多くはCRUDの基本で終わりますが、この記事では、注文APIを例に、入力チェックと共通のエラー処理(RFC 9457形式)、MockMvcでのテストまで、実務で必要な形を一通り解説します。バージョンは、現在の主流であるSpring Boot 4系(Java 17以上)を前提にしています。
この記事で作るAPI
| メソッド | URL | 内容 | 成功時 |
|---|---|---|---|
| GET | /api/orders |
注文の一覧 | 200 OK |
| GET | /api/orders/{id} |
注文を1件取得 | 200 OK(ないときは404) |
| POST | /api/orders |
注文を作成 | 201 Created + Locationヘッダー |
| DELETE | /api/orders/{id} |
注文を削除 | 204 No Content |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Spring BootでREST APIを作る|入力チェック・エラー処理・テストまで」
プロジェクトの作り方
Spring Initializr(start.spring.io)で、次の依存関係を選んでプロジェクトを作ります。
| 依存関係 | 役割 |
|---|---|
Spring Web MVC(spring-boot-starter-webmvc) |
REST APIを作る |
Validation(spring-boot-starter-validation) |
入力チェック |
Spring Web MVC Test(spring-boot-starter-webmvc-test) |
MockMvcでのテスト |
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Spring Boot 4では、Webの依存関係の名前が spring-boot-starter-web から spring-boot-starter-webmvc に変わりました。入力チェックも、spring-boot-starter-validation を明示的に追加する必要があります。
クラスの役割分担
com.example.order
├── OrderController.java # HTTPの受け口(URLとメソッドの対応)
├── OrderService.java # 業務のロジック
├── OrderRepository.java # データの保存・取得
├── Order.java # 注文のデータ
├── CreateOrderRequest.java # 作成リクエストの形と入力チェック
├── OrderNotFoundException.java
└── ApiExceptionHandler.java # 共通のエラー処理
| 層 | 役割 | 書かないこと |
|---|---|---|
| Controller | リクエストを受け取り、Serviceを呼び、レスポンスを返す | 業務のロジック |
| Service | 業務のルール(在庫の確認、状態の変更など) | HTTPの知識(ステータスコードなど) |
| Repository | データベースとのやり取り | 業務のルール |
Controllerを薄く保つのがポイントです。業務のロジックがServiceにまとまっていると、テストや再利用がしやすくなります。
データの定義:recordを使う
public record Order(long id, String item, int quantity, String status) {}
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateOrderRequest(
@NotBlank(message = "商品名は必須です")
@Size(max = 100, message = "商品名は100文字以内で入力してください")
String item,
@Min(value = 1, message = "数量は1以上で指定してください")
@Max(value = 99, message = "数量は99以下で指定してください")
int quantity
) {}
record を使うと、フィールド・コンストラクター・getterなどが自動で作られ、リクエストやレスポンスの定義が短く書けます。入力チェックのルールは、アノテーションで項目ごとに付けます。
| アノテーション | チェック内容 |
|---|---|
@NotBlank |
null・空文字・空白だけを禁止(文字列) |
@NotNull |
nullを禁止 |
@Size(max = 100) |
文字数・要素数の範囲 |
@Min / @Max |
数値の範囲 |
@Email |
メールアドレスの形式 |
@Pattern(regexp = ...) |
正規表現に一致するか |
Repository と Service
この記事では、APIの作り方に集中するため、メモリ上に保存する簡単なRepositoryを使います。データベースを使う場合は、Spring Data JPAなどに置き換えます。
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Repository;
@Repository
public class OrderRepository {
private final Map<Long, Order> store = new ConcurrentHashMap<>();
private final AtomicLong seq = new AtomicLong();
public Order save(String item, int quantity) {
long id = seq.incrementAndGet();
Order order = new Order(id, item, quantity, "pending");
store.put(id, order);
return order;
}
public Optional<Order> findById(long id) { return Optional.ofNullable(store.get(id)); }
public List<Order> findAll() { return List.copyOf(store.values()); }
public boolean delete(long id) { return store.remove(id) != null; }
}
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(long id) {
super("注文が見つかりません: id=" + id);
}
}
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) { // コンストラクターインジェクション
this.repository = repository;
}
public Order create(CreateOrderRequest request) {
return repository.save(request.item(), request.quantity());
}
public Order get(long id) {
return repository.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
}
public List<Order> list() { return repository.findAll(); }
public void delete(long id) {
if (!repository.delete(id)) throw new OrderNotFoundException(id);
}
}
依存するクラスは、コンストラクターで受け取る(コンストラクターインジェクション)のが推奨の書き方です。フィールドに @Autowired を付ける書き方より、テストで差し替えやすく、必須の依存関係が明確になります。
Controller
import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService service;
public OrderController(OrderService service) {
this.service = service;
}
@GetMapping
public List<Order> list() {
return service.list();
}
@GetMapping("/{id}")
public Order get(@PathVariable long id) {
return service.get(id);
}
@PostMapping
public ResponseEntity<Order> create(@Valid @RequestBody CreateOrderRequest request) {
Order created = service.create(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(created); // 201 + Location
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable long id) {
service.delete(id);
return ResponseEntity.noContent().build(); // 204
}
}
| ポイント | 内容 |
|---|---|
@RestController |
戻り値をJSONに変換して返す |
@Valid @RequestBody |
リクエストのJSONを受け取り、入力チェックを実行する |
ResponseEntity.created(...) |
201 Created と Locationヘッダーを返す |
ResponseEntity.noContent() |
204 No Content を返す |
@Valid を付け忘れると、入力チェックのアノテーションを書いていても実行されません。よくある見落としの1つです。
共通のエラー処理:ProblemDetailで返す
エラーのレスポンスは、アプリ全体で形を揃えます。Springには、RFC 9457(Problem Details for HTTP APIs)の形式でエラーを返すための ProblemDetail クラスがあります。
import java.util.List;
import java.util.Map;
import org.springframework.http.*;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
// 404:注文が見つからない
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException e) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
pd.setTitle("Not Found");
return pd;
}
// 400:入力チェックのエラー(項目ごとのメッセージを追加)
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
ProblemDetail pd = ex.getBody();
pd.setDetail("入力内容に誤りがあります");
List<Map<String, String>> errors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> Map.of("field", fe.getField(),
"message", String.valueOf(fe.getDefaultMessage())))
.toList();
pd.setProperty("errors", errors);
return ResponseEntity.status(status).headers(headers).body(pd);
}
}
入力チェックでエラーになったときのレスポンスは、次のような形になります。
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "入力内容に誤りがあります",
"errors": [
{ "field": "item", "message": "商品名は必須です" },
{ "field": "quantity", "message": "数量は1以上で指定してください" }
]
}
| 仕組み | 役割 |
|---|---|
@RestControllerAdvice |
すべてのControllerに共通の例外処理を1か所にまとめる |
@ExceptionHandler |
特定の例外が起きたときのレスポンスを決める |
ResponseEntityExceptionHandler |
Spring標準の例外(入力チェック、JSONの形式エラー、未対応のメソッドなど)をProblemDetailの形で返す |
ProblemDetail |
RFC 9457の形式のエラーを表すクラス。独自の項目も追加できる |
Controllerの中で try-catch を書かずに、例外を投げて共通のハンドラーで受け止める形にすると、各メソッドが正常系の処理だけに集中できます。
MockMvcでテストする
// Spring Boot 4 の各アノテーションの import は、IDEの補完で追加してください
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
MockMvc mockMvc;
@MockitoBean
OrderService service;
@Test
void 注文を作成すると201とLocationが返る() throws Exception {
when(service.create(any())).thenReturn(new Order(1L, "マウス", 2, "pending"));
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"item": "マウス", "quantity": 2}
"""))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "http://localhost/api/orders/1"))
.andExpect(jsonPath("$.item").value("マウス"));
}
@Test
void 数量が0だと400と項目ごとのエラーが返る() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"item": "マウス", "quantity": 0}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[0].field").value("quantity"));
}
@Test
void 存在しない注文は404が返る() throws Exception {
when(service.get(999L)).thenThrow(new OrderNotFoundException(999L));
mockMvc.perform(get("/api/orders/999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.detail").value("注文が見つかりません: id=999"));
}
}
| 仕組み | 役割 |
|---|---|
@WebMvcTest |
Web層(Controllerと例外ハンドラー)だけを起動する。起動が速い |
MockMvc |
サーバーを起動せずに、HTTPリクエストを模擬して送る |
@MockitoBean |
Serviceをモックに差し替える(Spring Boot 4では @MockBean は削除され、こちらを使う) |
jsonPath |
レスポンスのJSONの中身を確認する |
正常系だけでなく、入力エラー(400)と存在しないデータ(404)のテストも書くと、エラー処理の変更で動きが変わったときにすぐ気づけます。
Spring Boot 3から4での主な変更点
| 項目 | Spring Boot 3 | Spring Boot 4 |
|---|---|---|
| Webのstarter | spring-boot-starter-web |
spring-boot-starter-webmvc |
| テストのstarter | spring-boot-starter-test |
機能ごとのstarter(spring-boot-starter-webmvc-test など) |
| Beanのモック | @MockBean |
@MockitoBean(@MockBean は削除) |
| JSONライブラリ | Jackson 2(com.fasterxml.jackson) |
Jackson 3(tools.jackson) |
| Javaの最低バージョン | 17 | 17 |
既存のプロジェクトを移行するときは、公式の移行ガイドで対象の項目を確認しながら進めます。
よくある質問
Q. @RestController と @Controller の違いは何ですか?
A. @RestController は、@Controller に @ResponseBody を組み合わせたものです。戻り値をJSONなどのレスポンスの本文として返します。画面(HTML)を返す場合は @Controller を使います。
Q. Entity(データベースのクラス)をそのままレスポンスにしてもいいですか? A. 小さなアプリでは問題ないこともありますが、レスポンス用のクラス(DTO)を分けるのがおすすめです。データベースの項目を変えたときにAPIの形が意図せず変わったり、公開すべきでない項目が出てしまったりするのを防げます。
Q. APIの仕様書はどう作ればいいですか? A. springdoc-openapi を使うと、Controllerのコードから OpenAPI の仕様書と Swagger UI を自動で生成できます。仕様書を先に書いてからコードを生成する方法もあります。
まとめ
- Spring Boot 4では、
spring-boot-starter-webmvcとspring-boot-starter-validationを使う - Controller・Service・Repositoryに役割を分け、Controllerは薄く保つ
- リクエストは record+入力チェックのアノテーションで定義し、
@Validを付ける - 作成は 201+Location、削除は 204 で返す
- エラーは
@RestControllerAdvice+ProblemDetailで、RFC 9457の形に揃える - MockMvc+@MockitoBean で、正常系と異常系の両方をテストする
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Spring BootでREST APIを作る|入力チェック・エラー処理・テストまで」
株式会社RJCでは、JavaとSpring Bootを使った開発を、研修から実際のプロジェクトまで、仲間と一緒に学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。