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入門⑥】RESTful API設計 ― URI設計・ステータスコード・DTO・ページネーションで実務レベルのAPIを作る

0
Posted at

自己紹介

株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。

Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
コーポレートサイト

はじめに

第2回では @RestControllerResponseEntity を使って簡易TODO APIを作り、第5回では Spring Data JPA でデータベースに永続化しました。

しかし、これまでのAPIは動くことを優先しており、「RESTful かどうか」は意識していませんでした。例えば次のような点が気になります。

  • URI が適切に設計されているか?
  • ステータスコードは正しく返せているか?
  • エンティティをそのままJSONで返してよいのか?
  • 大量データをどうやってページ単位で返すか?

第6回では、REST の設計原則を理解し、第5回までに作ったAPIを実務で通用するレベルにリファクタリングします。

今回学ぶこと

  • REST の基本原則(6つの制約)
  • URI設計のベストプラクティスとアンチパターン
  • HTTPステータスコードの使い分け
  • DTOパターン(エンティティとAPIの分離)
  • ページネーション(Pageable / Page<T>
  • HATEOAS の概要

本記事のコードはすべて第1回で作成したhello-springプロジェクト(com.example.hellospringパッケージ)上で動作します。第5回で導入した H2 + Spring Data JPA の環境が前提です。環境構築がまだの方は第1回・第5回を先にご覧ください。


1. REST とは

REST の正式名称と起源

REST(Representational State Transfer) は、2000年にRoy Fieldingが博士論文で提唱したアーキテクチャスタイルです。特定の技術やプロトコルではなく、分散システムを設計するための制約の集合です。

REST はプロトコルでも仕様でもありません。「こういう制約を満たせば、スケーラブルで保守しやすいWebシステムになる」という設計思想です。HTTP + JSON で API を作れば自動的に RESTful になるわけではありません。

REST の6つの制約

制約 説明 具体例
クライアント-サーバー UIとデータ処理を分離する フロントエンド(React等)とバックエンド(Spring Boot)を別々に開発する
ステートレス サーバーはクライアントの状態を保持しない リクエストごとに必要な情報(認証トークン等)をすべて含める
キャッシュ レスポンスにキャッシュ可否を明示する Cache-Control ヘッダーでキャッシュ期間を指定する
統一インターフェース リソースの操作方法を統一する HTTPメソッド(GET/POST/PUT/DELETE)とURIでリソースを操作する
階層システム クライアントとサーバーの間にプロキシやロードバランサーを置ける リバースプロキシ(Nginx)を介してリクエストを受ける
コードオンデマンド(任意) 必要に応じてサーバーからコードを送信する JavaScriptをレスポンスに含めてクライアントで実行する

この中で、API設計において最も重要なのは統一インターフェースです。

リソース指向 vs アクション指向

REST はリソース指向の設計です。「何を(リソース)」と「どうする(HTTPメソッド)」を分離します。

Servlet/JSP 時代に見られたアクション指向URLとの違いを比較してみましょう。

【アクション指向URL(非REST)】
POST /doCreateUser       ← ユーザーを作成
GET  /getUserById?id=1   ← ユーザーを取得
POST /updateUser         ← ユーザーを更新
POST /deleteUser?id=1    ← ユーザーを削除

【リソース指向URL(REST)】
POST   /api/users        ← ユーザーを作成
GET    /api/users/1      ← ユーザーを取得
PUT    /api/users/1      ← ユーザーを更新
DELETE /api/users/1      ← ユーザーを削除

アクション指向では、URI に動詞doCreateupdatedelete)が含まれています。REST では、URI は名詞(リソース) を表し、動詞はHTTPメソッドが担うというのが基本的な考え方です。


2. URI設計のベストプラクティス

基本ルール

ルール 良い例 悪い例
リソース名は名詞を使う /api/users /api/getUsers
リソース名は複数形にする /api/users /api/user
小文字で統一する /api/users /api/Users
単語の区切りはハイフンを使う /api/user-profiles /api/user_profiles
拡張子を含めない /api/users /api/users.json
末尾スラッシュを付けない /api/users /api/users/

階層構造でリソースの関係を表す

リソース間に親子関係がある場合、URIの階層で表現します。

/api/users                    ← ユーザー一覧
/api/users/{userId}           ← 特定のユーザー
/api/users/{userId}/orders    ← 特定ユーザーの注文一覧
/api/users/{userId}/orders/{orderId}  ← 特定ユーザーの特定の注文

階層を深くしすぎると URI が長く複雑になります。一般的に2~3階層に留めるのがよいとされています。4階層以上が必要な場合は、クエリパラメータや独立したリソースとして設計することを検討してください。

クエリパラメータの活用

フィルタリング、ソート、ページネーションにはクエリパラメータを使います。

# フィルタリング
GET /api/users?status=active
GET /api/users?role=admin&status=active

# ソート
GET /api/users?sort=name,asc

# ページネーション
GET /api/users?page=0&size=10

# 検索
GET /api/users?keyword=田中

# 組み合わせ
GET /api/users?status=active&sort=createdAt,desc&page=0&size=20

URI設計のアンチパターン

# 動詞を含める
GET /api/getUsers           ← GET メソッドが「取得」を意味するので不要
POST /api/createUser        ← POST メソッドが「作成」を意味するので不要

# CRUD を URI で表現する
POST /api/users/delete/1    ← DELETE メソッドを使うべき

# 大文字やアンダースコア
GET /api/UserProfiles       ← 小文字 + ハイフンにする
GET /api/user_profiles      ← ハイフンに統一する

# 拡張子
GET /api/users.json         ← Content-Type ヘッダーで制御する

CRUD操作と HTTP メソッドの対応(復習)

第2回の復習ですが、REST の文脈で改めて整理します。

操作 HTTPメソッド URI例 説明
一覧取得 GET /api/users リソースのコレクションを取得する
1件取得 GET /api/users/{id} 特定のリソースを取得する
新規作成 POST /api/users リソースを作成する
全体更新 PUT /api/users/{id} リソース全体を置き換える
部分更新 PATCH /api/users/{id} リソースの一部を更新する
削除 DELETE /api/users/{id} リソースを削除する

PUT と PATCH の違い:PUT はリソース全体を置き換えます(送信しなかったフィールドは null になる)。PATCH はリソースの一部だけを更新します。実務では「更新 = PUT」とする設計が多いですが、厳密には使い分ける必要があります。本記事では PUT を使います。


3. HTTP ステータスコードの使い分け

よく使うステータスコード

REST API では、処理結果に応じて適切なステータスコードを返すことが重要です。

2xx:成功系

コード 名前 使いどころ
200 OK 取得・更新に成功した(レスポンスボディあり)
201 Created リソースの新規作成に成功した
204 No Content 削除に成功した(レスポンスボディなし)

