株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。
Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
▶コーポレートサイト
はじめに
前回(第1回)では、Spring Bootの概要、環境構築、そして@RestController + @GetMappingでHello Worldを作りました。@PathVariableや@RequestParamにも軽く触れました。
第2回では、コントローラとルーティングを深掘りします。
今回学ぶこと
-
@Controllerと@RestControllerの違い - HTTPメソッドに対応するマッピングアノテーション(
@PostMapping、@PutMappingなど) - パスの設計(ベースパス、パスパラメータ、クエリパラメータの詳細)
-
@RequestBodyによるJSONリクエストの受け取り -
ResponseEntityによるレスポンスのカスタマイズ - 実践例として簡易TODO APIを構築
本記事のコードはすべて第1回で作成したhello-springプロジェクト(com.example.hellospringパッケージ)上で動作します。環境構築がまだの方は第1回を先にご覧ください。
1. @Controller と @RestController の違い
第1回では@RestControllerだけを使いましたが、Spring Bootにはもう1つ、@Controllerというアノテーションがあります。
@Controller ― ビュー(画面)を返す
@Controllerを付けたクラスのメソッドは、戻り値を**テンプレート名(ビュー名)**として解釈します。Thymeleafなどのテンプレートエンジンがそのビュー名に対応するHTMLを描画し、レスポンスとして返します。
package com.example.hellospring;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class PageController {
@GetMapping("/welcome")
public String welcome(Model model) {
model.addAttribute("message", "ようこそ!");
return "welcome"; // templates/welcome.html を返す
}
}
この場合、return "welcome" はsrc/main/resources/templates/welcome.htmlを探して描画するという意味です。
Servlet/JSPで言えば、以下のコードに相当します。
// Servlet/JSP版
request.setAttribute("message", "ようこそ!");
request.getRequestDispatcher("/WEB-INF/views/welcome.jsp").forward(request, response);
@RestController ― データ(JSON / テキスト)を返す
@RestControllerを付けたクラスのメソッドは、戻り値をHTTPレスポンスのボディそのものとして返します。テンプレートエンジンは介在しません。
package com.example.hellospring;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ApiController {
@GetMapping("/api/message")
public String message() {
return "これがレスポンスボディです";
}
}
両者の関係
@RestControllerは、@Controller + @ResponseBodyを合わせたものです。
// この2つは同じ意味
@RestController
public class MyController { ... }
@Controller
@ResponseBody
public class MyController { ... }
使い分けの基準
| やりたいこと | 使うアノテーション |
|---|---|
| HTML画面を返したい(Thymeleaf等) | @Controller |
| JSON / テキストデータを返したい(REST API) | @RestController |
本記事ではREST APIの構築に焦点を当てるため、以降は@RestControllerを使います。@ControllerとThymeleafについては第3回で詳しく扱います。
2. HTTPメソッドとマッピングアノテーション
REST APIでは、HTTPメソッド(GET / POST / PUT / DELETE / PATCH)を使い分けて、リソースに対する操作を表現します。Spring Bootでは、各HTTPメソッドに対応するアノテーションが用意されています。
アノテーション一覧
| アノテーション | HTTPメソッド | 用途 | Servletの対応メソッド |
|---|---|---|---|
@GetMapping |
GET | リソースの取得 | doGet() |
@PostMapping |
POST | リソースの作成 | doPost() |
@PutMapping |
PUT | リソースの全体更新 | doPut() |
@DeleteMapping |
DELETE | リソースの削除 | doDelete() |
@PatchMapping |
PATCH | リソースの部分更新 | ― |
@RequestMapping |
全メソッド | 汎用(method属性で指定) | service() |
コード例
package com.example.hellospring;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/items")
public class ItemController {
@GetMapping
public String getAll() {
return "全件取得";
}
@GetMapping("/{id}")
public String getById(@PathVariable int id) {
return "取得: ID=" + id;
}
@PostMapping
public String create() {
return "作成";
}
@PutMapping("/{id}")
public String update(@PathVariable int id) {
return "更新: ID=" + id;
}
@DeleteMapping("/{id}")
public String delete(@PathVariable int id) {
return "削除: ID=" + id;
}
}
@RequestMapping ― 汎用アノテーション
@GetMapping等は@RequestMappingのショートカットです。以下は同じ意味です。
// ショートカット版
@GetMapping("/items")
public String getItems() { ... }
// @RequestMapping版
@RequestMapping(value = "/items", method = RequestMethod.GET)
public String getItems() { ... }
@RequestMappingを使う場面は、同じパスで複数のHTTPメソッドを処理したい場合などに限られます。通常は@GetMapping等のショートカットを使う方が意図が明確になるため推奨されます。
Servlet/JSPとの対比
Servlet/JSPでは、1つのServletクラスにdoGet()とdoPost()を両方書き、メソッドごとに処理を分けていました。
// Servlet版: 1クラスにGETとPOSTを同居
public class ItemServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) { ... }
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) { ... }
}
Spring Bootでは、メソッドごとにアノテーションで明示的にマッピングするため、コードの可読性が向上します。
3. パスの設計(URLルーティング)
クラスレベルとメソッドレベルのパス
@RequestMappingをクラスに付けると、そのクラスの全メソッドにベースパスが適用されます。
@RestController
@RequestMapping("/api/users") // ベースパス
public class UserController {
@GetMapping // → GET /api/users
public String getAll() { ... }
@GetMapping("/{id}") // → GET /api/users/{id}
public String getById(@PathVariable int id) { ... }
@PostMapping // → POST /api/users
public String create() { ... }
}
クラスレベルの/api/usersとメソッドレベルのパスが結合されます。
@PathVariable の詳細
第1回で基本を学んだ@PathVariableについて、詳細を見ていきます。
複数のパスパラメータ
パスに複数のパラメータを含めることができます。
package com.example.hellospring;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class OrderController {
@GetMapping("/users/{userId}/orders/{orderId}")
public String getOrder(@PathVariable int userId,
@PathVariable int orderId) {
return "ユーザーID: " + userId + " の注文ID: " + orderId;
}
}
http://localhost:8080/users/5/orders/42 にアクセスすると:
ユーザーID: 5 の注文ID: 42
名前の明示的指定
メソッドの引数名とパス変数名が異なる場合、@PathVariable("パス変数名")で明示します。
※以下はメソッド部分のみ示します。
@GetMapping("/products/{product-id}")
public String getProduct(@PathVariable("product-id") int productId) {
return "商品ID: " + productId;
}
パス中にハイフンが含まれる場合、Javaの変数名にはハイフンを使えないため、この指定が必要になります。
必須/任意の制御
@PathVariableにはデフォルトでrequired = trueが設定されています。パスパラメータはURLの一部なので、原則として省略できません。
※以下はメソッド部分のみ示します。
// パスパラメータを任意にする場合はOptionalを使う
@GetMapping({"/items", "/items/{id}"})
public String getItem(@PathVariable(required = false) Integer id) {
if (id == null) {
return "全件取得";
}
return "取得: ID=" + id;
}
ただし、上記のような設計は分かりにくくなるため、パスパラメータは必須にして、メソッドを分けるのが一般的です。
@RequestParam の詳細
required と defaultValue
package com.example.hellospring;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ProductSearchController {
@GetMapping("/products/search")
public String search(
@RequestParam String keyword,
@RequestParam(required = false) String category,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
return "キーワード: " + keyword
+ ", カテゴリ: " + (category != null ? category : "指定なし")
+ ", ページ: " + page
+ ", サイズ: " + size;
}
}
各属性の意味は以下の通りです。
| 属性 | 説明 | デフォルト値 |
|---|---|---|
required |
パラメータが必須かどうか | true |
defaultValue |
パラメータが省略されたときのデフォルト値 | なし |
http://localhost:8080/products/search?keyword=Java にアクセスすると:
キーワード: Java, カテゴリ: 指定なし, ページ: 1, サイズ: 10
http://localhost:8080/products/search?keyword=Java&category=book&page=3 にアクセスすると:
キーワード: Java, カテゴリ: book, ページ: 3, サイズ: 10
defaultValueを指定すると、requiredは暗黙的にfalseになります。defaultValueとrequired = trueを同時に指定しても、defaultValueが優先されます。
Optionalによる任意パラメータ
required = falseの代わりにOptionalを使うこともできます。
※以下はメソッド部分のみ示します。
@GetMapping("/products/filter")
public String filter(@RequestParam Optional<String> category) {
return "カテゴリ: " + category.orElse("全カテゴリ");
}
http://localhost:8080/products/filter にアクセスすると:
カテゴリ: 全カテゴリ
http://localhost:8080/products/filter?category=food にアクセスすると:
カテゴリ: food
4. リクエストボディの受け取り(@RequestBody)
GETリクエストではURLパラメータでデータを渡しますが、POST/PUTリクエストではリクエストボディにデータを格納します。Spring Bootでは@RequestBodyを使って、JSONを自動的にJavaオブジェクトに変換できます。
DTOクラスの作成(Record クラス)
まず、リクエストのJSON構造に対応するDTOクラスを作成します。Java 16以降ではrecordクラスが使えます。
package com.example.hellospring;
public record UserRequest(String name, String email, int age) {
}
この1行で、以下のようなJSONと自動的にマッピングされます。
{
"name": "田中太郎",
"email": "tanaka@example.com",
"age": 25
}
recordクラスは、コンストラクタ、getter(name()等)、equals()、hashCode()、toString()を自動生成するJava 16以降の機能です。DTOのような不変(イミュータブル)なデータ保持クラスに適しています。
@RequestBody でJSONをオブジェクトに変換
package com.example.hellospring;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UserRegistrationController {
@PostMapping("/api/register")
public String register(@RequestBody UserRequest user) {
return "登録完了: " + user.name() + "(" + user.email() + "), " + user.age() + "歳";
}
}
curlで動作確認します。
curl -X POST http://localhost:8080/api/register \
-H "Content-Type: application/json" \
-d '{"name":"田中太郎","email":"tanaka@example.com","age":25}'
レスポンス:
登録完了: 田中太郎(tanaka@example.com), 25歳
Spring Bootに内蔵されているJacksonライブラリが、JSONからJavaオブジェクトへの変換(デシリアライズ)を自動で行います。
Servlet/JSP版との対比
Servlet/JSPでは、JSONの受け取りは非常に手間がかかりました。
// Servlet/JSP版: JSONリクエストの受け取り
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// リクエストボディを文字列として読み取る
StringBuilder sb = new StringBuilder();
try (BufferedReader reader = request.getReader()) {
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
}
String json = sb.toString();
// JSONライブラリで手動パース(例: Jackson ObjectMapper)
ObjectMapper mapper = new ObjectMapper();
UserRequest user = mapper.readValue(json, UserRequest.class);
// レスポンスの出力
response.setContentType("text/plain; charset=UTF-8");
response.getWriter().println("登録完了: " + user.name());
}
Spring Bootでは@RequestBodyの1アノテーションで、リクエストボディの読み取り、JSONパース、オブジェクトへの変換がすべて自動化されます。
5. レスポンスのカスタマイズ
ResponseEntity とは
これまでの例では、メソッドの戻り値をStringにして文字列を返していました。しかし、REST APIではステータスコードやレスポンスヘッダーを制御したい場面が多くあります。ResponseEntity<T>を使うと、HTTPレスポンス全体(ステータスコード、ヘッダー、ボディ)を制御できます。
基本的な使い方
package com.example.hellospring;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ResponseExampleController {
@GetMapping("/api/status/{code}")
public ResponseEntity<String> statusExample(@PathVariable int code) {
return switch (code) {
case 200 -> ResponseEntity.ok("成功です");
case 404 -> ResponseEntity.notFound().build();
case 400 -> ResponseEntity.badRequest().body("不正なリクエストです");
default -> ResponseEntity.status(code).body("ステータスコード: " + code);
};
}
}
ResponseEntity の主なファクトリメソッド
| メソッド | ステータスコード | 説明 |
|---|---|---|
ResponseEntity.ok(body) |
200 OK | 成功 |
ResponseEntity.created(uri).body(body) |
201 Created | リソース作成成功 |
ResponseEntity.noContent().build() |
204 No Content | 成功(ボディなし) |
ResponseEntity.badRequest().body(body) |
400 Bad Request | 不正なリクエスト |
ResponseEntity.notFound().build() |
404 Not Found | リソースが見つからない |
ResponseEntity.status(code).body(body) |
任意 | 任意のステータスコード |
JSONオブジェクトを返す
ResponseEntity<T>の型パラメータにDTOクラスを指定すると、JacksonがJavaオブジェクトをJSONに自動変換(シリアライズ)してレスポンスボディに含めます。
package com.example.hellospring;
public record UserResponse(int id, String name, String email) {
}
package com.example.hellospring;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UserApiController {
@GetMapping("/api/users/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable int id) {
if (id <= 0) {
return ResponseEntity.badRequest().build();
}
UserResponse user = new UserResponse(id, "田中太郎", "tanaka@example.com");
return ResponseEntity.ok(user);
}
}
http://localhost:8080/api/users/1 にアクセスすると:
{"id":1,"name":"田中太郎","email":"tanaka@example.com"}
http://localhost:8080/api/users/0 にアクセスすると、400 Bad Requestが返されます(ボディは空)。
Servlet/JSP版との対比
Servlet/JSPでは、ステータスコードやヘッダーの設定をHttpServletResponseで個別に行う必要がありました。
// Servlet/JSP版
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json; charset=UTF-8");
response.setHeader("X-Custom-Header", "value");
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(user);
response.getWriter().println(json);
Spring BootではResponseEntityがステータスコード・ヘッダー・ボディをまとめて表現するため、コードが簡潔かつ意図が明確になります。
6. 実践例: 簡易TODO API
ここまで学んだ知識を総合して、メモリ内で動作する簡易TODOリストAPIを作ります。データベースは使わず、List<Todo>で管理します。
TODO のデータモデル
package com.example.hellospring;
public class Todo {
private int id;
private String title;
private boolean completed;
public Todo(int id, String title, boolean completed) {
this.id = id;
this.title = title;
this.completed = completed;
}
// getter
public int getId() {
return id;
}
public String getTitle() {
return title;
}
public boolean isCompleted() {
return completed;
}
// setter
public void setId(int id) {
this.id = id;
}
public void setTitle(String title) {
this.title = title;
}
public void setCompleted(boolean completed) {
this.completed = completed;
}
}
ここでは通常のクラスを使っています。Todoは更新操作があるため、不変(イミュータブル)なrecordクラスではなく、setterを持つ通常のクラスが適切です。JacksonがJSONへシリアライズする際にgetter(getId()等)を使い、デシリアライズする際にsetter(setId()等)を使います。
リクエスト用DTO
package com.example.hellospring;
public record TodoRequest(String title, boolean completed) {
}
TODO コントローラー
package com.example.hellospring;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/todos")
public class TodoController {
private final List<Todo> todos = new ArrayList<>();
private final AtomicInteger idCounter = new AtomicInteger(1);
// GET /api/todos ― 全件取得
@GetMapping
public ResponseEntity<List<Todo>> getAll() {
return ResponseEntity.ok(todos);
}
// GET /api/todos/{id} ― 個別取得
@GetMapping("/{id}")
public ResponseEntity<Todo> getById(@PathVariable int id) {
return todos.stream()
.filter(todo -> todo.getId() == id)
.findFirst()
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// POST /api/todos ― 新規作成
@PostMapping
public ResponseEntity<Todo> create(@RequestBody TodoRequest request) {
Todo todo = new Todo(idCounter.getAndIncrement(), request.title(), request.completed());
todos.add(todo);
URI location = URI.create("/api/todos/" + todo.getId());
return ResponseEntity.created(location).body(todo);
}
// PUT /api/todos/{id} ― 更新
@PutMapping("/{id}")
public ResponseEntity<Todo> update(@PathVariable int id,
@RequestBody TodoRequest request) {
for (Todo todo : todos) {
if (todo.getId() == id) {
todo.setTitle(request.title());
todo.setCompleted(request.completed());
return ResponseEntity.ok(todo);
}
}
return ResponseEntity.notFound().build();
}
// DELETE /api/todos/{id} ― 削除
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable int id) {
boolean removed = todos.removeIf(todo -> todo.getId() == id);
if (removed) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.notFound().build();
}
}
コードのポイント
AtomicIntegerによるID管理
private final AtomicInteger idCounter = new AtomicInteger(1);
IDを自動採番するためにAtomicIntegerを使っています。getAndIncrement()を呼ぶたびに、現在の値を返してからインクリメントします。
ResponseEntity.created()の使い方
URI location = URI.create("/api/todos/" + todo.getId());
return ResponseEntity.created(location).body(todo);
REST APIの慣例では、リソースを作成した際に201 Createdを返し、Locationヘッダーに新しいリソースのURIを含めます。
Streamによる検索
return todos.stream()
.filter(todo -> todo.getId() == id)
.findFirst()
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
findFirst()がOptional<Todo>を返すため、見つかった場合はResponseEntity.ok(todo)、見つからなかった場合はResponseEntity.notFound().build()を返しています。
curlによる動作確認
アプリケーションを起動した状態で、以下のcurlコマンドを順に実行します。
1. TODOを作成する(POST)
curl -X POST http://localhost:8080/api/todos \
-H "Content-Type: application/json" \
-d '{"title":"Spring Bootを学ぶ","completed":false}'
レスポンス(201 Created):
{"id":1,"title":"Spring Bootを学ぶ","completed":false}
2. もう1件作成する(POST)
curl -X POST http://localhost:8080/api/todos \
-H "Content-Type: application/json" \
-d '{"title":"コントローラを理解する","completed":false}'
レスポンス(201 Created):
{"id":2,"title":"コントローラを理解する","completed":false}
3. 全件取得する(GET)
curl http://localhost:8080/api/todos
レスポンス(200 OK):
[{"id":1,"title":"Spring Bootを学ぶ","completed":false},{"id":2,"title":"コントローラを理解する","completed":false}]
4. 個別取得する(GET)
curl http://localhost:8080/api/todos/1
レスポンス(200 OK):
{"id":1,"title":"Spring Bootを学ぶ","completed":false}
5. 更新する(PUT)
curl -X PUT http://localhost:8080/api/todos/1 \
-H "Content-Type: application/json" \
-d '{"title":"Spring Bootを学ぶ","completed":true}'
レスポンス(200 OK):
{"id":1,"title":"Spring Bootを学ぶ","completed":true}
6. 削除する(DELETE)
curl -X DELETE http://localhost:8080/api/todos/1
レスポンス(204 No Content):ボディなし
7. 存在しないIDを取得する(GET)
curl -v http://localhost:8080/api/todos/999
レスポンス(404 Not Found):ボディなし
この実装はメモリ内でデータを管理しているため、アプリケーションを再起動するとデータは消えます。データの永続化はSpring Data JPA(第5回)で学びます。また、Listをフィールドで直接管理する方法はスレッドセーフではないため、本番アプリケーションでは適切な同期処理やデータベースの使用が必要です。
練習問題
問題1:ユーザー情報API ⭐
以下の仕様でユーザー情報を返すAPIを作成してください。
- エンドポイント:
GET /api/profile/{id} - レスポンス:JSON形式で
id、name、departmentを返す -
id=1の場合:{"id":1, "name":"山田花子", "department":"開発部"} -
id=2の場合:{"id":2, "name":"佐藤次郎", "department":"営業部"} - 上記以外のIDの場合:404 Not Found
ヒント:レスポンス用のrecordクラスを作成し、ResponseEntityを使いましょう。
模範解答
レスポンス用DTO:
package com.example.hellospring;
public record ProfileResponse(int id, String name, String department) {
}
コントローラー:
package com.example.hellospring;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ProfileApiController {
@GetMapping("/api/profile/{id}")
public ResponseEntity<ProfileResponse> getProfile(@PathVariable int id) {
return switch (id) {
case 1 -> ResponseEntity.ok(new ProfileResponse(1, "山田花子", "開発部"));
case 2 -> ResponseEntity.ok(new ProfileResponse(2, "佐藤次郎", "営業部"));
default -> ResponseEntity.notFound().build();
};
}
}
動作確認:
curl http://localhost:8080/api/profile/1
{"id":1,"name":"山田花子","department":"開発部"}
curl http://localhost:8080/api/profile/99
404 Not Found(ボディなし)
問題2:商品リストCRUD API ⭐⭐
以下の仕様で商品を管理するAPIを作成してください。
- ベースパス:
/api/products - データモデル:
id(int)、name(String)、price(int) - CRUD操作を実装する
-
GET /api/products― 全件取得 -
GET /api/products/{id}― 個別取得(見つからない場合は404) -
POST /api/products― 新規作成(201 Created) -
DELETE /api/products/{id}― 削除(見つからない場合は404、成功は204)
-
- データはメモリ内(
List)で管理する
ヒント:本記事のTODO APIを参考に、データモデルとリクエストDTOを作成しましょう。
模範解答
データモデル:
package com.example.hellospring;
public class Product {
private int id;
private String name;
private int price;
public Product(int id, String name, int price) {
this.id = id;
this.name = name;
this.price = price;
}
public int getId() {
return id;
}
public String getName() {
return name;
}
public int getPrice() {
return price;
}
public void setId(int id) {
this.id = id;
}
public void setName(String name) {
this.name = name;
}
public void setPrice(int price) {
this.price = price;
}
}
リクエスト用DTO:
package com.example.hellospring;
public record ProductRequest(String name, int price) {
}
コントローラー:
package com.example.hellospring;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final List<Product> products = new ArrayList<>();
private final AtomicInteger idCounter = new AtomicInteger(1);
@GetMapping
public ResponseEntity<List<Product>> getAll() {
return ResponseEntity.ok(products);
}
@GetMapping("/{id}")
public ResponseEntity<Product> getById(@PathVariable int id) {
return products.stream()
.filter(p -> p.getId() == id)
.findFirst()
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<Product> create(@RequestBody ProductRequest request) {
Product product = new Product(
idCounter.getAndIncrement(), request.name(), request.price());
products.add(product);
URI location = URI.create("/api/products/" + product.getId());
return ResponseEntity.created(location).body(product);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable int id) {
boolean removed = products.removeIf(p -> p.getId() == id);
if (removed) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.notFound().build();
}
}
動作確認:
# 商品を作成
curl -X POST http://localhost:8080/api/products \
-H "Content-Type: application/json" \
-d '{"name":"Java入門書","price":2800}'
{"id":1,"name":"Java入門書","price":2800}
# 全件取得
curl http://localhost:8080/api/products
[{"id":1,"name":"Java入門書","price":2800}]
# 削除
curl -X DELETE http://localhost:8080/api/products/1
204 No Content(ボディなし)
問題3:エラーハンドリング付き書籍API ⭐⭐⭐
以下の仕様で書籍を管理するAPIを作成してください。ResponseEntityを活用してエラーハンドリングを実装します。
- ベースパス:
/api/books - データモデル:
id(int)、title(String)、author(String)、price(int) - 操作
-
POST /api/books― 新規作成-
titleが空文字またはnullの場合:400 Bad Request(メッセージ:"タイトルは必須です") -
priceが0以下の場合:400 Bad Request(メッセージ:"価格は1以上を指定してください") - 成功時:201 Created
-
-
GET /api/books/{id}― 個別取得- 見つからない場合:404 Not Found
-
PUT /api/books/{id}― 更新- 見つからない場合:404 Not Found
- バリデーションエラー時:400 Bad Request
-
ヒント:エラーメッセージを返す場合はResponseEntity<Object>を使い、正常時はオブジェクト、異常時はエラーメッセージの文字列を返す方法があります。
模範解答
データモデル:
package com.example.hellospring;
public class Book {
private int id;
private String title;
private String author;
private int price;
public Book(int id, String title, String author, int price) {
this.id = id;
this.title = title;
this.author = author;
this.price = price;
}
public int getId() {
return id;
}
public String getTitle() {
return title;
}
public String getAuthor() {
return author;
}
public int getPrice() {
return price;
}
public void setId(int id) {
this.id = id;
}
public void setTitle(String title) {
this.title = title;
}
public void setAuthor(String author) {
this.author = author;
}
public void setPrice(int price) {
this.price = price;
}
}
リクエスト用DTO:
package com.example.hellospring;
public record BookRequest(String title, String author, int price) {
}
コントローラー:
package com.example.hellospring;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final List<Book> books = new ArrayList<>();
private final AtomicInteger idCounter = new AtomicInteger(1);
@PostMapping
public ResponseEntity<Object> create(@RequestBody BookRequest request) {
// バリデーション
if (request.title() == null || request.title().isBlank()) {
return ResponseEntity.badRequest().body("タイトルは必須です");
}
if (request.price() <= 0) {
return ResponseEntity.badRequest().body("価格は1以上を指定してください");
}
Book book = new Book(
idCounter.getAndIncrement(),
request.title(),
request.author(),
request.price());
books.add(book);
URI location = URI.create("/api/books/" + book.getId());
return ResponseEntity.created(location).body(book);
}
@GetMapping("/{id}")
public ResponseEntity<Object> getById(@PathVariable int id) {
Optional<Book> found = books.stream()
.filter(b -> b.getId() == id)
.findFirst();
if (found.isPresent()) {
return ResponseEntity.ok(found.get());
}
return ResponseEntity.notFound().build();
}
@PutMapping("/{id}")
public ResponseEntity<Object> update(@PathVariable int id,
@RequestBody BookRequest request) {
// バリデーション
if (request.title() == null || request.title().isBlank()) {
return ResponseEntity.badRequest().body("タイトルは必須です");
}
if (request.price() <= 0) {
return ResponseEntity.badRequest().body("価格は1以上を指定してください");
}
for (Book book : books) {
if (book.getId() == id) {
book.setTitle(request.title());
book.setAuthor(request.author());
book.setPrice(request.price());
return ResponseEntity.ok(book);
}
}
return ResponseEntity.notFound().build();
}
}
動作確認:
# 正常な作成
curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"Spring Boot入門","author":"山田太郎","price":3200}'
{"id":1,"title":"Spring Boot入門","author":"山田太郎","price":3200}
# タイトルが空で作成(バリデーションエラー)
curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"","author":"山田太郎","price":3200}'
タイトルは必須です
# 価格が0で作成(バリデーションエラー)
curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"Spring Boot入門","author":"山田太郎","price":0}'
価格は1以上を指定してください
# 存在しないIDを取得
curl -v http://localhost:8080/api/books/999
404 Not Found(ボディなし)
この問題では手動でバリデーションを行いましたが、Spring Bootには**Bean Validation(@Valid + @NotBlank等)**という仕組みがあり、より宣言的にバリデーションを実装できます。これは第4回「フォーム処理とバリデーション」で学びます。
まとめ
学んだことの整理
| トピック | キーワード |
|---|---|
| コントローラーの種類 |
@Controller(ビュー)、@RestController(データ) |
| HTTPメソッドのマッピング |
@GetMapping、@PostMapping、@PutMapping、@DeleteMapping、@PatchMapping
|
| パスの設計 | クラスレベルの@RequestMapping、メソッドレベルのマッピング |
| パスパラメータ |
@PathVariable、複数パラメータ、名前の明示的指定 |
| クエリパラメータ |
@RequestParam、required、defaultValue、Optional
|
| リクエストボディ |
@RequestBody、recordクラス(DTO)、JSONの自動変換 |
| レスポンス制御 |
ResponseEntity<T>、ステータスコード、Locationヘッダー |
Servlet/JSPの知識がどう活きたか
| Servlet/JSPの知識 | Spring Bootでの活用 |
|---|---|
doGet() / doPost() / doPut() / doDelete()
|
@GetMapping / @PostMapping / @PutMapping / @DeleteMapping の意味がすぐ分かる |
request.getPathInfo() でURLパスを解析 |
@PathVariable がURL解析を自動化していると理解できる |
request.getParameter() |
@RequestParam の内部動作が理解できる |
request.getInputStream() + JSONパース |
@RequestBody が入力ストリームの読み取り + パースを自動化していると分かる |
response.setStatus() / response.setHeader()
|
ResponseEntity がレスポンス設定をオブジェクトとして表現していると分かる |
次回予告
第3回では Thymeleafによるビュー を学びます。@Controllerを使ってHTML画面を描画し、フォームからデータを受け取る方法を扱います。Servlet/JSPでJSP + EL式 + JSTLで行っていた処理が、Thymeleafでどう変わるかを対比しながら学びます。
Spring Boot入門シリーズ 全10回(予定):
- Servlet/JSPからの移行と環境構築
- 👉 コントローラとルーティング(本記事)
- Thymeleafによるビュー
- フォーム処理とバリデーション
- Spring Data JPA(データベース連携)
- RESTful API設計
- Spring Security(認証・認可)
- 例外処理とエラーハンドリング
- テストの書き方(JUnit + MockMvc)
- 総合演習:掲示板アプリをSpring Bootで再構築
参考
- Spring Boot 公式ドキュメント
- Spring Web MVC リファレンス
- Spring Web MVC - Annotated Controllers
- ResponseEntity Javadoc
- Jackson Project(JSON処理ライブラリ)
@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!