0
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?

【Spring Boot入門②】コントローラとルーティング ― HTTPメソッド・リクエスト/レスポンスを自在に操る

0
Posted at

株式会社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になります。defaultValuerequired = 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形式でidnamedepartmentを返す
  • 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、複数パラメータ、名前の明示的指定
クエリパラメータ @RequestParamrequireddefaultValueOptional
リクエストボディ @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回(予定):

  1. Servlet/JSPからの移行と環境構築
  2. 👉 コントローラとルーティング(本記事)
  3. Thymeleafによるビュー
  4. フォーム処理とバリデーション
  5. Spring Data JPA(データベース連携)
  6. RESTful API設計
  7. Spring Security(認証・認可)
  8. 例外処理とエラーハンドリング
  9. テストの書き方(JUnit + MockMvc)
  10. 総合演習:掲示板アプリをSpring Bootで再構築

参考


@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!

0
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
0
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?