4xx:クライアントエラー系

コード 名前 使いどころ
400 Bad Request リクエストの形式が不正(バリデーションエラー等)
404 Not Found 指定したリソースが存在しない
409 Conflict リソースの状態が競合している(重複登録等)

5xx:サーバーエラー系

コード 名前 使いどころ
500 Internal Server Error サーバー側の予期しないエラー

すべてのレスポンスを 200 OK で返し、ボディ内の status フィールドで成功/失敗を判別する設計を見ることがありますが、これは REST のアンチパターンです。HTTPステータスコードを正しく使い分けましょう。

ResponseEntity でステータスコードを返す

Spring Boot では ResponseEntity を使って、ステータスコードとレスポンスボディを制御します。

package com.example.hellospring.controller;

import java.net.URI;

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/examples")
public class StatusCodeExampleController {

    // 200 OK(取得成功)
    @GetMapping("/{id}")
    public ResponseEntity<String> getById(@PathVariable Long id) {
        return ResponseEntity.ok("リソース " + id);
    }

    // 201 Created(作成成功)
    @PostMapping
    public ResponseEntity<String> create(@RequestBody String name) {
        URI location = URI.create("/api/examples/1");
        return ResponseEntity.created(location).body("作成しました: " + name);
    }

    // 204 No Content(削除成功)
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        return ResponseEntity.noContent().build();
    }

    // 404 Not Found(リソースが見つからない)
    @GetMapping("/not-found-example")
    public ResponseEntity<String> notFoundExample() {
        return ResponseEntity.notFound().build();
    }
}

ResponseEntity.created(location) は、レスポンスヘッダーに Location: /api/examples/1 を含めます。これにより、クライアントは作成されたリソースの URI を知ることができます。REST の慣習として、201 Created には Location ヘッダーを付けるのが望ましいです。


4. DTOパターン

エンティティを直接返す問題点

第5回では、Todo エンティティをそのままJSONレスポンスとして返していました。

// 第5回のコード(エンティティ直接返却)
@GetMapping
public ResponseEntity<List<Todo>> getAll() {
    return ResponseEntity.ok(todoService.findAll());
}

この設計には以下の問題があります。

問題 説明
不要なフィールドの露出 DB内部のID戦略やシステム管理用カラムがクライアントに見えてしまう
APIとDB設計の結合 テーブル構造を変更するとAPIレスポンスも変わってしまう
循環参照 JPA のリレーション(@OneToMany 等)があると、JSON変換時に無限ループになることがある
入力と出力の要件が異なる 作成時には id は不要だが、レスポンスには含めたい

DTO とは

DTO(Data Transfer Object) は、レイヤー間でデータを受け渡すためのオブジェクトです。API 設計においては、リクエスト用DTOレスポンス用DTOを分けて作成します。

クライアント → [Request DTO] → Controller → Service → Repository → DB
クライアント ← [Response DTO] ← Controller ← Service ← Repository ← DB

Java の Record で DTO を実装する

Java 16 で正式導入された Record は、DTO の実装に最適です。Record は、不変(イミュータブル)なデータキャリアを簡潔に定義できます。

Record クラスは、コンストラクタ、getter(アクセサメソッド)、equals()hashCode()toString() を自動生成します。DTO のように「データを運ぶだけ」のクラスに適しています。

従来のクラスで DTO を書いた場合と比較してみましょう。

// 従来のクラスで書いた場合(冗長)
public class UserResponse {
    private final Long id;
    private final String name;
    private final String email;

    public UserResponse(Long id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public String getEmail() { return email; }

    // equals(), hashCode(), toString() も手書きが必要
}

// Record で書いた場合(簡潔)
public record UserResponse(Long id, String name, String email) {
}

Record を使えば、たった1行で同等のコードが完成します。

DTO の設計指針

種類 命名規則 含めるフィールド
作成リクエスト XxxCreateRequest クライアントが入力する項目(id は含めない)
更新リクエスト XxxUpdateRequest 更新対象の項目(id は含めない。パスパラメータで受け取る)
レスポンス XxxResponse クライアントに返す項目(id を含める)

5. ページネーション

大量データの問題

データベースに10万件のレコードがある場合、findAll() で全件取得してJSON化すると、レスポンスが巨大になり、ネットワーク帯域やメモリを圧迫します。

ページネーションは、データをページ単位で分割して返す仕組みです。

Spring Data の Pageable と Page

Spring Data JPA には、ページネーションのための仕組みが組み込まれています。

クラス/インターフェース 役割
Pageable ページ番号、ページサイズ、ソート順を保持するリクエストオブジェクト
Page<T> ページングされた結果を保持するレスポンスオブジェクト(総件数やページ数を含む)
@PageableDefault コントローラーメソッドで Pageable のデフォルト値を指定するアノテーション

Pageable の使い方

JpaRepositoryfindAll(Pageable pageable) メソッドを標準で提供しています。コントローラーでは Pageable を引数として受け取るだけです。

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;

@GetMapping
public ResponseEntity<Page<Todo>> getAll(
        @PageableDefault(size = 10, sort = "id") Pageable pageable) {
    return ResponseEntity.ok(todoRepository.findAll(pageable));
}

クライアントはクエリパラメータでページネーションを指定します。

# デフォルト(1ページ目、10件、id昇順)
GET /api/todos

# 2ページ目、20件ずつ
GET /api/todos?page=1&size=20

# 作成日の降順でソート
GET /api/todos?sort=createdAt,desc

# 複数条件でソート
GET /api/todos?sort=completed,asc&sort=createdAt,desc

Spring Data のページ番号は0始まりです。page=0 が1ページ目、page=1 が2ページ目です。

Page レスポンスの構造

Page<T> をそのまま返すと、以下のようなJSONが返されます。

{
  "content": [
    { "id": 1, "title": "買い物", "completed": false },
    { "id": 2, "title": "掃除", "completed": true }
  ],
  "pageable": {
    "pageNumber": 0,
    "pageSize": 10,
    "sort": { "sorted": true, "unsorted": false, "empty": false },
    "offset": 0,
    "paged": true,
    "unpaged": false
  },
  "totalElements": 25,
  "totalPages": 3,
  "size": 10,
  "number": 0,
  "numberOfElements": 2,
  "first": true,
  "last": false,
  "empty": false,
  "sort": { "sorted": true, "unsorted": false, "empty": false }
}
フィールド 説明
content 現在のページのデータ
totalElements 全データの総件数
totalPages 総ページ数
size 1ページあたりの件数
number 現在のページ番号(0始まり)
first 最初のページかどうか
last 最後のページかどうか

6. 実践例:ユーザー管理API

ここまで学んだ内容をすべて組み合わせて、RESTful なユーザー管理APIを構築します。

プロジェクト構成

src/main/java/com/example/hellospring/
├── entity/
│   └── User.java
├── repository/
│   └── UserRepository.java
├── service/
│   └── UserService.java
├── dto/
│   ├── UserCreateRequest.java
│   ├── UserUpdateRequest.java
│   └── UserResponse.java
└── controller/
    └── UserApiController.java

6.1 エンティティ

src/main/java/com/example/hellospring/entity/User.java

package com.example.hellospring.entity;

import java.time.LocalDateTime;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 50)
    private String name;

    @Column(nullable = false, unique = true, length = 100)
    private String email;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;

    // JPAはデフォルトコンストラクタを必要とする
    public User() {
    }

    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }

    // エンティティが永続化される直前に呼ばれる
    @PrePersist
    protected void onCreate() {
        this.createdAt = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
    }

    // エンティティが更新される直前に呼ばれる
    @PreUpdate
    protected void onUpdate() {
        this.updatedAt = LocalDateTime.now();
    }

    // getter
    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }

    public LocalDateTime getCreatedAt() {
        return createdAt;
    }

    public LocalDateTime getUpdatedAt() {
        return updatedAt;
    }

    // setter
    public void setId(Long id) {
        this.id = id;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public void setCreatedAt(LocalDateTime createdAt) {
        this.createdAt = createdAt;
    }

    public void setUpdatedAt(LocalDateTime updatedAt) {
        this.updatedAt = updatedAt;
    }
}

