10-4. Spring Bootで書籍APIの土台とCRUDを作る
このセクションで学ぶこと
Section titled “このセクションで学ぶこと”- REST の考え方で書籍 API の URL・HTTP メソッド・ステータスコードを設計する方法
- Controller / Service / DTO を分ける理由
@Validとバリデーションアノテーションで不正な入力を早めに止める方法- 例外ハンドリングで
400/404/201/204を明確に返す方法 - HTTP ステータス、レスポンスボディ、ログを使って API の問題を切り分ける方法
10-3 までで、フロントエンド側では一覧表示・追加・更新・削除・入力チェックまでそろった。 ここからは、そのデータをブラウザ内だけで持つのではなく、Spring Boot の API として扱える形 へ広げていく。
ただし、いきなりデータベースへ進む前に、まずは メモリ上のデータ で CRUD の流れを完成させる。 これにより、REST 設計・DTO・バリデーション・例外処理を DB なしで先に理解できる。
このセクションの終わりでは、次の API が動く状態を目指す。
| 操作 | HTTP メソッド | URL | 代表的な成功コード |
|---|---|---|---|
| 一覧取得 | GET | /api/books | 200 OK |
| 1件取得 | GET | /api/books/{id} | 200 OK |
| 新規作成 | POST | /api/books | 201 Created |
| 状態更新 | PATCH | /api/books/{id} | 200 OK |
| 削除 | DELETE | /api/books/{id} | 204 No Content |
アプリの流れは次のようになる。
HTTPリクエスト(JSON) ↓Controller ↓ DTO で受け取るService ↓ Map<Long, Book> で保持するHTTPレスポンス(JSON)エラー時には次のような JSON を返す。
{ "message": "入力値を確認してください", "errors": { "title": "タイトルは必須です", "category": "カテゴリは必須です", "status": "状態は必須です" }}このセクションの進め方
Section titled “このセクションの進め方”10-4 は Java ファイルが一気に増えるので、1エンドポイントを追加するごとに curl で動作を確かめながら進める ほうが理解しやすい。
各実装段階の末尾に「ここで確認する」を置く。毎回、次の順で進めよう。
- 指定されたファイルを追加・修正する
- 保存する
- 1本目のターミナルでサーバーを再起動する(Ctrl+C →
./gradlew bootRun) - 2本目のターミナルで
curl -iを実行する - HTTP ステータスとレスポンスボディを確認する
見た目が変わらないステップもある。 その場合は「まだ HTTP の入口まで届いていない」「今はファイル配置だけ整えた段階だ」と説明できればよい。
1. なぜ REST で考えるのか
Section titled “1. なぜ REST で考えるのか”書籍管理 API を作るとき、「追加」「一覧」「削除」などの操作は決まっている。 そこで URL と HTTP メソッドに一貫したルールを持たせるのが REST の考え方である。
/api/books + GET → 一覧/api/books/1 + GET → 1件取得/api/books + POST → 新規作成/api/books/1 + PATCH → 状態更新/api/books/1 + DELETE → 削除こうしておく利点は次のとおりである。
- URL だけ見て「何を扱う API か」が分かる
- HTTP メソッドだけ見て「読み取りか更新か」が分かる
- フロントエンドやテストコードからも予測しやすい
REST は「おしゃれな流行語」ではなく、チームで API を読みやすくする共通ルール だと考えると実務に結び付きやすい。
2. Controller / Service / DTO を分ける
Section titled “2. Controller / Service / DTO を分ける”まず全体の責務を整理する。
Controller ├─ URL、HTTPメソッド、ステータスコードを扱う └─ Request/Response DTO を受け渡しする
Service ├─ 書籍を追加する ├─ 既存書籍の状態を更新する └─ 見つからない場合に例外を投げる
DTO ├─ APIで受け取る入力の形 └─ APIから返す出力の形これを1クラスに詰め込むと、HTTP の話と業務ルールの話が混ざって読みにくくなる。 分けておくと、「今どこを直すべきか」が明確になる。
2-1. どのフォルダに何を置くか
Section titled “2-1. どのフォルダに何を置くか”Spring Boot プロジェクトでは、src/main/java の下に package 名と同じ階層でフォルダを作る。
このセクションで出てくる package com.example.bookmanager... は、VS Code 上では次の配置になる。
bookmanager/├── build.gradle├── gradlew├── gradlew.bat├── settings.gradle└── src/ └── main/ ├── java/ │ └── com/ │ └── example/ │ └── bookmanager/ │ ├── controller/ │ │ └── BookController.java │ ├── dto/ │ │ ├── BookResponse.java │ │ ├── CreateBookRequest.java │ │ └── UpdateBookRequest.java │ ├── exception/ │ │ ├── BookNotFoundException.java │ │ └── GlobalExceptionHandler.java │ ├── model/ │ │ └── Book.java │ └── service/ │ └── BookService.java └── resources/ └── application.propertiesbuild.gradle や gradlew はプロジェクト直下、Java のソースコードは src/main/java/...、設定ファイルは src/main/resources/ に置く。
コード例の先頭にある package 行は、そのファイルが入るフォルダの住所 だと考えると迷いにくい。
| 役割 | 置き場所 | この章で作るファイル |
|---|---|---|
| DTO | src/main/java/com/example/bookmanager/dto/ | CreateBookRequest.java、UpdateBookRequest.java、BookResponse.java |
| Model | src/main/java/com/example/bookmanager/model/ | Book.java |
| Service | src/main/java/com/example/bookmanager/service/ | BookService.java |
| Controller | src/main/java/com/example/bookmanager/controller/ | BookController.java |
| Exception | src/main/java/com/example/bookmanager/exception/ | BookNotFoundException.java、GlobalExceptionHandler.java |
| 設定 | src/main/resources/ | application.properties(10-6 で追加) |
2-2. VS Code でファイルを作る手順
Section titled “2-2. VS Code でファイルを作る手順”- 10-0 で
book-app/bookmanager/を VS Code で開く - エクスプローラーで
src/main/java/com/example/bookmanager/を開く dto、model、service、controller、exceptionフォルダを作る- それぞれのフォルダの中に
.javaファイルを作る - 先頭の
package文とフォルダの位置が一致しているか確認する
たとえば package com.example.bookmanager.dto; と書くなら、ファイルは src/main/java/com/example/bookmanager/dto/ の下に置く。
フォルダがずれると import エラーやコンパイルエラーになりやすいので、「package 名 = フォルダの並び」 を必ず見比べる。
ここで確認する
Section titled “ここで確認する”- VS Code の Explorer で
dto、model、service、controller、exceptionフォルダがsrc/main/java/com/example/bookmanager/の下に並んでいることを確認する - これから作るファイルの置き場を、自分の言葉で説明できるか確かめる
3. 入出力を表す DTO を作る
Section titled “3. 入出力を表す DTO を作る”API では、内部で使う Book オブジェクトと、外へ見せる JSON の形を分けるほうが安全である。
ここでは DTO を record でシンプルに書く。
10-0 で https://start.spring.io/ から Gradle - Groovy を選び、Spring Web / Validation / Spring Data JPA / H2 Database を入れて生成していれば、build.gradle には次の依存関係が入っている。
確認する場所: Spring Initializr で生成した build.gradle の dependencies ブロックに以下が含まれていることを確認する(自動生成済み)。
dependencies { implementation 'org.springframework.boot:spring-boot-h2console' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-webmvc' runtimeOnly 'com.h2database:h2' testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' testImplementation 'org.springframework.boot:spring-boot-starter-validation-test' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher'}次に、リクエスト DTO を作る。
package com.example.bookmanager.dto;
import jakarta.validation.constraints.Min;import jakarta.validation.constraints.NotBlank;import jakarta.validation.constraints.NotNull;import jakarta.validation.constraints.Size;
public record CreateBookRequest( @NotBlank(message = "タイトルは必須です") String title,
@NotBlank(message = "著者名は必須です") String author,
@NotBlank(message = "カテゴリは必須です") String category,
@NotNull(message = "価格は必須です") @Min(value = 0, message = "価格は0以上で入力してください") Integer price,
@NotBlank(message = "状態は必須です") String status,
@Size(max = 120, message = "メモは120文字以内で入力してください") String memo) {}package com.example.bookmanager.dto;
import jakarta.validation.constraints.NotBlank;
public record UpdateBookRequest( @NotBlank(message = "状態は必須です") String status) {}今回は 10-3 のフロントエンド導線に合わせ、更新 API は書籍の状態だけを切り替える形にする。
レスポンス DTO も用意する。
package com.example.bookmanager.dto;
import com.example.bookmanager.model.Book;
public record BookResponse( Long id, String title, String author, String category, Integer price, String status, String memo) { public static BookResponse from(Book book) { return new BookResponse( book.getId(), book.getTitle(), book.getAuthor(), book.getCategory(), book.getPrice(), book.getStatus(), book.getMemo() ); }}DTO を分ける理由
Section titled “DTO を分ける理由”内部モデルをそのまま返すと、後でフィールドが増えたときに API まで意図せず変わる危険がある。 DTO は 外部との約束 を固定する役割を持つ。
ここで確認する
Section titled “ここで確認する”CreateBookRequest.java、UpdateBookRequest.java、BookResponse.javaの 3 ファイルがsrc/main/java/com/example/bookmanager/dto/の下にあることを確認する- 各ファイルの先頭の
package com.example.bookmanager.dto;と実際のフォルダ位置が一致しているか見直す - VS Code の Problems に import や package のエラーが出ていないことを確認する
4. 内部で使う Book モデルを作る
Section titled “4. 内部で使う Book モデルを作る”今回は DB をまだ使わないので、サービスの内部で扱う Book クラスを用意し、Map<Long, Book> で管理する。
package com.example.bookmanager.model;
public class Book { private Long id; private String title; private String author; private String category; private Integer price; private String status; private String memo;
public Book(Long id, String title, String author, String category, Integer price, String status, String memo) { this.id = id; this.title = title; this.author = author; this.category = category; this.price = price; this.status = status; this.memo = memo; }
public Long getId() { return id; }
public String getTitle() { return title; }
public String getAuthor() { return author; }
public String getCategory() { return category; }
public Integer getPrice() { return price; }
public String getStatus() { return status; }
public String getMemo() { return memo; }
public void updateStatus(String status) { this.status = status; }}Book はアプリ内部の状態を表し、DTO は HTTP の入出力を表す。
同じ「書籍」を扱っていても、役割が違うので分けて考える。
ここで確認する
Section titled “ここで確認する”src/main/java/com/example/bookmanager/model/Book.javaが作成されていることを確認する- getter と
updateStatus()がそろっているか見直す - まだ API は起動しなくてよい。今は Service が扱う内部モデルを用意した段階 である
5. 一覧取得を実装して最初に動かす(GET /api/books)
Section titled “5. 一覧取得を実装して最初に動かす(GET /api/books)”Service と Controller の最小構成を作り、最初の curl で動作を確かめる。
5-1. 例外クラスを作る
Section titled “5-1. 例外クラスを作る”まず「存在しない ID を問い合わせたとき」の例外を先に定義しておく。 後の Service でこの例外を使う。
package com.example.bookmanager.exception;
public class BookNotFoundException extends RuntimeException { public BookNotFoundException(Long id) { super("id=" + id + " の書籍は見つかりません"); }}この例外を HTTP レスポンスへ変換する
GlobalExceptionHandlerはセクション 10 で追加する。
5-2. Service(初期版)を作る
Section titled “5-2. Service(初期版)を作る”一覧取得と、存在しない ID への対応で使う getBookOrThrow を先に用意する。
package com.example.bookmanager.service;
import java.util.LinkedHashMap;import java.util.List;import java.util.Map;import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Service;
import com.example.bookmanager.dto.BookResponse;import com.example.bookmanager.dto.CreateBookRequest;import com.example.bookmanager.dto.UpdateBookRequest;import com.example.bookmanager.exception.BookNotFoundException;import com.example.bookmanager.model.Book;
@Servicepublic class BookService { private final Map<Long, Book> books = new LinkedHashMap<>(); private final AtomicLong sequence = new AtomicLong(2L);
public BookService() { books.put(1L, new Book(1L, "リーダブルコード", "Dustin Boswell", "バックエンド", 3200, "読書中", "第3章まで読了")); books.put(2L, new Book(2L, "ドメイン駆動設計入門", "Eric Evans", "設計", 5400, "未読", "週末に読み始める")); }
public List<BookResponse> findAll() { return books.values().stream() .map(BookResponse::from) .toList(); }
private Book getBookOrThrow(Long id) { Book book = books.get(id); if (book == null) { throw new BookNotFoundException(id); } return book; }}LinkedHashMap は追加順を保つので、一覧の表示順が安定しやすい。
sequence は後のステップで採番に使う。getBookOrThrow は ID で本を取り出し、見つからなければ例外を投げる共通処理である。
5-3. Controller(初期版)を作る
Section titled “5-3. Controller(初期版)を作る”package com.example.bookmanager.controller;
import java.util.List;
import org.springframework.web.bind.annotation.CrossOrigin;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;
import com.example.bookmanager.dto.BookResponse;import com.example.bookmanager.service.BookService;
@RestController@CrossOrigin(origins = "*")@RequestMapping("/api/books")public class BookController { private final BookService bookService;
public BookController(BookService bookService) { this.bookService = bookService; }
@GetMapping public List<BookResponse> listBooks() { return bookService.findAll(); }}@CrossOrigin(origins = "*") は 10-5 でブラウザから fetch できるようにするための設定である。
実務では許可するオリジンを絞るが、ここでは API 連携の流れを理解すること を優先する。
ここで確認する(初回起動)
Section titled “ここで確認する(初回起動)”- 1本目のターミナルで
./gradlew bootRunを実行する Tomcat started on port 8080とStarted ...Applicationが出るまで待つ- 2本目のターミナルで次を実行する
curl -i http://localhost:8080/api/books期待する結果:
HTTP/1.1 200[ {"id":1,"title":"リーダブルコード","author":"Dustin Boswell","category":"バックエンド","price":3200,"status":"読書中","memo":"第3章まで読了"}, {"id":2,"title":"ドメイン駆動設計入門","author":"Eric Evans","category":"設計","price":5400,"status":"未読","memo":"週末に読み始める"}]ここで初めて Controller と Service が HTTP でつながった ことが見える。
404 が返った場合
Section titled “404 が返った場合”HTTP/1.1 200 ではなく 404 が返ったときは、次の順に確認する。
1. サーバーのログに Started BookManagerApplication が出ているか
1本目のターミナルを確認する。Tomcat started on port 8080 より後に Started BookManagerApplication のような行が出ていれば起動済みである。出ていない場合は、その直前に ERROR がないかを確認する。
2. package 宣言とフォルダ構成が一致しているか
BookController.java の先頭行が package com.example.bookmanager.controller; になっているか、ファイルが src/main/java/com/example/bookmanager/controller/ の下にあるかを確認する。package 名とフォルダがずれていると Spring が Controller を見つけられず 404 になる。
正しい位置 src/main/java/com/example/bookmanager/controller/BookController.java 先頭: package com.example.bookmanager.controller;
ずれている例 src/main/java/com/example/BookController.java 先頭: package com.example.bookmanager.controller; ← フォルダと合っていない上記を確認・修正したらサーバーを再起動して curl を再実行する。
6. 1件取得を追加する(GET /api/books/{id})
Section titled “6. 1件取得を追加する(GET /api/books/{id})”6-1. Service に findById を追加する
Section titled “6-1. Service に findById を追加する”追加する場所: findAll() の後、getBookOrThrow() の前に追加する。
public List<BookResponse> findAll() { return books.values().stream() .map(BookResponse::from) .toList(); }
public BookResponse findById(Long id) { return BookResponse.from(getBookOrThrow(id)); }
private Book getBookOrThrow(Long id) {6-2. Controller に getBook を追加する
Section titled “6-2. Controller に getBook を追加する”追加する import: ファイル先頭の import ブロックに追加する。
import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import org.springframework.web.bind.annotation.PathVariable;追加するメソッド: listBooks() の後、クラスの閉じカッコ前に追加する。
@GetMapping public List<BookResponse> listBooks() { return bookService.findAll(); }
@GetMapping("/{id}") public BookResponse getBook(@PathVariable Long id) { return bookService.findById(id); }}ここで確認する
Section titled “ここで確認する”サーバーを止めて再起動(Ctrl+C → ./gradlew bootRun)してから確認する。
curl -i http://localhost:8080/api/books/1期待する結果:
HTTP/1.1 200{"id":1,"title":"リーダブルコード","author":"Dustin Boswell","category":"バックエンド","price":3200,"status":"読書中","memo":"第3章まで読了"}ID 1 の本だけが返ることを確認する。
パラメータを2に変えて、ID 2 の本も返ることを確かめよう。
7. 新規作成を追加する(POST /api/books)
Section titled “7. 新規作成を追加する(POST /api/books)”7-1. Service に create を追加する
Section titled “7-1. Service に create を追加する”追加する場所: findById() の後に追加する。
public BookResponse findById(Long id) { return BookResponse.from(getBookOrThrow(id)); }
public BookResponse create(CreateBookRequest request) { Long id = sequence.incrementAndGet(); Book book = new Book(id, request.title(), request.author(), request.category(), request.price(), request.status(), request.memo()); books.put(id, book); return BookResponse.from(book); }
private Book getBookOrThrow(Long id) {7-2. Controller に createBook を追加する
Section titled “7-2. Controller に createBook を追加する”追加する import:
import org.springframework.web.bind.annotation.RestController;import org.springframework.web.bind.annotation.PathVariable;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import jakarta.validation.Valid;また CreateBookRequest も import に追加する。
import com.example.bookmanager.dto.CreateBookRequest;追加するメソッド: getBook() の後に追加する。
@GetMapping("/{id}") public BookResponse getBook(@PathVariable Long id) { return bookService.findById(id); }
@PostMapping public ResponseEntity<BookResponse> createBook(@Valid @RequestBody CreateBookRequest request) { BookResponse response = bookService.create(request); return ResponseEntity.status(HttpStatus.CREATED).body(response); }}@Valid で DTO のバリデーションが有効になる。201 Created は「新規作成に成功した」ことを表すステータスである。
ここで確認する
Section titled “ここで確認する”サーバーを再起動してから確認する。
curl -i -X POST http://localhost:8080/api/books \ -H "Content-Type: application/json" \ -d '{"title":"実践Java設計","author":"研修 花子","category":"バックエンド","price":2800,"status":"未読","memo":"演習用サンプル"}'期待する結果:
HTTP/1.1 201{"id":3,"title":"実践Java設計","author":"研修 花子","category":"バックエンド","price":2800,"status":"未読","memo":"演習用サンプル"}レスポンスに id が含まれ、201 が返ることを確認する。続けて一覧取得を実行し、登録した本が含まれていることも確かめよう。
curl -i http://localhost:8080/api/books8. 状態更新を追加する(PATCH /api/books/{id})
Section titled “8. 状態更新を追加する(PATCH /api/books/{id})”8-1. Service に update を追加する
Section titled “8-1. Service に update を追加する”追加する場所: create() の後に追加する。
public BookResponse create(CreateBookRequest request) { Long id = sequence.incrementAndGet(); Book book = new Book(id, request.title(), request.author(), request.category(), request.price(), request.status(), request.memo()); books.put(id, book); return BookResponse.from(book); }
public BookResponse update(Long id, UpdateBookRequest request) { Book book = getBookOrThrow(id); book.updateStatus(request.status()); return BookResponse.from(book); }
private Book getBookOrThrow(Long id) {8-2. Controller に updateBookStatus を追加する
Section titled “8-2. Controller に updateBookStatus を追加する”追加する import:
import org.springframework.web.bind.annotation.PatchMapping;import com.example.bookmanager.dto.UpdateBookRequest;追加するメソッド: createBook() の後に追加する。
@PostMapping public ResponseEntity<BookResponse> createBook(@Valid @RequestBody CreateBookRequest request) { BookResponse response = bookService.create(request); return ResponseEntity.status(HttpStatus.CREATED).body(response); }
@PatchMapping("/{id}") public BookResponse updateBookStatus(@PathVariable Long id, @Valid @RequestBody UpdateBookRequest request) { return bookService.update(id, request); }}ここで確認する
Section titled “ここで確認する”サーバーを再起動してから確認する。
curl -i -X PATCH http://localhost:8080/api/books/1 \ -H "Content-Type: application/json" \ -d '{"status":"読了"}'期待する結果:
HTTP/1.1 200{"id":1,"title":"リーダブルコード","author":"Dustin Boswell","category":"バックエンド","price":3200,"status":"読了","memo":"第3章まで読了"}status が 読了 へ変わっていることを確認する。
9. 削除を追加する(DELETE /api/books/{id})
Section titled “9. 削除を追加する(DELETE /api/books/{id})”9-1. Service に delete を追加する
Section titled “9-1. Service に delete を追加する”追加する場所: update() の後、getBookOrThrow() の前に追加する。
public BookResponse update(Long id, UpdateBookRequest request) { Book book = getBookOrThrow(id); book.updateStatus(request.status()); return BookResponse.from(book); }
public void delete(Long id) { Book removed = books.remove(id); if (removed == null) { throw new BookNotFoundException(id); } }
private Book getBookOrThrow(Long id) {9-2. Controller に deleteBook を追加する
Section titled “9-2. Controller に deleteBook を追加する”追加する import:
import org.springframework.web.bind.annotation.DeleteMapping;追加するメソッド: updateBookStatus() の後に追加する。
@PatchMapping("/{id}") public BookResponse updateBookStatus(@PathVariable Long id, @Valid @RequestBody UpdateBookRequest request) { return bookService.update(id, request); }
@DeleteMapping("/{id}") public ResponseEntity<Void> deleteBook(@PathVariable Long id) { bookService.delete(id); return ResponseEntity.noContent().build(); }}204 No Content は「削除に成功し、返す本文はない」ことを表すステータスである。
ここで確認する
Section titled “ここで確認する”サーバーを再起動してから確認する。
curl -i -X DELETE http://localhost:8080/api/books/2期待する結果:
HTTP/1.1 204本文が返らないことを確認する。続けて一覧取得を行い、id=2 の本が含まれていないことも確かめよう。
curl -i http://localhost:8080/api/books10. 例外を HTTP レスポンスへ変換する
Section titled “10. 例外を HTTP レスポンスへ変換する”ここまでの実装では、存在しない ID や不正入力を渡すと 500 が返ってしまう。
GlobalExceptionHandler を追加し、例外を意味のある HTTP ステータスへ変換する。
package com.example.bookmanager.exception;
import java.util.LinkedHashMap;import java.util.Map;
import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.MethodArgumentNotValidException;import org.springframework.web.bind.annotation.ExceptionHandler;import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvicepublic class GlobalExceptionHandler {
@ExceptionHandler(BookNotFoundException.class) public ResponseEntity<Map<String, String>> handleBookNotFound(BookNotFoundException ex) { return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(Map.of("message", ex.getMessage())); }
@ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) { Map<String, String> errors = new LinkedHashMap<>(); ex.getBindingResult().getFieldErrors() .forEach(error -> errors.put(error.getField(), error.getDefaultMessage()));
Map<String, Object> body = new LinkedHashMap<>(); body.put("message", "入力値を確認してください"); body.put("errors", errors); return ResponseEntity.badRequest().body(body); }}これにより、
- 存在しない ID →
404 Not Found - 入力値不正 →
400 Bad Request
と意味のある応答が返せるようになる。
ここで確認する
Section titled “ここで確認する”サーバーを再起動してから、2本ずつ試す。
存在しない ID の確認:
curl -i http://localhost:8080/api/books/999HTTP/1.1 404{"message":"id=999 の書籍は見つかりません"}バリデーションエラーの確認:
curl -i -X POST http://localhost:8080/api/books \ -H "Content-Type: application/json" \ -d '{"title":"","author":"","category":"","price":-1,"status":""}'HTTP/1.1 400{ "message": "入力値を確認してください", "errors": { "title": "タイトルは必須です", "author": "著者名は必須です", "category": "カテゴリは必須です", "price": "価格は0以上で入力してください", "status": "状態は必須です" }}message だけでなく、どの項目が悪いかを errors で返せていることを確認する。
11. なぜ今 DTO とバリデーションを入れるのか
Section titled “11. なぜ今 DTO とバリデーションを入れるのか”「まだ DB もないのに DTO や @Valid は早すぎないか」と感じるかもしれない。
しかし実際は逆で、早い段階で入れておくほうが後の変更に強い。
入力の形を固定する ↓Controller と Service の責務が分かれる ↓DB を入れても API の契約を保ちやすい10-6 で JPA に切り替えるときも、Controller の DTO 契約が保たれていれば、変更範囲を主に Service と Repository 側へ閉じ込めやすい。
よくある失敗
Section titled “よくある失敗”| 失敗 | 起きやすい原因 | どう確認するか |
|---|---|---|
@Valid を付けたのに無効 | spring-boot-starter-validation を追加していない | build.gradle を確認する |
400 ではなく 500 になる | GlobalExceptionHandler がない、または別の例外が飛んでいる | ログと @RestControllerAdvice を確認する |
POST で値が入らない | @RequestBody を付け忘れた | Controller の引数を確認する |
404 になる | パス変数や URL がずれている | /api/books/{id} と実際のリクエストを見比べる |
DELETE 後に本文が返る | 204 No Content の意味を誤解している | ResponseEntity.noContent() を使う |
| DTO と内部モデルが混ざる | Controller から Book を直接返している | BookResponse を返す形に戻す |
REST API の不具合は、次の順で切り分けると落ち着いて追いやすい。
1. まず HTTP ステータスを見る
Section titled “1. まず HTTP ステータスを見る”200台 → 成功400台 → リクエスト側の問題500台 → サーバー側の問題これだけで「入力を見直すべきか」「コードを直すべきか」の方向が見える。
2. レスポンス本文のメッセージを読む
Section titled “2. レスポンス本文のメッセージを読む”バリデーションエラーなら、どのフィールドが問題かを本文で返す。 画面やテストが失敗したときも、この本文があると原因を特定しやすい。
3. アプリケーションログを見る
Section titled “3. アプリケーションログを見る”500 が出たら、サーバー側のスタックトレースを確認する。
最初に出てくる例外名と、自分のクラス名が出てくる行を追う。
4. curl -i で再現する
Section titled “4. curl -i で再現する”ブラウザ経由だと UI の問題と混ざる。
curl -i なら、純粋に HTTP の入出力だけを確認できる。
5. 期待する入出力を表にする
Section titled “5. 期待する入出力を表にする”| 操作 | 入力 | 期待する結果 |
|---|---|---|
| 新規作成 | 正しい JSON | 201 と作成データ |
| 新規作成 | タイトル空 | 400 とエラー詳細 |
| 1件取得 | 存在しない ID | 404 |
こうして期待値を明文化すると、テストも書きやすくなる。 これは 10-7 のテストと切り分けにも直結する考え方である。
| キーワード | 説明 |
|---|---|
| REST | URL と HTTP メソッドに一貫した意味を持たせる API 設計 |
| DTO | API の入力・出力の形を表す専用の型 |
| Controller | HTTP とアプリ処理をつなぐ入口 |
| Service | 業務ルールや CRUD の中心処理 |
@Valid | DTO の入力チェックを有効にするアノテーション |
201 Created | 新規作成成功を表すステータス |
204 No Content | 削除成功で本文がないことを表すステータス |
400 Bad Request | 入力不正 |
404 Not Found | 指定した資源が見つからない |
@RestControllerAdvice | 例外を HTTP レスポンスへ変換する仕組み |
次のステップ
Section titled “次のステップ”まずは 演習問題 に取り組み、REST の設計・DTO・バリデーション・ステータスコードの対応を整理しよう。
その後は 10-5. fetchでフロントエンドとAPIを接続する へ進み、今作った API をブラウザの画面から呼べるようにしよう。