@PrePersist@PreUpdate は JPA のライフサイクルコールバックです。save()merge() が呼ばれる直前に自動実行されるため、createdAt/updatedAt を手動でセットする必要がありません。

6.2 リポジトリ

src/main/java/com/example/hellospring/repository/UserRepository.java

package com.example.hellospring.repository;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

import com.example.hellospring.entity.User;

public interface UserRepository extends JpaRepository<User, Long> {

    // 名前でキーワード検索(部分一致)
    Page<User> findByNameContaining(String keyword, Pageable pageable);

    // メールアドレスの重複チェック
    boolean existsByEmail(String email);

    // 指定IDを除いてメールアドレスの重複チェック(更新時に使用)
    boolean existsByEmailAndIdNot(String email, Long id);
}

findByNameContaining の戻り値を Page<User> にし、引数に Pageable を追加するだけで、検索結果にもページネーションが自動適用されます。List<User> を返すクエリメソッドを、Page<User> に変更するだけでページネーション対応が完了します。

6.3 DTO

UserCreateRequest.javasrc/main/java/com/example/hellospring/dto/UserCreateRequest.java

package com.example.hellospring.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record UserCreateRequest(
        @NotBlank(message = "名前は必須です")
        @Size(max = 50, message = "名前は50文字以内で入力してください")
        String name,

        @NotBlank(message = "メールアドレスは必須です")
        @Email(message = "メールアドレスの形式が正しくありません")
        @Size(max = 100, message = "メールアドレスは100文字以内で入力してください")
        String email
) {
}

UserUpdateRequest.javasrc/main/java/com/example/hellospring/dto/UserUpdateRequest.java

package com.example.hellospring.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record UserUpdateRequest(
        @NotBlank(message = "名前は必須です")
        @Size(max = 50, message = "名前は50文字以内で入力してください")
        String name,

        @NotBlank(message = "メールアドレスは必須です")
        @Email(message = "メールアドレスの形式が正しくありません")
        @Size(max = 100, message = "メールアドレスは100文字以内で入力してください")
        String email
) {
}

UserResponse.javasrc/main/java/com/example/hellospring/dto/UserResponse.java

package com.example.hellospring.dto;

import java.time.LocalDateTime;

import com.example.hellospring.entity.User;

public record UserResponse(
        Long id,
        String name,
        String email,
        LocalDateTime createdAt,
        LocalDateTime updatedAt
) {
    /**
     * User エンティティから UserResponse を生成するファクトリメソッド。
     * エンティティ → DTO の変換ロジックを1箇所に集約する。
     */
    public static UserResponse from(User user) {
        return new UserResponse(
                user.getId(),
                user.getName(),
                user.getEmail(),
                user.getCreatedAt(),
                user.getUpdatedAt()
        );
    }
}

UserResponse.from(user) というファクトリメソッドを用意しておくと、変換ロジックが1箇所にまとまります。実務では MapStruct 等のマッピングライブラリを使うことも多いですが、本記事ではシンプルにファクトリメソッドで対応します。

6.4 サービス

src/main/java/com/example/hellospring/service/UserService.java

package com.example.hellospring.service;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.example.hellospring.entity.User;
import com.example.hellospring.repository.UserRepository;

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    /**
     * ユーザー一覧を取得する(ページネーション対応)。
     */
    public Page<User> findAll(Pageable pageable) {
        return userRepository.findAll(pageable);
    }

    /**
     * 名前でキーワード検索する(ページネーション対応)。
     */
    public Page<User> searchByName(String keyword, Pageable pageable) {
        return userRepository.findByNameContaining(keyword, pageable);
    }

    /**
     * IDでユーザーを取得する。
     * 見つからない場合は例外をスローする。
     */
    public User findById(Long id) {
        return userRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException(
                        "ユーザーが見つかりません(ID: " + id + ")"));
    }

    /**
     * ユーザーを新規作成する。
     * メールアドレスが重複している場合は例外をスローする。
     */
    @Transactional
    public User create(String name, String email) {
        if (userRepository.existsByEmail(email)) {
            throw new IllegalStateException(
                    "このメールアドレスは既に使用されています: " + email);
        }
        User user = new User(name, email);
        return userRepository.save(user);
    }

    /**
     * ユーザー情報を更新する。
     * メールアドレスが他のユーザーと重複している場合は例外をスローする。
     */
    @Transactional
    public User update(Long id, String name, String email) {
        User user = findById(id);

        if (userRepository.existsByEmailAndIdNot(email, id)) {
            throw new IllegalStateException(
                    "このメールアドレスは既に使用されています: " + email);
        }

        user.setName(name);
        user.setEmail(email);
        return userRepository.save(user);
    }

    /**
     * ユーザーを削除する。
     */
    @Transactional
    public void delete(Long id) {
        User user = findById(id);
        userRepository.delete(user);
    }
}

6.5 コントローラー

src/main/java/com/example/hellospring/controller/UserApiController.java

package com.example.hellospring.controller;

import java.net.URI;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.example.hellospring.dto.UserCreateRequest;
import com.example.hellospring.dto.UserResponse;
import com.example.hellospring.dto.UserUpdateRequest;
import com.example.hellospring.entity.User;
import com.example.hellospring.service.UserService;

@RestController
@RequestMapping("/api/users")
public class UserApiController {

    private final UserService userService;

    public UserApiController(UserService userService) {
        this.userService = userService;
    }

    /**
     * ユーザー一覧取得(ページネーション対応)。
     * GET /api/users?page=0&size=10&sort=id,asc
     * GET /api/users?keyword=田中&page=0&size=10
     */
    @GetMapping
    public ResponseEntity<Page<UserResponse>> getAll(
            @RequestParam(required = false) String keyword,
            @PageableDefault(size = 10, sort = "id") Pageable pageable) {

        Page<User> users;
        if (keyword != null && !keyword.isBlank()) {
            users = userService.searchByName(keyword, pageable);
        } else {
            users = userService.findAll(pageable);
        }

        // Page<User> → Page<UserResponse> に変換
        Page<UserResponse> response = users.map(UserResponse::from);
        return ResponseEntity.ok(response);
    }

    /**
     * ユーザー1件取得。
     * GET /api/users/{id}
     */
    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> getById(@PathVariable Long id) {
        User user = userService.findById(id);
        return ResponseEntity.ok(UserResponse.from(user));
    }

    /**
     * ユーザー新規作成。
     * POST /api/users
     */
    @PostMapping
    public ResponseEntity<UserResponse> create(
            @Validated @RequestBody UserCreateRequest request) {
        User user = userService.create(request.name(), request.email());
        UserResponse response = UserResponse.from(user);

        URI location = URI.create("/api/users/" + user.getId());
        return ResponseEntity.created(location).body(response);
    }

    /**
     * ユーザー情報更新。
     * PUT /api/users/{id}
     */
    @PutMapping("/{id}")
    public ResponseEntity<UserResponse> update(
            @PathVariable Long id,
            @Validated @RequestBody UserUpdateRequest request) {
        User user = userService.update(id, request.name(), request.email());
        return ResponseEntity.ok(UserResponse.from(user));
    }

    /**
     * ユーザー削除。
     * DELETE /api/users/{id}
     */
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        userService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

各エンドポイントのポイントを整理します。

エンドポイント ステータスコード ポイント
GET /api/users 200 OK Page<UserResponse> でページネーション付きで返却。keyword パラメータで検索も可能
GET /api/users/{id} 200 OK エンティティではなく UserResponse DTO で返却
POST /api/users 201 Created Location ヘッダーに作成されたリソースの URI を含める
PUT /api/users/{id} 200 OK 更新後のリソースを返却
DELETE /api/users/{id} 204 No Content レスポンスボディなし

users.map(UserResponse::from) は、Pagemap() メソッドを使って、ページの中身(User)を UserResponse に変換しています。Page はページネーション情報(総件数、総ページ数など)をそのまま保持しつつ、中身だけを変換できます。

6.6 curl での動作確認

# 1. ユーザーを作成
curl -X POST http://localhost:8080/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "田中太郎", "email": "tanaka@example.com"}'

# レスポンス(201 Created):
# {
#   "id": 1,
#   "name": "田中太郎",
#   "email": "tanaka@example.com",
#   "createdAt": "2026-04-13T10:00:00",
#   "updatedAt": "2026-04-13T10:00:00"
# }

# 2. さらに数件作成
curl -X POST http://localhost:8080/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "鈴木花子", "email": "suzuki@example.com"}'

curl -X POST http://localhost:8080/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "田中次郎", "email": "tanaka.jiro@example.com"}'

# 3. 一覧取得(ページネーション)
curl "http://localhost:8080/api/users?page=0&size=2&sort=name,asc"

# 4. キーワード検索
curl "http://localhost:8080/api/users?keyword=田中"

# 5. 1件取得
curl http://localhost:8080/api/users/1

# 6. 更新
curl -X PUT http://localhost:8080/api/users/1 \
  -H "Content-Type: application/json" \
  -d '{"name": "田中太郎(更新)", "email": "tanaka.updated@example.com"}'

# 7. 削除(204 No Content)
curl -X DELETE http://localhost:8080/api/users/3 -v
# < HTTP/1.1 204

# 8. 存在しないユーザーを取得(500 → 第8回で例外ハンドリングを学ぶ)
curl http://localhost:8080/api/users/999

存在しないIDを指定すると、IllegalArgumentException がスローされ、Spring Boot のデフォルトでは 500 Internal Server Error が返されます。本来は 404 Not Found を返すべきですが、適切な例外ハンドリングは第8回(例外処理とエラーハンドリング)で詳しく扱います。

6.7 エンティティ直接返却 vs DTO パターンの対比

第5回の TODO API と今回の User API の設計を比較します。

// 第5回:エンティティ直接返却
@GetMapping("/{id}")
public ResponseEntity<Todo> getById(@PathVariable Long id) {
    Todo todo = todoService.findById(id);
    return ResponseEntity.ok(todo);  // エンティティをそのまま返す
}

// 第6回:DTOパターン
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getById(@PathVariable Long id) {
    User user = userService.findById(id);
    return ResponseEntity.ok(UserResponse.from(user));  // DTOに変換して返す
}
比較項目 エンティティ直接返却 DTOパターン
コード量 少ない 多い(DTO クラスの作成が必要)
APIとDB設計の結合 密結合(テーブル変更 = API変更) 疎結合(独立して変更可能)
レスポンスの柔軟性 テーブルの全フィールドが公開される 必要なフィールドだけ選択できる
循環参照のリスク JPA リレーションで発生しうる DTO側で制御できる
適用場面 学習用、プロトタイプ 実務、チーム開発

7. HATEOAS の概要

HATEOAS とは

HATEOAS(Hypermedia as the Engine of Application State) は、REST の統一インターフェース制約の一部で、レスポンスにリソースへのリンク情報を含める設計です。

通常の REST API のレスポンス:

{
  "id": 1,
  "name": "田中太郎",
  "email": "tanaka@example.com"
}

HATEOAS 対応のレスポンス:

{
  "id": 1,
  "name": "田中太郎",
  "email": "tanaka@example.com",
  "_links": {
    "self": { "href": "/api/users/1" },
    "update": { "href": "/api/users/1" },
    "delete": { "href": "/api/users/1" },
    "collection": { "href": "/api/users" }
  }
}

_links に「このリソースに対してどんな操作ができるか」をリンクとして含めています。クライアントは、APIのドキュメントを参照しなくても、レスポンスに含まれるリンクを辿って次の操作を発見できます。

Spring HATEOAS

Spring Boot では Spring HATEOAS ライブラリを使って HATEOAS を実装できます。

<!-- pom.xml に追加 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>

主要なクラスは以下の通りです。

クラス 説明
EntityModel<T> 単一リソースにリンクを追加する
CollectionModel<T> コレクションリソースにリンクを追加する
PagedModel<T> ページネーション付きコレクションにリンクを追加する
WebMvcLinkBuilder コントローラーのメソッドからリンクを生成するユーティリティ

簡単な使用例を示します。

import org.springframework.hateoas.EntityModel;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;

@GetMapping("/{id}")
public ResponseEntity<EntityModel<UserResponse>> getById(@PathVariable Long id) {
    User user = userService.findById(id);
    UserResponse response = UserResponse.from(user);

    EntityModel<UserResponse> model = EntityModel.of(response,
            linkTo(methodOn(UserApiController.class).getById(id)).withSelfRel(),
            linkTo(methodOn(UserApiController.class).getAll(null, null)).withRel("collection")
    );

    return ResponseEntity.ok(model);
}

HATEOAS は REST の理想的な成熟度(Richardson Maturity Model のレベル3)に対応します。ただし、実務のほとんどの REST API は HATEOAS を採用しておらず、レベル2(HTTPメソッド + ステータスコードの適切な使用)で十分とされることが多いです。本記事では概要の紹介にとどめます。

Richardson Maturity Model(REST成熟度モデル)

REST API の成熟度を4段階で評価するモデルです。

レベル 名前 説明
レベル0 The Swamp of POX 1つのURIに全操作をPOSTで送る POST /api にアクション名を含むJSONを送る
レベル1 Resources リソースごとにURIを分ける /api/users/api/orders
レベル2 HTTP Verbs HTTPメソッドとステータスコードを適切に使う GET/POST/PUT/DELETE + 200/201/404 等
レベル3 Hypermedia Controls レスポンスにリンク情報を含める(HATEOAS) _links にリソース操作のリンクを含める

本記事で目指すのはレベル2です。レベル2を正しく実践できれば、実務で十分に通用します。


練習問題

問題1:商品APIのURI設計 ⭐

以下の要件を満たすECサイトの商品APIのURI設計を行ってください(設計のみ。コードの実装は不要です)。

要件:

  • 商品のCRUD操作
  • カテゴリ別の商品一覧取得
  • 商品の在庫情報取得・更新
  • 商品レビューの取得・投稿
  • 商品検索(キーワード、価格範囲)
  • ページネーション対応

以下の表を埋める形で設計してください。

HTTPメソッド URI 説明 ステータスコード
GET ? 商品一覧取得 ?
POST ? 商品新規作成 ?
? ? 商品1件取得 ?
? ? 商品更新 ?
? ? 商品削除 ?
? ? カテゴリ別商品一覧 ?
? ? 商品の在庫情報取得 ?
? ? 商品の在庫情報更新 ?
? ? 商品のレビュー一覧取得 ?
? ? 商品にレビューを投稿 ?
? ? 商品検索 ?
模範解答
HTTPメソッド URI 説明 ステータスコード
GET /api/products?page=0&size=10 商品一覧取得 200 OK
POST /api/products 商品新規作成 201 Created
GET /api/products/{id} 商品1件取得 200 OK
PUT /api/products/{id} 商品更新 200 OK
DELETE /api/products/{id} 商品削除 204 No Content
GET /api/products?category=electronics&page=0&size=10 カテゴリ別商品一覧 200 OK
GET /api/products/{id}/stock 商品の在庫情報取得 200 OK
PUT /api/products/{id}/stock 商品の在庫情報更新 200 OK
GET /api/products/{id}/reviews?page=0&size=10 商品のレビュー一覧取得 200 OK
POST /api/products/{id}/reviews 商品にレビューを投稿 201 Created
GET /api/products?keyword=laptop&minPrice=50000&maxPrice=200000 商品検索 200 OK

設計のポイント:

  • カテゴリ別取得は /api/categories/{id}/products とする設計もありますが、フィルタリングはクエリパラメータで行う方がシンプルです
  • 在庫情報(stock)は商品のサブリソースとして /api/products/{id}/stock に配置しました
  • レビューも商品のサブリソースとして /api/products/{id}/reviews に配置しました
  • 検索条件はクエリパラメータで指定します。/api/products/search のような別URIにする設計もありますが、リソース指向では一覧取得とフィルタリングを統一的に扱う方が自然です

問題2:記事管理API ⭐⭐

以下の仕様で、RESTful な記事管理APIを実装してください。

Article エンティティ

フィールド 制約
id Long 主キー、自動採番
title String NOT NULL、最大200文字
content String NOT NULL、最大5000文字
author String NOT NULL、最大50文字
createdAt LocalDateTime 作成日時(自動設定)
updatedAt LocalDateTime 更新日時(自動設定)

要件

  1. DTO パターンを使い、ArticleCreateRequestArticleUpdateRequestArticleResponse を Record で実装する
  2. CRUD + ページネーション + 著者名検索を実装する
  3. ステータスコードを適切に使い分ける(201 Created、204 No Content 等)

API仕様

メソッド URI 説明
GET /api/articles?page=0&size=10 記事一覧(ページネーション付き)
GET /api/articles?author=田中 著者名で検索
GET /api/articles/{id} 記事1件取得
POST /api/articles 記事作成
PUT /api/articles/{id} 記事更新
DELETE /api/articles/{id} 記事削除
模範解答

Article.javacom.example.hellospring.entityパッケージ)

package com.example.hellospring.entity;

import java.time.LocalDateTime;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;

@Entity
@Table(name = "articles")
public class Article {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 200)
    private String title;

    @Column(nullable = false, length = 5000)
    private String content;

    @Column(nullable = false, length = 50)
    private String author;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;

    public Article() {
    }

    public Article(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }

    @PrePersist
    protected void onCreate() {
        this.createdAt = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        this.updatedAt = LocalDateTime.now();
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }

    public String getAuthor() {
        return author;
    }

    public void setAuthor(String author) {
        this.author = author;
    }

    public LocalDateTime getCreatedAt() {
        return createdAt;
    }

    public void setCreatedAt(LocalDateTime createdAt) {
        this.createdAt = createdAt;
    }

    public LocalDateTime getUpdatedAt() {
        return updatedAt;
    }

    public void setUpdatedAt(LocalDateTime updatedAt) {
        this.updatedAt = updatedAt;
    }
}

ArticleRepository.javacom.example.hellospring.repositoryパッケージ)

package com.example.hellospring.repository;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

import com.example.hellospring.entity.Article;

public interface ArticleRepository extends JpaRepository<Article, Long> {

    Page<Article> findByAuthorContaining(String author, Pageable pageable);
}

ArticleCreateRequest.javacom.example.hellospring.dtoパッケージ)

package com.example.hellospring.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record ArticleCreateRequest(
        @NotBlank(message = "タイトルは必須です")
        @Size(max = 200, message = "タイトルは200文字以内で入力してください")
        String title,

        @NotBlank(message = "本文は必須です")
        @Size(max = 5000, message = "本文は5000文字以内で入力してください")
        String content,

        @NotBlank(message = "著者名は必須です")
        @Size(max = 50, message = "著者名は50文字以内で入力してください")
        String author
) {
}

ArticleUpdateRequest.javacom.example.hellospring.dtoパッケージ)

package com.example.hellospring.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record ArticleUpdateRequest(
        @NotBlank(message = "タイトルは必須です")
        @Size(max = 200, message = "タイトルは200文字以内で入力してください")
        String title,

        @NotBlank(message = "本文は必須です")
        @Size(max = 5000, message = "本文は5000文字以内で入力してください")
        String content,

        @NotBlank(message = "著者名は必須です")
        @Size(max = 50, message = "著者名は50文字以内で入力してください")
        String author
) {
}

ArticleResponse.javacom.example.hellospring.dtoパッケージ)

package com.example.hellospring.dto;

import java.time.LocalDateTime;

import com.example.hellospring.entity.Article;

public record ArticleResponse(
        Long id,
        String title,
        String content,
        String author,
        LocalDateTime createdAt,
        LocalDateTime updatedAt
) {
    public static ArticleResponse from(Article article) {
        return new ArticleResponse(
                article.getId(),
                article.getTitle(),
                article.getContent(),
                article.getAuthor(),
                article.getCreatedAt(),
                article.getUpdatedAt()
        );
    }
}

ArticleService.javacom.example.hellospring.serviceパッケージ)

package com.example.hellospring.service;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.example.hellospring.entity.Article;
import com.example.hellospring.repository.ArticleRepository;

@Service
public class ArticleService {

    private final ArticleRepository articleRepository;

    public ArticleService(ArticleRepository articleRepository) {
        this.articleRepository = articleRepository;
    }

    public Page<Article> findAll(Pageable pageable) {
        return articleRepository.findAll(pageable);
    }

    public Page<Article> searchByAuthor(String author, Pageable pageable) {
        return articleRepository.findByAuthorContaining(author, pageable);
    }

    public Article findById(Long id) {
        return articleRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException(
                        "記事が見つかりません(ID: " + id + ")"));
    }

    @Transactional
    public Article create(String title, String content, String author) {
        Article article = new Article(title, content, author);
        return articleRepository.save(article);
    }

    @Transactional
    public Article update(Long id, String title, String content, String author) {
        Article article = findById(id);
        article.setTitle(title);
        article.setContent(content);
        article.setAuthor(author);
        return articleRepository.save(article);
    }

    @Transactional
    public void delete(Long id) {
        Article article = findById(id);
        articleRepository.delete(article);
    }
}

ArticleApiController.javacom.example.hellospring.controllerパッケージ)

package com.example.hellospring.controller;

import java.net.URI;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.example.hellospring.dto.ArticleCreateRequest;
import com.example.hellospring.dto.ArticleResponse;
import com.example.hellospring.dto.ArticleUpdateRequest;
import com.example.hellospring.entity.Article;
import com.example.hellospring.service.ArticleService;

@RestController
@RequestMapping("/api/articles")
public class ArticleApiController {

    private final ArticleService articleService;

    public ArticleApiController(ArticleService articleService) {
        this.articleService = articleService;
    }

    @GetMapping
    public ResponseEntity<Page<ArticleResponse>> getAll(
            @RequestParam(required = false) String author,
            @PageableDefault(size = 10, sort = "id") Pageable pageable) {

        Page<Article> articles;
        if (author != null && !author.isBlank()) {
            articles = articleService.searchByAuthor(author, pageable);
        } else {
            articles = articleService.findAll(pageable);
        }

        Page<ArticleResponse> response = articles.map(ArticleResponse::from);
        return ResponseEntity.ok(response);
    }

    @GetMapping("/{id}")
    public ResponseEntity<ArticleResponse> getById(@PathVariable Long id) {
        Article article = articleService.findById(id);
        return ResponseEntity.ok(ArticleResponse.from(article));
    }

    @PostMapping
    public ResponseEntity<ArticleResponse> create(
            @Validated @RequestBody ArticleCreateRequest request) {
        Article article = articleService.create(
                request.title(), request.content(), request.author());
        ArticleResponse response = ArticleResponse.from(article);

        URI location = URI.create("/api/articles/" + article.getId());
        return ResponseEntity.created(location).body(response);
    }

    @PutMapping("/{id}")
    public ResponseEntity<ArticleResponse> update(
            @PathVariable Long id,
            @Validated @RequestBody ArticleUpdateRequest request) {
        Article article = articleService.update(
                id, request.title(), request.content(), request.author());
        return ResponseEntity.ok(ArticleResponse.from(article));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        articleService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

動作確認:

# 記事を作成
curl -X POST http://localhost:8080/api/articles \
  -H "Content-Type: application/json" \
  -d '{"title": "Spring Boot入門", "content": "Spring Bootの基礎を解説します。", "author": "田中太郎"}'

# 一覧取得(ページネーション)
curl "http://localhost:8080/api/articles?page=0&size=5&sort=createdAt,desc"

# 著者名で検索
curl "http://localhost:8080/api/articles?author=田中"

# 更新
curl -X PUT http://localhost:8080/api/articles/1 \
  -H "Content-Type: application/json" \
  -d '{"title": "Spring Boot入門(改訂版)", "content": "内容を更新しました。", "author": "田中太郎"}'

# 削除
curl -X DELETE http://localhost:8080/api/articles/1

問題3:ネストリソース(ユーザー → 注文 → 注文明細) ⭐⭐⭐

以下の仕様で、ネストリソースを含むAPIを設計・実装してください。

エンティティ

エンティティ フィールド 制約
Order id Long 主キー、自動採番
userId Long NOT NULL(ユーザーID)
orderDate LocalDate NOT NULL
status String NOT NULL、最大20文字(PENDING / CONFIRMED / SHIPPED / DELIVERED)
OrderItem id Long 主キー、自動採番
orderId Long NOT NULL(注文ID)
productName String NOT NULL、最大100文字
quantity int NOT NULL
unitPrice int NOT NULL

要件

  1. DTO パターンを使い、Record で Request DTO / Response DTO を実装する
  2. 以下のエンドポイントを実装する
メソッド URI 説明
GET /api/users/{userId}/orders 特定ユーザーの注文一覧(ページネーション付き)
GET /api/users/{userId}/orders/{orderId} 特定ユーザーの注文1件取得
POST /api/users/{userId}/orders 注文作成
GET /api/users/{userId}/orders/{orderId}/items 注文明細一覧取得
POST /api/users/{userId}/orders/{orderId}/items 注文明細追加
  1. OrderResponse には注文の合計金額(totalAmount:各明細の quantity * unitPrice の合計)を含める

この問題は設計力を問う応用問題です。JPA のリレーション(@ManyToOne / @OneToMany)は使わず、userId / orderId をフィールドとして保持するシンプルな設計で実装してください。JPA リレーションの詳細は今後の回で扱います。

模範解答

Order.javacom.example.hellospring.entityパッケージ)

Order は SQL の予約語であるため、@Table(name = "orders") でテーブル名を明示的に指定する必要があります。

package com.example.hellospring.entity;

import java.time.LocalDate;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "user_id", nullable = false)
    private Long userId;

    @Column(name = "order_date", nullable = false)
    private LocalDate orderDate;

    @Column(nullable = false, length = 20)
    private String status;

    public Order() {
    }

    public Order(Long userId, LocalDate orderDate, String status) {
        this.userId = userId;
        this.orderDate = orderDate;
        this.status = status;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public Long getUserId() {
        return userId;
    }

    public void setUserId(Long userId) {
        this.userId = userId;
    }

    public LocalDate getOrderDate() {
        return orderDate;
    }

    public void setOrderDate(LocalDate orderDate) {
        this.orderDate = orderDate;
    }

    public String getStatus() {
        return status;
    }

    public void setStatus(String status) {
        this.status = status;
    }
}

OrderItem.javacom.example.hellospring.entityパッケージ)

package com.example.hellospring.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "order_items")
public class OrderItem {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "order_id", nullable = false)
    private Long orderId;

    @Column(name = "product_name", nullable = false, length = 100)
    private String productName;

    @Column(nullable = false)
    private int quantity;

    @Column(name = "unit_price", nullable = false)
    private int unitPrice;

    public OrderItem() {
    }

    public OrderItem(Long orderId, String productName, int quantity, int unitPrice) {
        this.orderId = orderId;
        this.productName = productName;
        this.quantity = quantity;
        this.unitPrice = unitPrice;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public Long getOrderId() {
        return orderId;
    }

    public void setOrderId(Long orderId) {
        this.orderId = orderId;
    }

    public String getProductName() {
        return productName;
    }

    public void setProductName(String productName) {
        this.productName = productName;
    }

    public int getQuantity() {
        return quantity;
    }

    public void setQuantity(int quantity) {
        this.quantity = quantity;
    }

    public int getUnitPrice() {
        return unitPrice;
    }

    public void setUnitPrice(int unitPrice) {
        this.unitPrice = unitPrice;
    }
}

OrderRepository.javacom.example.hellospring.repositoryパッケージ)

package com.example.hellospring.repository;

import java.util.Optional;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

import com.example.hellospring.entity.Order;

public interface OrderRepository extends JpaRepository<Order, Long> {

    Page<Order> findByUserId(Long userId, Pageable pageable);

    Optional<Order> findByIdAndUserId(Long id, Long userId);
}

OrderItemRepository.javacom.example.hellospring.repositoryパッケージ)

package com.example.hellospring.repository;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;

import com.example.hellospring.entity.OrderItem;

public interface OrderItemRepository extends JpaRepository<OrderItem, Long> {

    List<OrderItem> findByOrderId(Long orderId);
}

OrderCreateRequest.javacom.example.hellospring.dtoパッケージ)

package com.example.hellospring.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record OrderCreateRequest(
        @NotBlank(message = "ステータスは必須です")
        @Size(max = 20, message = "ステータスは20文字以内で入力してください")
        String status
) {
}

OrderItemCreateRequest.javacom.example.hellospring.dtoパッケージ)

package com.example.hellospring.dto;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record OrderItemCreateRequest(
        @NotBlank(message = "商品名は必須です")
        @Size(max = 100, message = "商品名は100文字以内で入力してください")
        String productName,

        @Min(value = 1, message = "数量は1以上で入力してください")
        int quantity,

        @Min(value = 0, message = "単価は0以上で入力してください")
        int unitPrice
) {
}

OrderItemResponse.javacom.example.hellospring.dtoパッケージ)

package com.example.hellospring.dto;

import com.example.hellospring.entity.OrderItem;

public record OrderItemResponse(
        Long id,
        String productName,
        int quantity,
        int unitPrice,
        int subtotal
) {
    public static OrderItemResponse from(OrderItem item) {
        return new OrderItemResponse(
                item.getId(),
                item.getProductName(),
                item.getQuantity(),
                item.getUnitPrice(),
                item.getQuantity() * item.getUnitPrice()
        );
    }
}

OrderResponse.javacom.example.hellospring.dtoパッケージ)

package com.example.hellospring.dto;

import java.time.LocalDate;
import java.util.List;

import com.example.hellospring.entity.Order;
import com.example.hellospring.entity.OrderItem;

public record OrderResponse(
        Long id,
        Long userId,
        LocalDate orderDate,
        String status,
        int totalAmount,
        List<OrderItemResponse> items
) {
    public static OrderResponse from(Order order, List<OrderItem> items) {
        List<OrderItemResponse> itemResponses = items.stream()
                .map(OrderItemResponse::from)
                .toList();

        int total = items.stream()
                .mapToInt(item -> item.getQuantity() * item.getUnitPrice())
                .sum();

        return new OrderResponse(
                order.getId(),
                order.getUserId(),
                order.getOrderDate(),
                order.getStatus(),
                total,
                itemResponses
        );
    }
}

OrderService.javacom.example.hellospring.serviceパッケージ)

package com.example.hellospring.service;

import java.time.LocalDate;
import java.util.List;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.example.hellospring.entity.Order;
import com.example.hellospring.entity.OrderItem;
import com.example.hellospring.repository.OrderItemRepository;
import com.example.hellospring.repository.OrderRepository;

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final OrderItemRepository orderItemRepository;

    public OrderService(OrderRepository orderRepository,
                        OrderItemRepository orderItemRepository) {
        this.orderRepository = orderRepository;
        this.orderItemRepository = orderItemRepository;
    }

    public Page<Order> findByUserId(Long userId, Pageable pageable) {
        return orderRepository.findByUserId(userId, pageable);
    }

    public Order findByIdAndUserId(Long orderId, Long userId) {
        return orderRepository.findByIdAndUserId(orderId, userId)
                .orElseThrow(() -> new IllegalArgumentException(
                        "注文が見つかりません(注文ID: " + orderId
                                + ", ユーザーID: " + userId + ")"));
    }

    public List<OrderItem> findItemsByOrderId(Long orderId) {
        return orderItemRepository.findByOrderId(orderId);
    }

    @Transactional
    public Order createOrder(Long userId, String status) {
        Order order = new Order(userId, LocalDate.now(), status);
        return orderRepository.save(order);
    }

    @Transactional
    public OrderItem addItem(Long orderId, String productName,
                             int quantity, int unitPrice) {
        OrderItem item = new OrderItem(orderId, productName, quantity, unitPrice);
        return orderItemRepository.save(item);
    }
}

OrderApiController.javacom.example.hellospring.controllerパッケージ)

package com.example.hellospring.controller;

import java.net.URI;
import java.util.List;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
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;

import com.example.hellospring.dto.OrderCreateRequest;
import com.example.hellospring.dto.OrderItemCreateRequest;
import com.example.hellospring.dto.OrderItemResponse;
import com.example.hellospring.dto.OrderResponse;
import com.example.hellospring.entity.Order;
import com.example.hellospring.entity.OrderItem;
import com.example.hellospring.service.OrderService;

@RestController
@RequestMapping("/api/users/{userId}/orders")
public class OrderApiController {

    private final OrderService orderService;

    public OrderApiController(OrderService orderService) {
        this.orderService = orderService;
    }

    // 特定ユーザーの注文一覧取得
    @GetMapping
    public ResponseEntity<Page<OrderResponse>> getOrders(
            @PathVariable Long userId,
            @PageableDefault(size = 10, sort = "id") Pageable pageable) {

        Page<Order> orders = orderService.findByUserId(userId, pageable);

        Page<OrderResponse> response = orders.map(order -> {
            List<OrderItem> items = orderService.findItemsByOrderId(order.getId());
            return OrderResponse.from(order, items);
        });

        return ResponseEntity.ok(response);
    }

    // 特定ユーザーの注文1件取得
    @GetMapping("/{orderId}")
    public ResponseEntity<OrderResponse> getOrder(
            @PathVariable Long userId,
            @PathVariable Long orderId) {

        Order order = orderService.findByIdAndUserId(orderId, userId);
        List<OrderItem> items = orderService.findItemsByOrderId(orderId);
        return ResponseEntity.ok(OrderResponse.from(order, items));
    }

    // 注文作成
    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(
            @PathVariable Long userId,
            @Validated @RequestBody OrderCreateRequest request) {

        Order order = orderService.createOrder(userId, request.status());
        OrderResponse response = OrderResponse.from(order, List.of());

        URI location = URI.create("/api/users/" + userId + "/orders/" + order.getId());
        return ResponseEntity.created(location).body(response);
    }

    // 注文明細一覧取得
    @GetMapping("/{orderId}/items")
    public ResponseEntity<List<OrderItemResponse>> getItems(
            @PathVariable Long userId,
            @PathVariable Long orderId) {

        // 注文がそのユーザーのものか検証
        orderService.findByIdAndUserId(orderId, userId);

        List<OrderItem> items = orderService.findItemsByOrderId(orderId);
        List<OrderItemResponse> response = items.stream()
                .map(OrderItemResponse::from)
                .toList();
        return ResponseEntity.ok(response);
    }

    // 注文明細追加
    @PostMapping("/{orderId}/items")
    public ResponseEntity<OrderItemResponse> addItem(
            @PathVariable Long userId,
            @PathVariable Long orderId,
            @Validated @RequestBody OrderItemCreateRequest request) {

        // 注文がそのユーザーのものか検証
        orderService.findByIdAndUserId(orderId, userId);

        OrderItem item = orderService.addItem(
                orderId, request.productName(), request.quantity(), request.unitPrice());
        OrderItemResponse response = OrderItemResponse.from(item);

        URI location = URI.create(
                "/api/users/" + userId + "/orders/" + orderId + "/items/" + item.getId());
        return ResponseEntity.created(location).body(response);
    }
}

動作確認:

# 前提:ユーザーID=1のユーザーが存在すること(本記事のUserApiControllerで事前に作成)

# 1. 注文を作成
curl -X POST http://localhost:8080/api/users/1/orders \
  -H "Content-Type: application/json" \
  -d '{"status": "PENDING"}'

# 2. 注文明細を追加
curl -X POST http://localhost:8080/api/users/1/orders/1/items \
  -H "Content-Type: application/json" \
  -d '{"productName": "プログラミング入門書", "quantity": 2, "unitPrice": 3000}'

curl -X POST http://localhost:8080/api/users/1/orders/1/items \
  -H "Content-Type: application/json" \
  -d '{"productName": "ノートPC", "quantity": 1, "unitPrice": 150000}'

# 3. 注文詳細を取得(合計金額が計算される)
curl http://localhost:8080/api/users/1/orders/1

# レスポンス例:
# {
#   "id": 1,
#   "userId": 1,
#   "orderDate": "2026-04-13",
#   "status": "PENDING",
#   "totalAmount": 156000,
#   "items": [
#     { "id": 1, "productName": "プログラミング入門書", "quantity": 2, "unitPrice": 3000, "subtotal": 6000 },
#     { "id": 2, "productName": "ノートPC", "quantity": 1, "unitPrice": 150000, "subtotal": 150000 }
#   ]
# }

# 4. 注文一覧を取得
curl "http://localhost:8080/api/users/1/orders?page=0&size=5"

# 5. 注文明細一覧を取得
curl http://localhost:8080/api/users/1/orders/1/items

totalAmount の計算値を確認します。

  • プログラミング入門書:2 x 3,000 = 6,000
  • ノートPC:1 x 150,000 = 150,000
  • 合計:6,000 + 150,000 = 156,000

レスポンス例の totalAmount: 156000 と一致します。


まとめ

REST API 設計チェックリスト

実務で REST API を設計する際の確認項目です。

チェック項目 確認内容
URI設計 リソース名は名詞・複数形か? 動詞を含んでいないか?
HTTPメソッド CRUD操作に適切なメソッドを使っているか?
ステータスコード 作成は201、削除は204を返しているか? すべて200で返していないか?
DTO エンティティを直接返していないか? Request/Response は分離されているか?
ページネーション 大量データを返すエンドポイントに Pageable を導入しているか?
バリデーション Request DTO に @NotBlank 等のバリデーションを付けているか?
Location ヘッダー 201 Created のレスポンスに Location ヘッダーを含めているか?

第5回 TODO API vs 第6回 User API の比較

比較項目 第5回 TODO API 第6回 User API
レスポンス エンティティ直接返却 DTO(Record)で返却
ステータスコード ほぼ 200 OK 201 Created / 204 No Content を使い分け
ページネーション なし(findAll() で全件取得) Pageable + Page<T> で対応
バリデーション なし Request DTO に @NotBlank
検索 なし クエリパラメータで対応
Location ヘッダー あり(第5回でも設定済み) あり

次回予告

次回(第7回)は Spring Security(認証・認可) を学びます。ユーザー認証、パスワードのハッシュ化、ロールベースのアクセス制御、CSRF対策などを扱い、今回作成したAPIにセキュリティを追加します。


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?