モダンなAPI開発はSpring Bootで!REST APIの始め方

モダンなAPI開発はSpring Bootで!REST APIの始め方

はじめに:なぜ今、API開発なのか、そしてなぜSpring Bootなのか

現代のソフトウェア開発において、API (Application Programming Interface) は不可欠な要素となっています。スマートフォンアプリ、Webフロントエンド、他のシステムとの連携、マイクロサービスアーキテクチャなど、様々な場所でソフトウェア同士が連携するためにAPIが利用されます。中でもRESTfulな設計原則に基づいたREST APIは、シンプルで分かりやすく、Webの技術(HTTP)をそのまま利用できるため、最も広く普及しているAPIスタイルの一つです。

APIを開発するためのフレームワークは数多く存在しますが、Javaの世界で「モダンなAPI開発」と言えば、まず候補に挙がるのがSpring Bootです。Spring Bootは、Javaにおけるエンタープライズ開発のデファクトスタンダードであるSpring Frameworkを基盤としつつ、その「設定の煩雑さ」を解消し、開発者が本質的なビジネスロジックの開発に集中できるように設計されています。その「おまかせ」の機能(オートコンフィグレーション)と、必要な機能を簡単に組み込める「スターター依存」のおかげで、驚くほど迅速に、かつ堅牢なAPIを開発開始できます。

この記事では、Spring Bootを使ってREST APIをゼロから開発するための具体的なステップを、詳細な説明と豊富なコード例とともに解説します。APIの基本から、Spring Bootプロジェクトの作成、CRUD処理の実装、データ永続化、バリデーション、ドキュメンテーション、簡単なセキュリティ、テストといった、API開発における基本的な要素を一通り網羅します。

「Javaは触ったことがあるけど、Spring Bootは初めて」「API開発の経験がない」という方でも、この記事を読み進めながら実際に手を動かすことで、Spring Bootを使ったモダンなREST API開発の基礎を習得できることを目指します。

1. APIとは? REST APIとは?

APIの基本概念

API(Application Programming Interface)とは、あるソフトウェアの機能やデータを、外部の別のソフトウェアから呼び出して利用するための窓口です。例えば、スマートフォンの天気予報アプリが、気象情報を提供するサーバーから最新の天気データを取得する際に、サーバー側が公開しているAPIを利用します。APIがあることで、個々のソフトウェアは他のソフトウェアの詳細な実装を知ることなく、その機能を利用したりデータを受け取ったりすることができます。これにより、システム全体の疎結合化や、機能の再利用が促進されます。

REST APIの原則(RESTfulの概念)

REST(Representational State Transfer)は、Roy Fielding氏によって提唱されたソフトウェアアーキテクチャスタイルであり、分散システム、特にWebのようなシステムの設計に適しています。RESTfulなAPIとは、このRESTの原則に従って設計されたAPIのことです。REST APIの最も重要な特徴は、システム内の「リソース」(データやサービス)に焦点を当て、そのリソースに対して標準的なHTTPメソッド(GET, POST, PUT, DELETEなど)を使って操作を行う点です。

RESTの主な原則は以下の通りです。

  1. Client-Server: クライアントとサーバーの責務が分離されます。クライアントはユーザーインターフェースやユーザーの状態管理を担当し、サーバーはデータやビジネスロジックを管理します。これにより、それぞれ独立して開発・拡張が可能になります。
  2. Stateless(ステートレス): 各リクエストはそれ自体が完結しており、サーバーはクライアントからの過去のリクエストの状態を保持しません。クライアントが必要なすべての情報(認証情報、リソースの特定情報など)を各リクエストに含める必要があります。これにより、サーバー側の負荷が軽減され、スケーラビリティが向上します。
  3. Cacheable(キャッシュ可能): レスポンスには、そのレスポンスをクライアント側でキャッシュしても良いかどうかの情報を含めることができます。これにより、クライアント側で同じリクエストに対するレスポンスを再利用でき、ネットワークの効率やアプリケーションのパフォーマンスが向上します。
  4. Layered System(階層化システム): クライアントは、自身がどの層(例えば、APIサーバー、認証サーバー、ロードバランサーなど)に接続しているかを知る必要がありません。中間層はリクエストを処理したり、セキュリティを強化したり、ロードバランシングを行ったりすることができます。
  5. Code-On-Demand (Optional): サーバーは、クライアントに実行可能なコード(例えばJavaScript)を提供して、クライアントの機能を拡張することができます。これは必須の原則ではありません。
  6. Uniform Interface(統一インターフェース): これはRESTfulシステムの最も重要な原則の一つで、以下の4つの制約から成ります。
    • Identification of Resources: 各リソースはURI(Uniform Resource Identifier)によって一意に識別されます。
    • Manipulation of Resources Through Representations: クライアントは、リソースの「表現(Representation)」を取得・変更することで、リソース自体を操作します。例えば、JSONやXML形式のデータを受け取り、それを変更して送信することで、サーバー上のリソースを更新します。
    • Self-Descriptive Messages: 各メッセージ(リクエストやレスポンス)は、それ自体がどのように処理されるべきかの情報を含みます。例えば、メディアタイプ(Content-Type, Acceptヘッダー)は、メッセージボディの形式を示します。
    • Hypermedia As The Engine Of Application State (HATEOAS): クライアントは、サーバーから提供されるハイパーメディア(例えば、レスポンス内のリンク)を通じて、次に可能な遷移(操作)を知ることができます。これにより、クライアントとサーバーの間の結合度がさらに低くなります。これはRESTful APIの中でも高度な原則であり、全てのAPIが厳密に遵守しているわけではありません。

これらの原則に従うことで、スケーラブルで保守性の高い、そして多くのクライアントから利用しやすいAPIを設計できます。

REST APIのメリット・デメリット

メリット:
* シンプルで分かりやすい: HTTPメソッドとURIによる操作は直感的です。
* 標準技術の利用: HTTP, URI, MIMEタイプなど、Webの標準技術をそのまま利用できます。
* ステートレス: サーバー側の状態管理が不要なため、スケーリングが容易です。
* キャッシュ可能: パフォーマンス向上に寄与します。
* 多くの言語・フレームワークでサポート: 開発環境を選びません。

デメリット:
* 過剰なデータのやり取り: 一つのリソースを取得するために複数のリクエストが必要になる場合や、必要以上のデータが含まれる場合があります(GraphQLなどがこれを解決しようとしています)。
* 厳密な設計規約がない: RESTfulはスタイルであり、厳密な標準仕様ではないため、API設計の品質は開発者のスキルに依存します。
* HATEOASの採用が難しい: 多くのREST APIはHATEOAS原則を完全に遵守していません。

それでもなお、REST APIはそのシンプルさと普及度から、API開発において最も一般的な選択肢であり続けています。

2. なぜSpring Bootなのか?

Spring FrameworkはJava EE(現在のJakarta EE)の複雑さから開発者を解放するために生まれ、依存性の注入(DI)やアスペクト指向プログラミング(AOP)などの強力な機能を提供してきました。しかし、Spring Framework単体での開発には多くのXML設定やJavaConfigが必要で、プロジェクトの初期セットアップや依存関係の管理が煩雑になるという課題がありました。

Spring Bootは、このSpring Frameworkの課題を解決するために登場しました。Spring Bootは「Convention over Configuration」(設定よりも規約)の思想を強く推進し、Spring Frameworkを「Just Run」(とにかく実行できる状態に)することを目標としています。

Spring BootがモダンなAPI開発に最適な理由をいくつか挙げます。

  1. オートコンフィグレーション (Auto-configuration):
    • クラスパスにあるライブラリや、開発者が定義した設定に基づいて、Springが自動的に必要な設定(Bean定義など)を行います。例えば、Webアプリケーション関連のライブラリがあれば、Tomcatのような組み込みサーバーやSpring MVCの設定を自動で行ってくれます。
    • これにより、開発者はXMLや煩雑なJavaConfigを手書きする必要がほとんどなくなり、アプリケーションのコアな部分(ビジネスロジック)の開発に集中できます。
  2. スターター依存 (Starter Dependencies):
    • spring-boot-starter-web, spring-boot-starter-data-jpa, spring-boot-starter-test のように、特定の機能(Web開発、データ永続化、テストなど)に必要な依存関係をまとめて提供します。
    • これらのスターターを追加するだけで、必要なライブラリとその互換性のあるバージョンが自動的に解決されるため、依存関係管理の手間が大幅に削減されます。
  3. スタンドアロン実行可能Jar:
    • Tomcat, Jetty, Undertowなどの組み込みサーバーが内蔵されており、アプリケーション全体を単一の実行可能Jarファイルとしてビルドできます。
    • これにより、外部のアプリケーションサーバーにデプロイする必要がなくなり、アプリケーションの配布や実行が非常に容易になります。Dockerコンテナとの相性も抜群です。
  4. 開発効率の向上:
    • 上記の特徴により、プロジェクトのセットアップから開発、テスト、デプロイに至るまで、開発サイクル全体が大幅に効率化されます。
    • Spring Boot Actuatorのような運用監視ツールも簡単に組み込めます。
  5. Springエコシステム:
    • Spring Data (データベースアクセス), Spring Security (認証・認可), Spring Cloud (マイクロサービス) など、Springエコシステムの他のプロジェクトとシームレスに連携できます。これらのプロジェクトもSpring Bootと組み合わせることで、より簡単に利用できるようになっています。
  6. 活発なコミュニティと豊富な資料:
    • 世界中に多くのユーザーがおり、公式ドキュメント、チュートリアル、フォーラム、ブログなどが非常に充実しています。困ったときに情報を得やすい環境です。

これらの理由から、Spring Bootは現在、JavaによるREST API開発において最も人気の高いフレームワークの一つとなっています。

3. 開発環境の準備

Spring Bootを使った開発を始めるにあたり、以下のものが必要です。

  1. JDK (Java Development Kit): Javaのコンパイルと実行環境です。Spring Bootのバージョンによって推奨されるJDKバージョンが異なりますが、モダンな開発ではJava 11以降、特にJava 17 (LTS) や Java 21 (LTS) がよく使われます。
  2. ビルドツール (Maven または Gradle): プロジェクトの依存関係管理やビルドを行うツールです。Spring BootはMavenとGradleの両方をサポートしています。ここではMavenを主に使用して説明を進めます。
  3. 統合開発環境 (IDE): コードを書いたり、実行したり、デバッグしたりするためのツールです。Spring Boot開発には以下のIDEがよく利用されます。
    • IntelliJ IDEA: JetBrainsが提供する高機能IDE。Spring Boot開発においては非常に強力なサポートを提供します(Ultimate版が特に強力ですが、Community版でも開発は可能です)。
    • Eclipse: オープンソースのIDE。Spring Tools 4プラグインを入れることでSpring Boot開発を強力にサポートします。
    • VS Code: Microsoftが提供する軽量なエディタ。Java Extension PackなどをインストールすることでJava/Spring Boot開発に対応できます。

これらのツールのインストールとセットアップを行います。

JDKのインストールと設定

OpenJDKやOracle JDKなど、お好みのJDKディストリビューションを選択し、インストールしてください。各OS(Windows, macOS, Linux)に応じた手順でインストールを行い、インストールディレクトリを覚えておきます。

インストール後、コマンドプロンプトまたはターミナルを開き、以下のコマンドを実行して正しくインストールされているか確認します。

bash
java -version
javac -version

JDKの実行ファイル(java, javacなど)がPATH環境変数に含まれていることを確認してください。含まれていない場合は、手動で設定が必要です(設定方法はOSやシェルの種類によって異なります)。

Mavenのインストールと設定

Mavenの公式サイトから最新版をダウンロードし、インストールします。インストール後、MavenのbinディレクトリをPATH環境変数に追加します。

コマンドプロンプトまたはターミナルを開き、以下のコマンドを実行して正しくインストールされているか確認します。

bash
mvn -v

Mavenのバージョン情報が表示されれば成功です。

IDEのセットアップ

お好みのIDEをインストールします。IntelliJ IDEA (Community Edition) は無料で利用でき、Spring Boot開発に必要な機能が十分に揃っているためおすすめです。

IDEを起動し、必要に応じてJavaやMavenのインストールディレクトリを設定します。ほとんどのIDEは、システムにインストールされているJDKやMavenを自動的に検出しますが、もし検出されない場合は設定を確認してください。

4. Spring Bootプロジェクトの作成

Spring Bootプロジェクトを作成する最も簡単な方法は、Spring Initializrを利用することです。Spring Initializrは、必要な設定や依存関係を選択するだけで、すぐに開発を開始できるプロジェクトの骨格を生成してくれるWebサービスです。

Spring InitializrのWebサイト (https://start.spring.io/) にアクセスします。

以下の項目を選択または入力します。

  • Project: Maven Project (または Gradle Project) を選択します。ここではMavenを選択します。
  • Language: Javaを選択します。
  • Spring Boot: 安定版の最新バージョンを選択します(例: 3.2.x)。
  • Project Metadata:
    • Group: プロジェクトのグループID。通常、組織や個人のドメイン名を逆順にしたものを使います(例: com.example)。
    • Artifact: プロジェクトの成果物名。プロジェクト名に合わせて自由に設定します(例: demo-api)。
    • Name: プロジェクト名。Artifactと同じで構いません。
    • Description: プロジェクトの説明。
    • Package name: パッケージ名。GroupとArtifactを組み合わせたものになります(例: com.example.demoapi)。
    • Packaging: Jarを選択します。Spring Bootの組み込みサーバーを利用する場合、Jar形式が標準です。
    • Java: インストールしたJDKのバージョンを選択します(例: 17)。
  • Dependencies: ここでAPI開発に必要な依存関係を追加します。最低限必要なのは以下のスターターです。
    • Spring Web: RESTfulアプリケーションを構築するために必要です。組み込みのTomcatサーバーやSpring MVCなどが含まれます。

「Add Dependency」ボタンをクリックして、「Spring Web」を検索して追加します。

“`
(Spring Initializrの画面イメージ)

PROJECT
Maven Project
Language: Java
Spring Boot: [選択したバージョン, e.g., 3.2.4]

PROJECT METADATA
Group: com.example
Artifact: demo-api
Name: demo-api
Description: Demo project for Spring Boot REST API
Package name: com.example.demoapi
Packaging: Jar
Java: [選択したJavaバージョン, e.g., 17]

DEPENDENCIES
[Add Dependency…]
Selected Dependencies:
Spring Web
“`

上記のように設定したら、「Generate」ボタンをクリックします。.zipファイルがダウンロードされるので、解凍して任意のディレクトリに配置します。

プロジェクト構成の説明

解凍したディレクトリを開くと、以下のようなディレクトリ構成になっているはずです(Mavenの場合)。

demo-api/
├── .gitignore
├── mvnw
├── mvnw.cmd
├── pom.xml
└── src/
├── main/
│ ├── java/
│ │ └── com/example/demoapi/
│ │ └── DemoApiApplication.java <-- アプリケーションのエントリーポイント
│ └── resources/
│ ├── application.properties <-- 設定ファイル
│ ├── static/ <-- 静的ファイル置き場 (今回は使わない)
│ └── templates/ <-- テンプレートファイル置き場 (今回は使わない)
└── test/
└── java/
└── com/example/demoapi/
└── DemoApiApplicationTests.java <-- テストクラス

  • pom.xml: Mavenのプロジェクト設定ファイルです。依存関係、ビルド設定などが記述されています。Spring Initializrで選択した依存関係(spring-boot-starter-webなど)がここに含まれています。
  • src/main/java: Javaのソースコードを配置するディレクトリです。Spring Initializrが生成したDemoApiApplication.javaがここにあります。
  • src/main/resources: 設定ファイルや静的ファイル、テンプレートファイルなどを配置するディレクトリです。application.properties(またはapplication.yml)はSpring Bootの設定ファイルとして非常に重要です。
  • src/test/java: テストコードを配置するディレクトリです。
  • mvnw, mvnw.cmd: Maven Wrapperスクリプトです。これを使うと、Mavenがインストールされていない環境でも、プロジェクトごとに定義されたMavenバージョンでビルドなどを実行できます。

IDEでこのプロジェクトを開きます。IDEがpom.xmlを認識し、依存関係のダウンロードやプロジェクトの設定を自動で行うはずです。

5. 最小限のREST APIを作成

プロジェクトの骨格ができたら、早速最小限のREST APIを作成してみましょう。ここでは、簡単な「Hello, World!」を返すAPIエンドポイントを作成します。

src/main/java/com/example/demoapi/ ディレクトリに新しいJavaクラスを作成します。クラス名は HelloController としましょう。

“`java
// src/main/java/com/example/demoapi/HelloController.java
package com.example.demoapi;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController // 1. このクラスがRESTコントローラーであることを示す
public class HelloController {

@GetMapping("/hello") // 2. HTTP GETリクエストを"/hello"パスにマッピングする
public String sayHello() { // 3. マッピングされたリクエストを処理するメソッド
    return "Hello, Spring Boot API!"; // 4. レスポンスボディとして返す文字列
}

}
“`

コードの解説

  1. @RestController: このアノテーションは、このクラスがコントローラーであり、かつそのすべてのメソッドがデフォルトでHTTPレスポンスボディを直接返すことを示します(@Controller@ResponseBodyを組み合わせたもの)。REST APIの開発においては、クライアントにデータ(JSONやXMLなど)を返すことが一般的であるため、@RestControllerがよく使われます。
  2. @GetMapping("/hello"): このアノテーションは、HTTP GETリクエストを特定のパスにマッピングするために使用されます。ここでは、 /hello というパスへのGETリクエストが、このアノテーションが付与されたメソッド(sayHello)によって処理されるように指定しています。@RequestMapping(method = RequestMethod.GET, value = "/hello") の省略形です。他のHTTPメソッドに対応するアノテーションとして @PostMapping, @PutMapping, @DeleteMapping などがあります。
  3. public String sayHello(): リクエストを処理するメソッドです。このメソッドの戻り値は、通常、HTTPレスポンスボディとしてクライアントに返されます。@RestControllerを使っているため、戻り値の文字列 "Hello, Spring Boot API!" はそのままレスポンスボディになります。Spring BootはデフォルトでJacksonライブラリを含んでいるため、オブジェクトを返すと自動的にJSONに変換してくれますが、ここでは単純な文字列を返しています。

アプリケーションの実行

プロジェクトのルートディレクトリ(pom.xmlがある場所)で、コマンドプロンプトまたはターミナルを開きます。以下のMaven Wrapperコマンドを実行してアプリケーションを起動します。

bash
./mvnw spring-boot:run

(Windowsの場合は mvnw spring-boot:run

Spring Bootがアプリケーションをビルドし、組み込みのTomcatサーバーを起動します。コンソール出力に以下のようなログが表示されるはずです。

... (他のログ) ...
2023-10-27T10:00:00.000+09:00 INFO 12345 --- [ main] c.e.d.DemoApiApplication : Started DemoApiApplication in X.XXX seconds (JVM running for Y.YYY)
2023-10-27T10:00:00.000+09:00 INFO 12345 --- [nio-8080-exec-0] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
2023-10-27T10:00:00.000+09:00 INFO 12345 --- [nio-8080-exec-0] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2023-10-27T10:00:00.000+09:00 INFO 12345 --- [nio-8080-exec-0] o.s.web.servlet.DispatcherServlet : Completed initialization in XXX ms

特に重要なのは、アプリケーションが起動したこと、そして組み込みサーバーがデフォルトのポート 8080 で待ち受けていることを示すログです。

動作確認

アプリケーションが起動したら、APIエンドポイントにアクセスして動作を確認できます。

  • Webブラウザ: ブラウザのアドレスバーに http://localhost:8080/hello と入力してアクセスします。画面に "Hello, Spring Boot API!" と表示されれば成功です。
  • curlコマンド: ターミナルを開き、以下のコマンドを実行します。

    bash
    curl http://localhost:8080/hello

    出力: Hello, Spring Boot API!

  • PostmanなどのAPIクライアント: Postmanなどのツールを利用して、GETリクエストを http://localhost:8080/hello に対して送信します。レスポンスボディに "Hello, Spring Boot API!" が含まれていることを確認します。

これで、最初のSpring Boot REST APIが正常に動作しました。

アプリケーションを停止するには、アプリケーションを起動したターミナルで Ctrl+C を押します。

6. 基本的なCRUD操作を実装

次に、より実践的な例として、簡単な「アイテム(Item)」を管理するREST APIを実装します。アイテムはIDと名前を持つとします。ここでは、データベースを使わず、メモリ上のリストでデータを一時的に保持します。

データモデルの作成

アイテムを表すシンプルなJavaクラス(POJO: Plain Old Java Object)を作成します。

src/main/java/com/example/demoapi/ ディレクトリに Item.java を作成します。

“`java
// src/main/java/com/example/demoapi/Item.java
package com.example.demoapi;

// モダンなJavaではrecordが簡潔
// import java.util.Objects; // 必要であればequals/hashCode/toStringの実装に使う

public class Item { // または public record Item(Long id, String name) { … } Java 14+

private Long id;
private String name;

// コンストラクタ (IDなし、新規作成用)
public Item(String name) {
    this.name = name;
}

// コンストラクタ (IDあり、取得/更新用)
public Item(Long id, String name) {
    this.id = id;
    this.name = name;
}

// デフォルトコンストラクタ (Spring MVCのデータバインディングに必要)
public Item() {
}

// GetterとSetter
public Long getId() {
    return id;
}

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

public String getName() {
    return name;
}

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

// オブジェクトの内容を確認しやすいようにtoStringをオーバーライドしておくと便利
@Override
public String toString() {
    return "Item{" +
           "id=" + id +
           ", name='" + name + '\'' +
           '}';
}

// 必要に応じてequals()とhashCode()もオーバーライドする(後述のデータ構造で重要になる場合がある)
/*
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Item item = (Item) o;
    return Objects.equals(id, item.id); // IDが同じなら同じアイテムとみなす場合
}

@Override
public int hashCode() {
    return Objects.hash(id);
}
*/

}
“`

ここではJava 14以降で使える record を使うと、さらに簡潔に記述できます。

“`java
// Java 14以降の場合
// src/main/java/com/example/demoapi/Item.java
package com.example.demoapi;

// recordは自動的にimmutable fields, constructor, accessors, equals(), hashCode(), toString()を生成
public record Item(Long id, String name) {
// 必要に応じてカスタムコンストラクタやメソッドを追加可能
}
“`

以降の説明では、Java 14以前でも使える従来のクラス形式で進めますが、recordも便利なので覚えておきましょう。

データの保持(インメモリリスト)

今回はデータベースを使わないので、データを保持するためのシンプルなクラスを作成します。ここでは、静的な List と ID 生成用のカウンターを使います。

src/main/java/com/example/demoapi/ ディレクトリに ItemRepository.java を作成します。これは厳密にはリポジトリパターンに従っていませんが、データアクセス層の役割を模倣します。

“`java
// src/main/java/com/example/demoapi/ItemRepository.java
package com.example.demoapi;

import org.springframework.stereotype.Repository; // 1. Springのコンポーネントとして登録

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong; // 2. スレッドセーフなカウンター

@Repository // 1
public class ItemRepository {

private final List<Item> items = new ArrayList<>(); // 3. データを保持するリスト
private final AtomicLong counter = new AtomicLong(); // 2, 4. ID生成用カウンター

// 便宜的に初期データを追加
public ItemRepository() {
    save(new Item("Sample Item 1"));
    save(new Item("Sample Item 2"));
}

// 全アイテムを取得 (Read - All)
public List<Item> findAll() {
    return new ArrayList<>(items); // 5. リストのコピーを返す(外部からの変更を防ぐ)
}

// IDを指定してアイテムを取得 (Read - One)
public Optional<Item> findById(Long id) {
    return items.stream()
                .filter(item -> item.getId().equals(id))
                .findFirst(); // 6. IDが一致する最初の要素をOptionalで返す
}

// 新規アイテムを保存または既存アイテムを更新 (Create / Update)
public Item save(Item item) {
    if (item.getId() == null) {
        // 新規作成の場合
        Long newId = counter.incrementAndGet(); // 7. 新しいIDを生成
        Item newItem = new Item(newId, item.getName());
        items.add(newItem);
        return newItem;
    } else {
        // 更新の場合 (今回は簡易的に、ID一致で置き換える)
        Optional<Item> existing = findById(item.getId());
        if (existing.isPresent()) {
            Item existingItem = existing.get();
            existingItem.setName(item.getName()); // 名前を更新
            // itemsリスト内の参照を更新しているため、リスト自体を操作する必要はない
            return existingItem;
        } else {
            // 指定されたIDのアイテムが存在しない場合は新規作成?それともエラー?
            // 今回は簡単のため、存在しないIDでの更新は無視 (本来はエラーを返すか、新規作成)
            // デモでは新規作成と同じロジックを採用する(簡易化のため)
             Long newId = counter.incrementAndGet();
             Item newItem = new Item(newId, item.getName());
             items.add(newItem);
             return newItem; // 新規作成されたアイテムを返す(注意:更新とは異なる挙動)

             // より厳密な更新を行う場合:
             // throw new IllegalArgumentException("Item with id " + item.getId() + " not found for update.");
        }
    }
}

// IDを指定してアイテムを削除 (Delete)
public boolean deleteById(Long id) {
    return items.removeIf(item -> item.getId().equals(id)); // 8. IDが一致するアイテムを削除
}

}
“`

コードの解説

  1. @Repository: このアノテーションは、このクラスがデータアクセス層のコンポーネントであることを示します。SpringがこのクラスをBeanとして管理し、他のコンポーネントから依存性注入(DI)できるようになります。
  2. AtomicLong: 複数のスレッドから安全にカウンターを操作するためのクラスです。ここではアイテムのIDを生成するために使用します。
  3. List<Item> items: アプリケーションの実行中にアイテムを保持するための ArrayList です。アプリケーションを再起動するとデータは失われます。
  4. AtomicLong counter: 新しいアイテムに一意のIDを割り当てるためのカウンターです。
  5. findAll(): items リストのコピーを返します。これにより、呼び出し元が返されたリストを操作しても、リポジトリ内の元のリストに影響を与えないようにします。
  6. findById(Long id): Java Stream APIの filterfindFirst を使って、指定されたIDを持つアイテムを検索します。結果は Optional<Item> で返されます。Optional を使うことで、アイテムが見つからなかった場合に null を返すのではなく、Optional.empty() を返すことが推奨されます。これにより、呼び出し元は isPresent() などを使って安全に結果を扱えます。
  7. save(Item item):
    • item.getId() == null の場合は新規作成とみなし、counter をインクリメントして新しいIDを生成し、リストに追加します。
    • item.getId() != null の場合は更新とみなします。ここでは簡易的に、リストから該当アイテムを見つけて名前を更新していますが、リスト内の参照を直接操作しているため、List::set などで置き換えるよりもシンプルになっています。ただし、この実装はリスト内を線形探索するため、アイテム数が増えるとパフォーマンスが悪化します。本来、永続化層ではIDによる効率的な検索(マップやデータベースインデックス)が行われます。また、指定されたIDが存在しない場合の挙動は、アプリケーションの要件によってエラーを返すか、新規作成するかを選択する必要があります。ここでは新規作成に近い挙動になっていますが、これはデモ用簡易実装であることに注意してください。
  8. deleteById(Long id): List::removeIf を使って、指定されたIDを持つアイテムをリストから削除します。削除が成功したかどうかがbooleanで返されます。

コントローラーの実装(CRUDエンドポイント)

次に、この ItemRepository を利用して、CRUD操作に対応するREST APIエンドポイントを実装します。既存の HelloController を修正する代わりに、新しい ItemController.java を作成するのが一般的です。

src/main/java/com/example/demoapi/ ディレクトリに ItemController.java を作成します。

“`java
// src/main/java/com/example/demoapi/ItemController.java
package com.example.demoapi;

import org.springframework.http.HttpStatus; // HTTPステータスコードを返すのに使用
import org.springframework.http.ResponseEntity; // レスポンス全体をカスタマイズするのに使用
import org.springframework.web.bind.annotation.*; // RESTコントローラー関連のアノテーション

import java.util.List;
import java.util.Optional;

@RestController
@RequestMapping(“/api/items”) // 1. このコントローラー内の全てのエンドポイントのベースパス
public class ItemController {

private final ItemRepository itemRepository; // 2. ItemRepositoryを注入するためのフィールド

// 3. コンストラクタインジェクション
public ItemController(ItemRepository itemRepository) {
    this.itemRepository = itemRepository;
}

// 全アイテム取得 (GET /api/items)
@GetMapping
public List<Item> getAllItems() { // 戻り値のList<Item>は自動的にJSON配列に変換される
    return itemRepository.findAll();
}

// ID指定でアイテム取得 (GET /api/items/{id})
@GetMapping("/{id}") // 4. パス変数 {id} を定義
public ResponseEntity<Item> getItemById(@PathVariable Long id) { // 5. @PathVariableでパス変数を受け取る
    Optional<Item> item = itemRepository.findById(id);
    // 6. Optionalを使って、アイテムが見つかったかどうかでレスポンスを分ける
    if (item.isPresent()) {
        return ResponseEntity.ok(item.get()); // 7. 見つかった場合は200 OKとアイテムを返す
    } else {
        return ResponseEntity.notFound().build(); // 8. 見つからなかった場合は404 Not Foundを返す
    }
}

// 新規アイテム作成 (POST /api/items)
@PostMapping
@ResponseStatus(HttpStatus.CREATED) // 9. 作成成功時に201 Createdを返す
public Item createItem(@RequestBody Item item) { // 10. リクエストボディをItemオブジェクトに変換
    return itemRepository.save(item);
}

// アイテム更新 (PUT /api/items/{id})
@PutMapping("/{id}")
public ResponseEntity<Item> updateItem(@PathVariable Long id, @RequestBody Item item) {
    // リクエストボディのItemオブジェクトのIDをパス変数のIDで上書き (念のため)
    Item itemToUpdate = new Item(id, item.getName()); // recordなら new Item(id, item.name());
    Item updatedItem = itemRepository.save(itemToUpdate); // saveメソッドで更新処理(または新規作成)

    // Repositoryのsaveメソッドが、更新できたか新規作成したかによって異なるItemオブジェクトを返す場合、
    // その情報に基づいてステータスコードを変える必要があるかもしれません。
    // 現在のRepository実装は簡易的なため、ここでは更新されたItemを返すことにします。
    // より厳密には、更新成功なら200 OK、存在しないIDで新規作成されたなら201 Createdなどを返すべきです。
    // ここでは単純に200 OKを返す例としています。
    return ResponseEntity.ok(updatedItem);
}

// アイテム削除 (DELETE /api/items/{id})
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) // 11. 削除成功時に204 No Contentを返す
public void deleteItem(@PathVariable Long id) {
    boolean deleted = itemRepository.deleteById(id);
    // 12. 削除成功/失敗でステータスコードを分けることもできる
    // if (!deleted) {
    //    throw new ResourceNotFoundException("Item with id " + id + " not found"); // 後述のエラーハンドリングで扱う
    // }
}

}
“`

コードの解説

  1. @RequestMapping("/api/items"): このコントローラー内のすべてのエンドポイントのベースパスを /api/items に設定します。例えば、@GetMapping/api/items@GetMapping("/{id}")/api/items/{id} というパスに対応します。
  2. private final ItemRepository itemRepository;: ItemRepository のインスタンスを保持するフィールドです。final キーワードを付けることで、一度設定されたら変更されないことを示します。
  3. コンストラクタインジェクション: ItemController のコンストラクタの引数に ItemRepository を指定しています。Springは、@Repository アノテーションが付いた ItemRepository のインスタンスを自動的に生成し、このコンストラクタを呼び出す際に引数として渡してくれます。これがSpringの依存性注入(DI)の仕組みです。コンストラクタインジェクションは、依存関係が必須であることを明示でき、テストしやすいなどのメリットがあるため、推奨される方法です。
  4. @GetMapping("/{id}"): URIパスの一部をテンプレート変数 {id} として定義します。
  5. @PathVariable Long id: @PathVariable アノテーションを使うことで、URIパスのテンプレート変数 {id} の値をメソッドの引数 id として受け取ることができます。Springはパス変数の値を適切な型(ここでは Long)に自動的に変換します。
  6. Optional<Item> item = itemRepository.findById(id);: リポジトリからID指定でアイテムを取得します。findByIdOptional を返すため、結果が存在するかどうかを安全にチェックできます。
  7. ResponseEntity.ok(item.get()): ResponseEntity クラスを使うと、HTTPステータスコード、ヘッダー、レスポンスボディを詳細に制御できます。ResponseEntity.ok() はステータスコード200 OKを表し、引数に指定したオブジェクト(ここでは item.get() で取得した Item)がレスポンスボディになります。
  8. ResponseEntity.notFound().build(): ステータスコード404 Not Foundを表します。.build() はボディを持たないレスポンスを生成します。
  9. @ResponseStatus(HttpStatus.CREATED): createItem メソッドが正常に完了した場合に、デフォルトの200 OKではなく、201 Createdステータスコードを返すように指定します。これは、新しいリソースが作成されたことをクライアントに伝えるためのRESTfulな慣習です。
  10. @RequestBody Item item: HTTPリクエストのボディに含まれるデータ(通常はJSON)を、メソッドの引数である Item オブジェクトに自動的に変換します。Spring BootはJacksonライブラリを使って、JSONとJavaオブジェクト間のマッピングを行います。クライアントはPOSTリクエストのボディにJSON形式のアイテムデータを含める必要があります。
  11. @ResponseStatus(HttpStatus.NO_CONTENT): deleteItem メソッドが正常に完了した場合に、204 No Contentステータスコードを返すように指定します。これは、リクエストは成功したが、レスポンスボディに返すコンテンツがない場合に使われます。DELETEリクエストの成功に対する一般的なレスポンスです。
  12. 削除成功/失敗のハンドリング: 簡易実装では削除に失敗した場合(存在しないIDを指定した場合など)も204を返していますが、本来は削除対象が見つからない場合は404 Not Foundなどを返すのがより正確なRESTfulな振る舞いです。後述のエラーハンドリングのセクションでこの対応方法を説明します。

動作確認 (CRUD)

アプリケーションを再起動します(./mvnw spring-boot:run)。

PostmanやcurlなどのAPIクライアントを使用して、以下の操作を試してみてください。

  1. 全アイテム取得 (GET)

    • リクエスト: GET http://localhost:8080/api/items
    • レスポンス (Status: 200 OK, Body: JSON配列):
      json
      [
      {"id": 1, "name": "Sample Item 1"},
      {"id": 2, "name": "Sample Item 2"}
      ]
  2. ID指定でアイテム取得 (GET)

    • リクエスト: GET http://localhost:8080/api/items/1
    • レスポンス (Status: 200 OK, Body: JSONオブジェクト):
      json
      {"id": 1, "name": "Sample Item 1"}
    • 存在しないIDの場合: GET http://localhost:8080/api/items/99
    • レスポンス (Status: 404 Not Found)
  3. 新規アイテム作成 (POST)

    • リクエスト: POST http://localhost:8080/api/items
    • Headers: Content-Type: application/json
    • Body (raw, JSON):
      json
      {
      "name": "New Item From API"
      }
    • レスポンス (Status: 201 Created, Body: 作成されたアイテムのJSON):
      json
      {"id": 3, "name": "New Item From API"}
    • もう一度全件取得すると、ID=3のアイテムが追加されていることを確認できます。
  4. アイテム更新 (PUT)

    • リクエスト: PUT http://localhost:8080/api/items/1
    • Headers: Content-Type: application/json
    • Body (raw, JSON):
      json
      {
      "name": "Updated Item 1 Name"
      // IDフィールドを含めることもできるが、パス変数のIDが優先されることが多い
      }
    • レスポンス (Status: 200 OK, Body: 更新されたアイテムのJSON):
      json
      {"id": 1, "name": "Updated Item 1 Name"}
    • ID=1のアイテムの名前が更新されていることを確認できます。
  5. アイテム削除 (DELETE)

    • リクエスト: DELETE http://localhost:8080/api/items/1
    • レスポンス (Status: 204 No Content)
    • もう一度ID=1のアイテムを取得しようとすると、404 Not Foundになることを確認できます。

これで、基本的なCRUD操作に対応したREST APIが完成しました。ただし、この状態ではデータはメモリ上に一時的に保存されるだけで、アプリケーションを停止すると失われます。

7. データ永続化 (JPA/H2 Database)

実際のAPI開発では、データを永続的に保存する必要があります。Spring Bootは、様々なデータベースとの連携を強力にサポートしています。ここでは、開発・テスト用途で手軽に使えるインメモリデータベース H2 Database と、Javaの標準ORM(オブジェクト関係マッピング)仕様であるJPA(Jakarta Persistence API)をSpring Data JPAを使って利用する例を示します。

依存関係の追加

pom.xml を開いて、以下の依存関係を追加します。

xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>

  • spring-boot-starter-data-jpa: Spring Data JPAを使うために必要な依存関係のセットです。Hibernate(JPAの実装の一つ)やSpring Dataコアなどが含まれます。
  • h2: H2 Databaseの依存関係です。scope=runtime は、実行時のみ必要であることを示します(コンパイル時には不要)。

pom.xml を変更したら、IDEでMavenプロジェクトをリロードするか、コマンドラインで ./mvnw clean install など実行して依存関係を解決します。

エンティティクラスの作成

データベースのテーブルに対応するクラスを「エンティティ」と呼びます。先ほど作成した Item クラスを、JPAエンティティとして使えるように修正します。

src/main/java/com/example/demoapi/Item.java を修正します。

“`java
// src/main/java/com/example/demoapi/Item.java
package com.example.demoapi;

import jakarta.persistence.*; // 1. JPA関連のアノテーション

@Entity // 2. このクラスがJPAエンティティであることを示す
@Table(name = “items”) // 3. 対応するテーブル名を指定(省略可、デフォルトはクラス名)
public class Item {

@Id // 4. 主キーであることを示す
@GeneratedValue(strategy = GenerationType.IDENTITY) // 5. 主キーがデータベースによって自動生成される方法を指定
private Long id;

private String name; // 6. カラムに対応(@Columnアノテーションも使えるが省略可)

// JPAエンティティには引数なしのpublicまたはprotectedなコンストラクタが必要
public Item() {
}

// 新規作成用コンストラクタ(IDは自動生成されるため含めない)
public Item(String name) {
    this.name = name;
}

// Getter (Setterは通常必要だが、JPAで読み込む際は不要な場合もある。今回は更新もあるので必要)
public Long getId() {
    return id;
}

// Setter (更新処理で必要)
public void setId(Long id) {
    this.id = id;
}

public String getName() {
    return name;
}

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

@Override
public String toString() {
    return "Item{" +
           "id=" + id +
           ", name='" + name + '\'' +
           '}';
}

// 必要に応じてequals/hashCode
// エンティティの場合、IDに基づいて比較するのが一般的だが、
// 永続化前のエンティティと永続化後のエンティティの比較などに注意が必要
// @Override
// public boolean equals(Object o) { ... }
// @Override
// public int hashCode() { ... }

}
“`

コードの解説

  1. jakarta.persistence.*: JPAの標準アノテーションが定義されているパッケージです。Spring Boot 3系からはJakarta EE仕様に移行したため、javax.persistence ではなく jakarta.persistence を使用します。
  2. @Entity: このアノテーションをクラスに付与することで、このクラスがJPAエンティティであり、データベースのテーブルに対応付けられることをHibernateなどのJPAプロバイダに伝えます。
  3. @Table(name = "items"): エンティティがマッピングされるデータベーステーブル名を指定します。省略した場合、デフォルトでクラス名(ここでは Item)がテーブル名として使用されます。SQLのキーワードと衝突しないよう、明示的に複数形などのテーブル名を指定することがよくあります。
  4. @Id: そのフィールドがエンティティの主キーであることを示します。
  5. @GeneratedValue(strategy = GenerationType.IDENTITY): 主キーの値がどのように生成されるかを指定します。GenerationType.IDENTITY は、データベース側で自動増分されるシーケンスやIDENTITYカラムを利用することを示します(H2, MySQL, PostgreSQLなどで利用可能)。他の戦略として AUTO, SEQUENCE, TABLE などがあります。
  6. 他のフィールド: @Column アノテーションを省略した場合、フィールド名がそのままカラム名として使用されます。必要に応じて @Column(name = "item_name", nullable = false, length = 255) のように詳細な設定を行うことも可能です。

リポジトリインターフェースの作成

Spring Data JPAは、「リポジトリパターン」を簡単に実装するためのフレームワークです。エンティティクラスに対応するインターフェースを定義し、JpaRepository を継承するだけで、基本的なCRUD操作(保存、検索、削除など)のメソッドを自動的に利用できるようになります。

先ほど作成した ItemRepository.java を削除または名前変更し、代わりに インターフェース として新しく ItemRepository.java を作成します。

“`java
// src/main/java/com/example/demoapi/ItemRepository.java
package com.example.demoapi;

import org.springframework.data.jpa.repository.JpaRepository; // 1. Spring Data JPAのリポジトリインターフェース

// 2. JpaRepository<エンティティクラス, 主キーの型> を継承
public interface ItemRepository extends JpaRepository {

// ここに追加の検索メソッドなどを定義できる (例: nameで検索)
// Optional<Item> findByName(String name); // findByPropertyName の規約に従う
// List<Item> findByNameContaining(String name); // Containingなどのキーワードも使える

}
“`

コードの解説

  1. JpaRepository<T, ID>: Spring Data JPAが提供するインターフェースで、基本的なCRUD操作やページング、ソートなどの機能を提供します。T はエンティティクラスの型(ここでは Item)、ID は主キーの型(ここでは Long)を指定します。
  2. extends JpaRepository<Item, Long>: ItemRepository インターフェースが JpaRepository を継承することで、Spring Data JPAは Item エンティティに対するリポジトリを自動的に生成し、そのインスタンスをSpringのコンテナに登録します。開発者は、このインターフェースを依存性注入で受け取って使用するだけで、save(), findById(), findAll(), deleteById() などのメソッドをすぐに利用できます。これらのメソッドの実装はSpring Data JPAが実行時に生成してくれます。

このように、Spring Data JPAを使うと、データアクセス層の実装コードをほとんど書く必要がなくなります。

H2 Databaseの設定

H2 Databaseは、デフォルトではアプリケーション終了時にデータが失われるインメモリモードで動作します。開発中にデータベースの内容を確認したい場合などには、H2 Databaseのコンソールを有効にすると便利です。

src/main/resources/application.properties ファイルに以下の設定を追加します。

“`properties

H2 Database Settings

spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

JPA/Hibernate Settings (Optional but helpful)

spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update # または create-drop, create
spring.jpa.show-sql=true # 実行されるSQLログを表示
spring.jpa.properties.hibernate.format_sql=true # SQLを整形して表示
“`

  • spring.h2.console.enabled=true: H2 DatabaseのWebコンソールを有効にします。
  • spring.h2.console.path=/h2-console: コンソールにアクセスするためのパスを指定します。アプリケーション起動後、http://localhost:8080/h2-console にアクセスできるようになります。
  • spring.jpa.hibernate.ddl-auto: JPAプロバイダ(Hibernate)がアプリケーション起動時にデータベーススキーマをどのように扱うかを指定します。
    • none: 何もしない(本番環境推奨)
    • validate: スキーマを検証する
    • update: スキーマを更新する(変更を差分適用、開発環境で便利だが注意が必要)
    • create: アプリケーション起動時にスキーマを作成する(既存があれば削除)(開発・テスト環境で便利)
    • create-drop: アプリケーション起動時にスキーマを作成し、終了時に削除する(テスト環境で便利)
      ここでは開発用に update または create を指定します。
  • spring.jpa.show-sql=true, spring.jpa.properties.hibernate.format_sql=true: 実行されるSQL文をコンソールに表示する設定です。デバッグに役立ちます。

H2コンソールにアクセスする際は、JDBC URL、ユーザー名、パスワードの入力を求められますが、特に設定を変更していなければデフォルトのままで接続できます。
* JDBC URL: jdbc:h2:mem:testdb (デフォルト)
* User Name: sa (デフォルト)
* Password: (空白) (デフォルト)

コントローラーの修正

ItemController.java を開き、先ほど作成した ItemRepository インターフェースを注入するように修正します。コンストラクタインジェクションのコードは同じですが、型が変わります。

“`java
// src/main/java/com/example/demoapi/ItemController.java
package com.example.demoapi;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Optional;

@RestController
@RequestMapping(“/api/items”)
public class ItemController {

private final ItemRepository itemRepository; // JPAのリポジトリインターフェースに変わった

public ItemController(ItemRepository itemRepository) { // 注入されるインスタンスはSpring Data JPAが生成したもの
    this.itemRepository = itemRepository;
}

// 全アイテム取得 (GET /api/items)
@GetMapping
public List<Item> getAllItems() {
    return itemRepository.findAll(); // Spring Data JPAが提供するfindAll()
}

// ID指定でアイテム取得 (GET /api/items/{id})
@GetMapping("/{id}")
public ResponseEntity<Item> getItemById(@PathVariable Long id) {
    Optional<Item> item = itemRepository.findById(id); // Spring Data JPAが提供するfindById()
    return item.map(ResponseEntity::ok) // Optional.mapで、存在すれば200 OKを返す
               .orElseGet(() -> ResponseEntity.notFound().build()); // なければ404 Not Foundを返す (Java 8+)
    // これは以下のif-else文と同じ意味です
    // if (item.isPresent()) {
    //     return ResponseEntity.ok(item.get());
    // } else {
    //     return ResponseEntity.notFound().build();
    // }
}

// 新規アイテム作成 (POST /api/items)
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Item createItem(@RequestBody Item item) {
    // 新規作成の場合、クライアントからIDが送られてくることは想定しない
    // 送られてきても無視するか、エラーにするのが一般的
    item.setId(null); // IDをnullに設定し、JPAに新しいエンティティとして扱わせる
    return itemRepository.save(item); // Spring Data JPAが提供するsave()
}

// アイテム更新 (PUT /api/items/{id})
@PutMapping("/{id}")
public ResponseEntity<Item> updateItem(@PathVariable Long id, @RequestBody Item itemDetails) {
    // 指定されたIDのアイテムがデータベースに存在するか確認
    Optional<Item> existingItem = itemRepository.findById(id);

    if (existingItem.isPresent()) {
        Item itemToUpdate = existingItem.get();
        // クライアントから送られてきたデータで既存のアイテムを更新
        itemToUpdate.setName(itemDetails.getName()); // 名前だけ更新する例

        // saveメソッドを呼び出すと、JPAが変更を検知してデータベースを更新する
        Item updatedItem = itemRepository.save(itemToUpdate);
        return ResponseEntity.ok(updatedItem); // 更新されたアイテムを200 OKで返す
    } else {
        // 指定されたIDのアイテムが存在しない場合は404 Not Foundを返す
        return ResponseEntity.notFound().build();
    }
}

// アイテム削除 (DELETE /api/items/{id})
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) // 削除成功時は204 No Contentを返すのがRESTful
public ResponseEntity<Void> deleteItem(@PathVariable Long id) {
    // 指定されたIDのアイテムがデータベースに存在するか確認
    Optional<Item> existingItem = itemRepository.findById(id);

    if (existingItem.isPresent()) {
        // 存在する場合は削除
        itemRepository.deleteById(id); // Spring Data JPAが提供するdeleteById()
        return ResponseEntity.noContent().build(); // 削除成功で204 No Contentを返す
    } else {
        // 存在しない場合は404 Not Foundを返す
        return ResponseEntity.notFound().build();
    }
}

}
“`

コードの変更点解説

  • ItemRepository の型が、手動で実装したクラスからSpring Data JPAが提供するインターフェースに変わりました。
  • 各メソッド内では、itemRepositoryfindAll(), findById(), save(), deleteById() といったSpring Data JPAが自動生成したメソッドを呼び出すだけになりました。
  • getItemById および deleteItem メソッドでは、findById が返す Optional を利用して、対象のリソースが見つからなかった場合に ResponseEntity.notFound().build() (404 Not Found)を返すように修正しました。これにより、よりRESTfulなエラーハンドリングが可能になります。
  • createItem では、新規作成時にクライアントからIDが送られてきても、item.setId(null) としてJPAに新しいエンティティとして認識させるようにしました。
  • updateItem では、まずIDで既存のアイテムを検索し、存在すれば更新、存在しなければ404を返すようにしました。これはRESTfulなPUTの一般的な実装パターンです。クライアントから送られてくるItemオブジェクトにはIDが含まれている可能性もありますが、PUTリクエストではパス変数のIDが更新対象を特定する際に優先されるべきです。

動作確認 (JPA/H2)

アプリケーションを再起動します(./mvnw spring-boot:run)。

H2コンソールにアクセス (http://localhost:8080/h2-console) し、接続してみると、ITEMS テーブルが作成されているのが確認できるはずです(初回起動時)。

APIエンドポイントに対するCRUD操作は、先ほどと同じ方法で動作確認できます。データはH2データベース(インメモリ)に保存されるようになりました。アプリケーションを停止するとデータは失われますが、これはH2のデフォルト設定です。もし永続化したい場合は、H2をファイルモードで設定するか、他のデータベース(PostgreSQL, MySQLなど)に接続するように設定を変更する必要があります(application.propertiesでデータソースの設定を変更します)。

これで、Spring BootとSpring Data JPA、H2 Databaseを使ったデータ永続化を含むREST APIの基本的な実装が完了しました。

8. バリデーション

APIが受け付ける入力データが正しい形式や制約を満たしていることを検証(バリデーション)することは、セキュリティやデータの整合性を保つ上で非常に重要です。Spring Bootは、Javaの標準バリデーション仕様であるJakarta Validation (旧 Bean Validation) を強力にサポートしています。

依存関係の追加

バリデーション機能を利用するために、必要なスターター依存関係を追加します。pom.xml に以下を追加します。

xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

このスターターは、Hibernate ValidatorというJakarta Validationの一般的な実装を含んでいます。pom.xml をリロードして依存関係を解決します。

エンティティ/DTOクラスにバリデーション制約を定義

バリデーション制約は、Jakarta Validationのアノテーションを使って、バリデーション対象のクラス(ここではItemエンティティ)のフィールドに定義します。

src/main/java/com/example/demoapi/Item.java を修正し、name フィールドに制約を追加します。

“`java
// src/main/java/com/example/demoapi/Item.java
package com.example.demoapi;

import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; // 1. バリデーションアノテーションをインポート
import jakarta.validation.constraints.Size;

@Entity
@Table(name = “items”)
public class Item {

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

@NotBlank(message = "Name is mandatory") // 2. 名前は必須で空白でないこと
@Size(min = 3, max = 100, message = "Name must be between 3 and 100 characters") // 3. 名前の長さに制約
private String name;

// ... (コンストラクタ、Getter, Setterなどは変更なし) ...

// JPAエンティティには引数なしのpublicまたはprotectedなコンストラクタが必要
public Item() {
}

// 新規作成用コンストラクタ(IDは自動生成されるため含めない)
public Item(String name) {
    this.name = name;
}

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

public Long getId() {
    return id;
}

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

public String getName() {
    return name;
}

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

 @Override
public String toString() {
    return "Item{" +
           "id=" + id +
           ", name='" + name + '\'' +
           '}';
}

}
“`

コードの解説

  1. jakarta.validation.constraints.*: Jakarta Validationが提供する標準的な制約アノテーションが定義されているパッケージです。
  2. @NotBlank(message = "Name is mandatory"): このフィールド(name)の値が null でなく、かつ空白文字だけでないことを検証します。違反した場合に返されるデフォルトのエラーメッセージを指定できます。
  3. @Size(min = 3, max = 100, message = "Name must be between 3 and 100 characters"): このフィールド(name)の文字列長が、指定された minmax の範囲内にあることを検証します。

他にも多くの標準アノテーションがあります(例: @NotNull, @NotEmpty, @Min, @Max, @Email, @Pattern など)。

コントローラーでバリデーションを有効化

次に、コントローラーでクライアントから受け取った Item オブジェクトに対して、これらの制約に基づくバリデーションを実行するように設定します。

src/main/java/com/example/demoapi/ItemController.java を修正します。

“`java
// src/main/java/com/example/demoapi/ItemController.java
package com.example.demoapi;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.MethodArgumentNotValidException; // 1. バリデーションエラー発生時にスローされる例外

import jakarta.validation.Valid; // 2. バリデーション対象を示すアノテーション

import java.util.List;
import java.util.Optional;
// import java.util.HashMap; // エラーレスポンスボディ作成に使用する場合
// import java.util.Map;

@RestController
@RequestMapping(“/api/items”)
public class ItemController {

private final ItemRepository itemRepository;

public ItemController(ItemRepository itemRepository) {
    this.itemRepository = itemRepository;
}

// 全アイテム取得 (GET /api/items) ... 変更なし ...
@GetMapping
public List<Item> getAllItems() {
    return itemRepository.findAll();
}

// ID指定でアイテム取得 (GET /api/items/{id}) ... 変更なし ...
@GetMapping("/{id}")
public ResponseEntity<Item> getItemById(@PathVariable Long id) {
    Optional<Item> item = itemRepository.findById(id);
    return item.map(ResponseEntity::ok)
               .orElseGet(() -> ResponseEntity.notFound().build());
}

// 新規アイテム作成 (POST /api/items) - バリデーション有効化
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Item createItem(@Valid @RequestBody Item item) { // 3. @Validアノテーションを追加
    item.setId(null); // 新規作成時にはIDを無視
    return itemRepository.save(item);
}

// アイテム更新 (PUT /api/items/{id}) - バリデーション有効化
@PutMapping("/{id}")
public ResponseEntity<Item> updateItem(@PathVariable Long id, @Valid @RequestBody Item itemDetails) { // 3. @Validアノテーションを追加
    Optional<Item> existingItem = itemRepository.findById(id);

    if (existingItem.isPresent()) {
        Item itemToUpdate = existingItem.get();
        // クライアントから送られてきたデータで既存のアイテムを更新
        itemToUpdate.setName(itemDetails.getName());

        Item updatedItem = itemRepository.save(itemToUpdate);
        return ResponseEntity.ok(updatedItem);
    } else {
        return ResponseEntity.notFound().build();
    }
}

// アイテム削除 (DELETE /api/items/{id}) ... 変更なし ...
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public ResponseEntity<Void> deleteItem(@PathVariable Long id) {
    Optional<Item> existingItem = itemRepository.findById(id);

    if (existingItem.isPresent()) {
        itemRepository.deleteById(id);
        return ResponseEntity.noContent().build();
    } else {
        return ResponseEntity.notFound().build();
    }
}

// 4. バリデーションエラーをハンドリングするメソッド (このコントローラー内限定)
// グローバルなエラーハンドリングは後述
/*
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST) // バリデーションエラーは400 Bad Request
public Map<String, String> handleValidationExceptions(MethodArgumentNotValidException ex) {
    Map<String, String> errors = new HashMap<>();
    ex.getBindingResult().getFieldErrors().forEach(error ->
        errors.put(error.getField(), error.getDefaultMessage()));
    return errors; // エラー情報をJSONで返す
}
*/

}
“`

コードの解説

  1. MethodArgumentNotValidException: @Valid 付きのメソッド引数に対するバリデーションに失敗した場合に、Spring MVCがスローする例外です。
  2. @Valid: メソッドの引数にこのアノテーションを付与すると、Spring MVCは引数のオブジェクトに対してJakarta Validationによるバリデーションを実行します。この例では、POSTとPUTリクエストで受け取る @RequestBodyItem オブジェクトに付与しています。
  3. @Valid @RequestBody Item item: これにより、リクエストボディから変換された Item オブジェクトが、コントローラーメソッドに渡される前に自動的にバリデーションされます。
  4. @ExceptionHandler(MethodArgumentNotValidException.class): (コメントアウトしていますが)特定のコントローラー内で発生した例外をハンドリングするためのメソッドに付与します。ここでは、バリデーションエラーが発生した場合にこのメソッドが呼び出されるように指定しています。メソッド内でエラー情報を取得し、カスタムのレスポンスを生成できます。ただし、これはこのコントローラーで発生したエラーのみをハンドリングするため、通常は後述するグローバルなエラーハンドリング (@ControllerAdvice) を利用します。

動作確認 (バリデーション)

アプリケーションを再起動します(./mvnw spring-boot:run)。

バリデーションエラーを発生させるリクエストをPostmanなどで送信してみてください。

  1. 新規アイテム作成 (POST) – 無効なデータ
    • リクエスト: POST http://localhost:8080/api/items
    • Headers: Content-Type: application/json
    • Body (raw, JSON):
      json
      {
      "name": "" // @NotBlank 制約に違反
      }
    • レスポンス (Status: 400 Bad Request, Body: Spring Bootのデフォルトエラーページ):
      json
      {
      "timestamp": "...",
      "status": 400,
      "error": "Bad Request",
      "trace": "...",
      "message": "Validation failed for argument [0] in public ...",
      "errors": [
      {
      "codes": [...],
      "arguments": [...],
      "defaultMessage": "Name is mandatory", // 定義したエラーメッセージ
      "objectName": "item",
      "field": "name",
      "rejectedValue": ""
      },
      {
      "codes": [...],
      "arguments": [...],
      "defaultMessage": "Name must be between 3 and 100 characters", // 定義したエラーメッセージ
      "objectName": "item",
      "field": "name",
      "rejectedValue": ""
      }
      ],
      "path": "/api/items"
      }
    • デフォルトでは、Spring Bootはバリデーションエラーが発生すると400 Bad Requestステータスと、エラーの詳細を含むJSONまたはHTMLのレスポンスを返します。このデフォルトのレスポンスは開発には便利ですが、クライアントに返すフォーマットとしてはあまり適切でない場合があります。

バリデーションエラー時のレスポンスをカスタマイズするためには、グローバルなエラーハンドリングを実装するのが一般的です。

9. エラーハンドリング

API開発において、予期せぬエラー(例えば、存在しないリソースへのアクセス、無効な入力データ、内部サーバーエラーなど)が発生した場合に、クライアントに対して適切で分かりやすいレスポンスを返すことは非常に重要です。Spring Bootでは、@ControllerAdvice アノテーションと @ExceptionHandler アノテーションを組み合わせて、アプリケーション全体で共通のエラーハンドリングを実装できます。

グローバルエラーハンドリングクラスの作成

src/main/java/com/example/demoapi/ ディレクトリに新しいクラスを作成します。クラス名は GlobalExceptionHandler など分かりやすい名前にします。

“`java
// src/main/java/com/example/demoapi/GlobalExceptionHandler.java
package com.example.demoapi;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException; // 1. バリデーションエラー例外
import org.springframework.web.bind.annotation.ControllerAdvice; // 2. グローバルハンドリング用アノテーション
import org.springframework.web.bind.annotation.ExceptionHandler; // 3. 例外タイプ指定用アノテーション
import org.springframework.web.bind.annotation.ResponseStatus; // 4. ステータスコード指定用アノテーション
import org.springframework.web.context.request.WebRequest; // 5. リクエスト情報取得
import org.springframework.web.servlet.NoHandlerFoundException; // 6. 404 Not Found (特定のケース)

import java.util.Date;
import java.util.LinkedHashMap; // 7. JSONのキーの順序を保持
import java.util.Map;
import java.util.stream.Collectors; // 8. Stream APIでリストをMapに変換

@ControllerAdvice // 2. このクラスがアプリケーション全体のエラーハンドリングを行うことを示す
public class GlobalExceptionHandler {

// 9. バリデーションエラー (MethodArgumentNotValidException) のハンドリング
@ExceptionHandler(MethodArgumentNotValidException.class) // 3. ハンドリングする例外のタイプを指定
@ResponseStatus(HttpStatus.BAD_REQUEST) // 4. レスポンスステータスコードを指定
public ResponseEntity<Object> handleValidationExceptions(MethodArgumentNotValidException ex, WebRequest request) {
    Map<String, Object> body = new LinkedHashMap<>(); // 7. レスポンスボディとなるMap
    body.put("timestamp", new Date()); // タイムスタンプ
    body.put("status", HttpStatus.BAD_REQUEST.value()); // ステータスコード (数値)
    body.put("error", "Validation Error"); // エラータイプ
    body.put("message", "Input validation failed"); // 全体メッセージ

    // フィールドごとのエラーメッセージを取得し、Mapに追加
    Map<String, String> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .collect(Collectors.toMap(
                    fieldError -> fieldError.getField(), // フィールド名
                    fieldError -> fieldError.getDefaultMessage() // エラーメッセージ
            ));
    body.put("details", errors); // フィールドごとのエラー詳細

    return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); // 400 Bad Requestステータスコードとボディを返す
}

// 10. 特定のリソースが見つからなかった場合のエラーハンドリング (例: GET/PUT/DELETEでID指定したが存在しない場合)
// ItemControllerで404を返しているため、このハンドラーはItemControllerのfindById/deleteByIdで明示的に
// 例外をスローした場合に有効になります。
// 例: findById/deleteByIdで見つからなかった場合に throw new ResourceNotFoundException("Item not found");
// というカスタム例外をスローし、ここでResourceNotFoundExceptionをハンドリングする
/*
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND) // 404 Not Found
public ResponseEntity<Object> handleResourceNotFoundException(ResourceNotFoundException ex, WebRequest request) {
     Map<String, Object> body = new LinkedHashMap<>();
     body.put("timestamp", new Date());
     body.put("status", HttpStatus.NOT_FOUND.value());
     body.put("error", "Not Found");
     body.put("message", ex.getMessage()); // 例外メッセージをレスポンスに含める

     return new ResponseEntity<>(body, HttpStatus.NOT_FOUND);
}
*/

// 11. 汎用的な例外ハンドリング (上記以外の全ての例外)
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) // 500 Internal Server Error
public ResponseEntity<Object> handleAllExceptions(Exception ex, WebRequest request) {
    Map<String, Object> body = new LinkedHashMap<>();
    body.put("timestamp", new Date());
    body.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value());
    body.put("error", "Internal Server Error");
    body.put("message", "An unexpected error occurred."); // 詳細なエラーメッセージはクライアントに返さない方が良い場合も

    // 開発中はスタックトレースを含めると便利だが、本番では非推奨
    // body.put("details", ex.getLocalizedMessage());
    // body.put("trace", Arrays.stream(ex.getStackTrace()).limit(10).map(StackTraceElement::toString).collect(Collectors.toList()));

    // 例外のログ出力は重要
    ex.printStackTrace(); // またはロガーを使用

    return new ResponseEntity<>(body, HttpStatus.INTERNAL_SERVER_ERROR);
}

// 12. 404 Not Found のハンドリング (DispatcherServletで処理されなかったパス)
// application.propertiesに spring.mvc.throw-exception-if-no-handler-found=true が必要
// spring.web.resources.add-mappings=false も必要になる場合がある
/*
@ExceptionHandler(NoHandlerFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ResponseEntity<Object> handleNoHandlerFoundException(NoHandlerFoundException ex, WebRequest request) {
    Map<String, Object> body = new LinkedHashMap<>();
    body.put("timestamp", new Date());
    body.put("status", HttpStatus.NOT_FOUND.value());
    body.put("error", "Not Found");
    body.put("message", "Resource not found: " + ex.getRequestURL());

    return new ResponseEntity<>(body, HttpStatus.NOT_FOUND);
}
*/

}
“`

コードの解説

  1. MethodArgumentNotValidException: バリデーション失敗時にSpring MVCがスローする例外です。
  2. @ControllerAdvice: このアノテーションを付与したクラスは、アプリケーション全体(または指定した範囲)のコントローラーからスローされた例外を捕捉し、ハンドリングできます。
  3. @ExceptionHandler(ExceptionType.class): 例外ハンドリングメソッドに付与し、どのタイプの例外をこのメソッドでハンドリングするかを指定します。
  4. @ResponseStatus(HttpStatus): ハンドリングメソッドが完了した際に返すべきHTTPステータスコードを指定します。これは ResponseEntity でステータスコードを返す場合と組み合わせることも、どちらか片方を使うことも可能です。
  5. WebRequest: リクエストに関する情報(ヘッダー、パラメータ、リクエストURLなど)を取得できます。
  6. NoHandlerFoundException: Spring MVCが、リクエストされたパスに対応するハンドラー(@RequestMappingなどでマッピングされたメソッド)を見つけられなかった場合にスローされる例外です。デフォルトでは404レスポンスになりますが、この例外をキャッチしてカスタムレスポンスを返すには追加設定が必要です(後述)。
  7. LinkedHashMap: Mapの実装の一つで、要素が追加された順序を保持します。JSONのキーの順序を固定したい場合に便利です。
  8. Collectors.toMap: Java 8のStream APIで、ストリームの要素をMapに収集するためのコレクタです。
  9. handleValidationExceptions: MethodArgumentNotValidException をハンドリングするメソッドです。ex.getBindingResult().getFieldErrors() でバリデーションエラーの詳細(どのフィールドが、なぜエラーになったか)を取得し、それをMap形式に整形してレスポンスボディに含めています。ステータスコードは400 Bad Requestを返します。
  10. handleResourceNotFoundException: (コメントアウト)これはカスタム例外 ResourceNotFoundException をハンドリングする例です。API開発では、特定のリソースが見つからなかった場合に404を返すことがよくあります。コントローラー内で見つからなかった場合にこのカスタム例外をスローするように実装しておけば、このハンドラーでまとめて処理できます。
  11. handleAllExceptions: Exception.class を指定することで、上記で捕捉されなかったすべての例外をこのメソッドでハンドリングします。これは「最後の砦」として機能し、予期しないエラーが発生した場合でも、少なくともクライアントに一貫したエラーレスポンス(ここでは500 Internal Server Error)を返すことができます。ただし、本番環境では詳細なエラーメッセージやスタックトレースをクライアントに返さないように注意が必要です。
  12. handleNoHandlerFoundException: (コメントアウト)存在しないパスへのアクセス(404 Not Found)をハンドリングする例です。このハンドラーを有効にするには、application.propertiesspring.mvc.throw-exception-if-no-handler-found=true を追加し、静的リソースなどのマッピングを無効にする必要がある場合があります(spring.web.resources.add-mappings=false)。

動作確認 (エラーハンドリング)

アプリケーションを再起動します(./mvnw spring-boot:run)。

  1. バリデーションエラー

    • 先ほどと同じく、空の名前でアイテムを作成するPOSTリクエストを送信します。
    • リクエスト: POST http://localhost:8080/api/items
    • Headers: Content-Type: application/json
    • Body: {"name": ""}
    • レスポンス (Status: 400 Bad Request, Body: カスタムエラーJSON):
      json
      {
      "timestamp": "...",
      "status": 400,
      "error": "Validation Error",
      "message": "Input validation failed",
      "details": {
      "name": "Name is mandatory"
      // @Sizeエラーも同時に発生している場合は複数表示される
      }
      }
    • 定義した GlobalExceptionHandler によって、カスタムのエラーJSONレスポンスが返されるようになりました。
  2. 存在しないパス

    • 存在しないパスにアクセスしてみます。
    • リクエスト: GET http://localhost:8080/non-existent-path
    • レスポンス (Status: 404 Not Found, Body: Spring Bootのデフォルトエラーページ):
      • NoHandlerFoundException ハンドラーをコメントアウトしたままの場合、Spring Bootのデフォルトの404レスポンスが返されます。これをカスタマイズしたい場合は、前述のNoHandlerFoundExceptionハンドラーと必要な設定を追加してください。
  3. 内部サーバーエラー

    • アプリケーションコードに意図的にエラーを引き起こすコードを追加してみるなどして確認できます。例えば、ItemControllerのどこかで throw new RuntimeException("Something went wrong!"); と記述して試してみてください。
    • レスポンス (Status: 500 Internal Server Error, Body: カスタムエラーJSON):
      json
      {
      "timestamp": "...",
      "status": 500,
      "error": "Internal Server Error",
      "message": "An unexpected error occurred."
      // detailsやtraceを含める設定をしていれば表示される
      }

このように、@ControllerAdvice を使うことで、アプリケーション全体で一貫したエラーレスポンスを提供できるようになります。

10. APIドキュメンテーション (Swagger/OpenAPI)

開発したAPIを他の開発者が利用するためには、そのAPIがどのようなエンドポイントを持ち、どのようなリクエストを受け付け、どのようなレスポンスを返すのかといった情報が必要不可欠です。APIドキュメンテーションは、APIの仕様を記述したものであり、開発効率を大きく向上させます。

Spring Bootの世界では、OpenAPI Specification (旧Swagger Specification) に基づいたドキュメンテーションを自動生成・表示するためのツールが広く使われています。代表的なものに Springdoc OpenAPI があります。

依存関係の追加

Springdoc OpenAPIをプロジェクトに組み込むために、pom.xml に以下の依存関係を追加します。

xml
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.x.x</version> <!-- 最新の安定版バージョンを指定 -->
</dependency>

version は、Spring Bootのバージョンと互換性のある最新の安定版を指定してください。記事執筆時点では2.x.x系が一般的です。Mavenプロジェクトをリロードして依存関係を解決します。

このスターターを追加するだけで、Spring Bootアプリケーションは自動的に起動時にAPIのエンドポイントをスキャンし、OpenAPI Specification形式のドキュメントを生成してくれます。また、そのドキュメントをインタラクティブなUIで表示するSwagger UIも組み込まれます。

ドキュメンテーションの確認

アプリケーションを再起動します(./mvnw spring-boot:run)。

アプリケーション起動後、以下のURLにアクセスします。

  • Swagger UI: http://localhost:8080/swagger-ui.html
  • OpenAPI JSON: http://localhost:8080/v3/api-docs (JSON形式のドキュメント)
  • OpenAPI YAML: http://localhost:8080/v3/api-docs.yaml (YAML形式のドキュメント)

swagger-ui.html にアクセスすると、開発したAPIのエンドポイント一覧が自動的に生成され、表示されているはずです。各エンドポイントを展開すると、その詳細(パス、HTTPメソッド、パラメータ、レスポンスなど)を確認できます。Swagger UI上から実際にAPIリクエストを送信して動作を試すことも可能です。

ドキュメンテーションのカスタマイズ (アノテーション)

基本的なドキュメントは自動生成されますが、より詳細な情報(エンドポイントの説明、パラメータの説明、レスポンスボディの例など)をドキュメントに含めるには、OpenAPI Specificationのアノテーションを使用します。

最もよく使われるのは jakarta.validationio.swagger.v3.oas.annotations パッケージのアノテーションです。

ItemController.java を修正し、アノテーションを追加してみましょう。

まず、必要なアノテーションをインポートします。

“`java
// src/main/java/com/example/demoapi/ItemController.java
package com.example.demoapi;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
// … 既存のimport …

import io.swagger.v3.oas.annotations.Operation; // 1. エンドポイント操作の説明
import io.swagger.v3.oas.annotations.Parameter; // 2. パラメータの説明
import io.swagger.v3.oas.annotations.media.Content; // 3. レスポンスボディのメディアタイプとスキーマ
import io.swagger.v3.oas.annotations.media.Schema; // 4. スキーマ(モデル)の説明
import io.swagger.v3.oas.annotations.responses.ApiResponse; // 5. レスポンスの説明
import io.swagger.v3.oas.annotations.tags.Tag; // 6. コントローラー(タグ)の説明

import jakarta.validation.Valid; // バリデーション用

import java.util.List;
import java.util.Optional;
// … 既存のimport …

@RestController
@RequestMapping(“/api/items”)
@Tag(name = “Items”, description = “Item management API”) // 6. コントローラー全体の説明 (Swagger UIのタグ)
public class ItemController {

private final ItemRepository itemRepository;

public ItemController(ItemRepository itemRepository) {
    this.itemRepository = itemRepository;
}

@Operation(summary = "Get all items", description = "Retrieve a list of all items") // 1. エンドポイントの説明
@ApiResponse(responseCode = "200", description = "Successfully retrieved list") // 5. 200 OK レスポンスの説明
@GetMapping
public List<Item> getAllItems() {
    return itemRepository.findAll();
}

@Operation(summary = "Get item by ID", description = "Retrieve a single item by its ID")
@ApiResponse(responseCode = "200", description = "Successfully retrieved item",
             content = @Content(mediaType = "application/json", schema = @Schema(implementation = Item.class))) // レスポンスボディのスキーマを指定
@ApiResponse(responseCode = "404", description = "Item not found") // 404 レスポンスの説明
@GetMapping("/{id}")
public ResponseEntity<Item> getItemById(
        @Parameter(description = "ID of the item to retrieve", required = true) @PathVariable Long id) { // 2. パラメータの説明
    Optional<Item> item = itemRepository.findById(id);
    return item.map(ResponseEntity::ok)
               .orElseGet(() -> ResponseEntity.notFound().build());
}

@Operation(summary = "Create a new item", description = "Add a new item to the system")
@ApiResponse(responseCode = "201", description = "Item created successfully",
             content = @Content(mediaType = "application/json", schema = @Schema(implementation = Item.class)))
@ApiResponse(responseCode = "400", description = "Invalid input") // バリデーションエラーなど
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Item createItem(@Valid @RequestBody Item item) {
    item.setId(null); // 新規作成時にはIDを無視
    return itemRepository.save(item);
}

@Operation(summary = "Update an existing item", description = "Update an item's details by ID")
@ApiResponse(responseCode = "200", description = "Item updated successfully",
             content = @Content(mediaType = "application/json", schema = @Schema(implementation = Item.class)))
@ApiResponse(responseCode = "400", description = "Invalid input")
@ApiResponse(responseCode = "404", description = "Item not found")
@PutMapping("/{id}")
public ResponseEntity<Item> updateItem(
        @Parameter(description = "ID of the item to update", required = true) @PathVariable Long id,
        @Valid @RequestBody Item itemDetails) {
    Optional<Item> existingItem = itemRepository.findById(id);

    if (existingItem.isPresent()) {
        Item itemToUpdate = existingItem.get();
        itemToUpdate.setName(itemDetails.getName());
        Item updatedItem = itemRepository.save(itemToUpdate);
        return ResponseEntity.ok(updatedItem);
    } else {
        return ResponseEntity.notFound().build();
    }
}

@Operation(summary = "Delete an item", description = "Remove an item from the system by ID")
@ApiResponse(responseCode = "204", description = "Item deleted successfully") // 204 No Content
@ApiResponse(responseCode = "404", description = "Item not found")
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public ResponseEntity<Void> deleteItem(
        @Parameter(description = "ID of the item to delete", required = true) @PathVariable Long id) {
    Optional<Item> existingItem = itemRepository.findById(id);

    if (existingItem.isPresent()) {
        itemRepository.deleteById(id);
        return ResponseEntity.noContent().build();
    } else {
        return ResponseEntity.notFound().build();
    }
}

}
“`

コードの解説

  1. @Operation: 個々のAPI操作(メソッド)に対する説明を記述します。summary は短い説明、description は長い説明です。
  2. @Parameter: メソッドの引数(パス変数、クエリパラメータ、リクエストボディなど)に対する説明を記述します。description で説明文を、required = true で必須パラメータであることを示します。
  3. @Content: レスポンスやリクエストボディの内容(メディアタイプやスキーマ)を記述します。
  4. @Schema: データモデル(エンティティやDTO)の構造を記述します。implementation = Item.class とすることで、Item クラスの構造から自動的にOpenAPIスキーマを生成させます。
  5. @ApiResponse: 特定のHTTPステータスコードに対応するレスポンスの説明を記述します。responseCode でステータスコード(文字列)、description で説明文を指定します。content を使って、レスポンスボディの構造(スキーマ)も記述できます。
  6. @Tag: コントローラー全体に対する説明を記述します。Swagger UIでエンドポイントをグループ化するための「タグ」として表示されます。

これらのアノテーションを追加してアプリケーションを再起動し、swagger-ui.html にアクセスすると、より詳細で分かりやすいAPIドキュメントが表示されるようになります。

11. セキュリティ (簡易版)

APIを公開する場合、適切な認証と認可の仕組みを導入して、不正アクセスや悪意のある操作から保護することが不可欠です。Spring Bootは、セキュリティフレームワークであるSpring Securityとの連携を強力にサポートしています。ここでは、Spring Securityを組み込んで、簡単な基本認証を有効にする例を示します。

依存関係の追加

Spring Securityをプロジェクトに組み込むために、pom.xml に以下の依存関係を追加します。

xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

Mavenプロジェクトをリロードして依存関係を解決します。

このスターターを追加するだけで、Spring Bootアプリケーションはデフォルトのセキュリティ設定(すべてのパスへのアクセスには認証が必要、ユーザー名userと起動時に生成されるパスワードを使用する基本認証など)が有効になって起動します。

動作確認 (デフォルトセキュリティ)

アプリケーションを再起動します(./mvnw spring-boot:run)。

コンソール出力に、デフォルトのパスワードがランダムに生成されて表示されているはずです。

...
Using generated security password: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
...

このパスワードをコピーしておきます。

APIエンドポイント(例: GET http://localhost:8080/api/items)にアクセスしてみてください。

  • ブラウザからアクセスすると、ユーザー名とパスワードの入力ダイアログが表示されます。ユーザー名に user、パスワードにコンソールに表示された値を入力します。
  • curlやPostmanからアクセスする場合、認証情報を含める必要があります。
    • curl: curl -u user:GENERATED_PASSWORD http://localhost:8080/api/items
    • Postman: AuthorizationタブでTypeをBasic Authにし、Usernameに user、Passwordに生成された値を入力します。

正しく認証情報を提供した場合のみ、APIレスポンスを受け取れるはずです。認証情報なしでアクセスすると、401 Unauthorized ステータスが返されます。

簡単なセキュリティ設定のカスタマイズ

デフォルトのセキュリティ設定はすべてのパスを保護しますが、例えばドキュメンテーション用のパス(/swagger-ui.html, /v3/api-docs*)は認証なしでアクセス可能にしたい場合などがあります。また、独自のユーザー名とパスワードを設定したい場合もあるでしょう。

src/main/resources/application.properties に以下の設定を追加することで、簡単なカスタマイズが可能です。

“`properties

Spring Security Basic Auth Credentials

spring.security.user.name=admin
spring.security.user.password=password # 本番環境では絶対にプレーンテキストのパスワードを使わないこと!

Security Filter Chain Configuration (Java Configで設定するのが推奨されるが、ここでは簡易例)

特定のパスの認証を無効化する設定例 (application.propertiesでは限定的)

Spring Boot 3.x では WebSecurityConfigurerAdapter は非推奨/削除されたため、Java Configが標準的

“`

application.properties によるセキュリティ設定は限定的です。より柔軟かつ詳細なセキュリティ設定(パスごとのアクセス制御、フォーム認証、JWT認証、OAuth2など)を行うには、JavaConfigで SecurityFilterChain Beanを定義する必要があります。Spring SecurityのJavaConfigは少し複雑なので、ここでは詳細な説明は省略しますが、公式ドキュメントや専門のチュートリアルを参照してください。

例: すべてのパスを認証不要にする(APIに認証をかけない場合)

“`java
// src/main/java/com/example/demoapi/SecurityConfig.java (新規作成)
package com.example.demoapi;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration // 1. 設定クラスとしてマーク
public class SecurityConfig {

// 2. SecurityFilterChain Beanを定義
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests((authz) -> authz
            // .requestMatchers("/public/**").permitAll() // 例: /public以下のパスは認証不要
            // .requestMatchers("/api/**").authenticated() // 例: /api以下のパスは認証必須
            .anyRequest().permitAll() // 3. 全てのパスへのアクセスを許可 (認証不要)
        )
        // .httpBasic(withDefaults()) // 4. HTTP Basic認証を有効にする場合
        .csrf().disable(); // 5. CSRF保護を無効化 (REST APIでは一般的に無効化することが多いが、状況による)
        // .formLogin(withDefaults()); // フォーム認証を有効にする場合

    return http.build();
}

// パスワードエンコーダーのBean定義などもここで行う
// @Bean
// public PasswordEncoder passwordEncoder() { ... }

}
“`

コードの解説

  1. @Configuration: このクラスがSpringの設定クラスであることを示します。
  2. @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception: SecurityFilterChain のBeanを定義します。Spring Securityは、このBeanを使ってセキュリティフィルターチェーンを設定します。HttpSecurity オブジェクトを使って、URLごとのアクセス制御、認証方法、CSRF保護などを設定します。
  3. authorizeHttpRequests((authz) -> authz.anyRequest().permitAll()): authorizeHttpRequests でリクエストの認可設定を開始します。anyRequest() は全てのリクエストを対象とし、permitAll() は認証なしでアクセスを許可することを意味します。これにより、全てのAPIエンドポイントが認証不要になります。逆に、authenticated() とすると認証が必要になります。特定のパスに対して設定することも可能です (requestMatchers("/api/**").authenticated(), requestMatchers("/admin/**").hasRole("ADMIN") など)。
  4. .httpBasic(withDefaults()): HTTP Basic認証を有効にする設定です。有効にすると、認証が必要なパスにアクセスする際にBasic認証が求められます。
  5. .csrf().disable(): CSRF (Cross-Site Request Forgery) 保護を無効にします。ステートレスなREST APIでは、通常セッション管理を行わず、トークンベースの認証(JWTなど)を使用するため、CSRF攻撃の対象になりにくく、無効にすることが多いです。ただし、セッションベースの認証を使用する場合は有効にしておくべきです。

このJavaConfigを適用してアプリケーションを再起動すると、セキュリティ設定が反映されます。上記の例(anyRequest().permitAll())では、認証なしでAPIにアクセスできるようになります。

セキュリティはAPI開発における非常に重要な側面であり、本番環境ではさらに多くの考慮事項(本番データベースでのユーザー管理、パスワードのハッシュ化、HTTPSの強制、レートリミット、ログ監視など)が必要です。ここではあくまで入門レベルとして、Spring Securityの組み込みと基本的な設定方法を紹介しました。

12. テスト

開発したAPIが期待通りに動作することを確認するために、テストは不可欠です。Spring Bootはテストを強力にサポートしており、ユニットテスト、統合テスト、スライス(特定レイヤーのみを対象とする)テストなどを容易に記述できます。

依存関係の追加

Spring Bootプロジェクトは、Spring Initializrで Spring Web を選択した時点で、テストに必要なスターター依存関係である spring-boot-starter-testpom.xml に自動的に追加されています。

xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

scope=test は、この依存関係がテスト実行時のみ必要であることを示します。このスターターには、JUnit 5 (Jupiter)、Mockito (モックオブジェクト作成)、AssertJ (アサーションライブラリ)、Hamcrest、Spring Test (Springアプリケーションのテストユーティリティ)、Spring Boot Test (Spring Bootテストの機能)などが含まれています。

統合テスト (コントローラーテスト)

APIエンドポイントの動作をテストするために、コントローラー層を対象とした統合テストを記述します。Spring Boot Testが提供する @SpringBootTest アノテーションと、Spring MVCテストユーティリティの MockMvc を使用します。

src/test/java/com/example/demoapi/ ディレクトリにある DemoApiApplicationTests.java を修正して、ItemControllerのテストを追加するか、新しく ItemControllerTests.java を作成します。ここでは新しく作成する例を示します。

“`java
// src/test/java/com/example/demoapi/ItemControllerTests.java
package com.example.demoapi;

import com.fasterxml.jackson.databind.ObjectMapper; // 1. JSONとの変換に使用
import org.junit.jupiter.api.BeforeEach; // 2. 各テストメソッド実行前に実行
import org.junit.jupiter.api.Test; // 3. テストメソッドを示すアノテーション
import org.springframework.beans.factory.annotation.Autowired; // 4. Spring Beanを注入
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; // 5. MockMvcを自動設定
import org.springframework.boot.test.context.SpringBootTest; // 6. Spring Bootアプリケーション全体をロード
import org.springframework.http.MediaType; // 7. HTTPメディアタイプ

import org.springframework.test.web.servlet.MockMvc; // 8. コントローラーテスト用ユーティリティ
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; // 9. リクエストビルダー
import org.springframework.test.web.servlet.result.MockMvcResultMatchers; // 10. レスポンスマッチャー

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.; // インポート
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.; // インポート

import java.util.Optional; // テスト内のJPAリポジトリのモック化で使う可能性

// インメモリDB (H2) を使う場合、テストごとにDBをクリアしたい場合は以下を使用することも検討
// @SpringBootTest(properties = {“spring.jpa.hibernate.ddl-auto=create-drop”})
@SpringBootTest // 6. Spring Bootアプリケーションコンテキストをロード
@AutoConfigureMockMvc // 5. MockMvc Beanを自動設定 (Web層のみを対象とするテストに適している)
class ItemControllerTests {

@Autowired // 4. MockMvc Beanを注入
private MockMvc mockMvc;

@Autowired // 4. ObjectMapper Beanを注入 (JSON変換用)
private ObjectMapper objectMapper;

// JPAを使用しているため、テスト間でデータが共有される可能性がある
// 各テスト実行前にDBをクリアするなどの前処理が必要になる場合がある
// ここでは簡易的なテストとし、DBの状態に依存しない、またはデータ投入をテスト内で行うとする。
// もしくは、テスト用のリポジトリをモック化して使う (@MockBeanを使う)
/*
@MockBean // テスト用のモックリポジトリを注入
private ItemRepository itemRepository;

@BeforeEach // 各テストメソッド実行前に実行
void setUp() {
    // 必要に応じてmockItemRepositoryのメソッドの振る舞いを定義 (Mockito)
    // example: when(itemRepository.findById(1L)).thenReturn(Optional.of(new Item(1L, "Test Item")));
}
*/


@Test // 3. テストメソッド
void getAllItems_shouldReturnOkAndListOfItems() throws Exception {
    // テスト対象のエンドポイントにGETリクエストを送信し、レスポンスを検証
    mockMvc.perform(get("/api/items")) // GETリクエストを作成
            .andExpect(status().isOk()) // ステータスコードが200 OKであることを検証
            .andExpect(content().contentType(MediaType.APPLICATION_JSON)) // Content-Typeがapplication/jsonであることを検証
            .andExpect(jsonPath("$").isArray()) // レスポンスボディがJSON配列であることを検証
            // 簡易的な初期データが2件あることを想定 (ItemRepositoryのコンストラクタ参照)
            .andExpect(jsonPath("$.length()").value(2)) // 配列の要素数が2であることを検証
            .andExpect(jsonPath("$[0].id").value(1)) // 1番目の要素のIDが1であることを検証
            .andExpect(jsonPath("$[0].name").value("Sample Item 1")); // 1番目の要素の名前を検証
            // 他の要素やプロパティも必要に応じて検証
}

@Test
void getItemById_existingId_shouldReturnOkAndItem() throws Exception {
    // まずアイテムを新規作成して、そのIDを使って取得テストを行う (JPA利用時のデータの依存性回避策)
    Item newItem = new Item("Item for Get Test");
    String itemJson = objectMapper.writeValueAsString(newItem); // ItemオブジェクトをJSON文字列に変換

    // POSTリクエストでアイテムを作成
    mockMvc.perform(post("/api/items")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(itemJson))
            .andExpect(status().isCreated()) // 201 Createdを検証
            .andExpect(jsonPath("$.id").exists()) // IDが生成されていることを検証
            .andExpect(jsonPath("$.name").value("Item for Get Test")); // 名前を検証

    // 作成されたアイテムのIDを取得することは、このPOSTレスポンスから行う必要があるが、
    // ここでは簡単のため、初期データや固定IDを想定するか、findAllで取得する。
    // あるいはPOSTのレスポンスからLocationヘッダーやボディをパースしてIDを取得する。
    // もっと簡単な方法として、テスト専用のリポジトリをモック化する方法もある。

    // JPAの初期データにID=1が存在する前提でテストする場合
    mockMvc.perform(get("/api/items/1"))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.name").value("Sample Item 1")); // ItemRepositoryの初期データに依存
}

@Test
void getItemById_nonExistingId_shouldReturnNotFound() throws Exception {
    // 存在しないIDへのGETリクエスト
    mockMvc.perform(get("/api/items/999"))
            .andExpect(status().isNotFound()); // 404 Not Foundを検証
}

@Test
void createItem_validInput_shouldReturnCreatedAndItem() throws Exception {
    Item newItem = new Item("New Item from Test");
    String itemJson = objectMapper.writeValueAsString(newItem);

    mockMvc.perform(post("/api/items")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(itemJson))
            .andExpect(status().isCreated()) // 201 Created
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$.id").exists()) // IDが生成されていること
            .andExpect(jsonPath("$.name").value("New Item from Test")); // 名前
}

 @Test
void createItem_invalidInput_shouldReturnBadRequest() throws Exception {
    Item invalidItem = new Item(""); // @NotBlankに違反
    String itemJson = objectMapper.writeValueAsString(invalidItem);

    mockMvc.perform(post("/api/items")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(itemJson))
            .andExpect(status().isBadRequest()) // 400 Bad Request
            .andExpect(content().contentType(MediaType.APPLICATION_JSON)) // エラーハンドリングでJSONを返す想定
            .andExpect(jsonPath("$.error").value("Validation Error")); // カスタムエラーハンドリングのメッセージを検証
            // フィールドごとのエラーも検証可能
            // .andExpect(jsonPath("$.details.name").exists());
}

@Test
void updateItem_existingId_shouldReturnOkAndUpdatedItem() throws Exception {
    // 既存のアイテムを更新するテスト (ここでは初期データID=1を想定)
    Item updatedItem = new Item(1L, "Updated Name from Test"); // IDを指定
    String itemJson = objectMapper.writeValueAsString(updatedItem);

    mockMvc.perform(put("/api/items/1")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(itemJson))
            .andExpect(status().isOk()) // 200 OK
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$.id").value(1)) // IDが変わっていないこと
            .andExpect(jsonPath("$.name").value("Updated Name from Test")); // 名前が更新されていること
}

@Test
void updateItem_nonExistingId_shouldReturnNotFound() throws Exception {
     Item updatedItem = new Item(999L, "Name for NonExisting");
     String itemJson = objectMapper.writeValueAsString(updatedItem);

     mockMvc.perform(put("/api/items/999")
                     .contentType(MediaType.APPLICATION_JSON)
                     .content(itemJson))
             .andExpect(status().isNotFound()); // 404 Not Found
}

@Test
void deleteItem_existingId_shouldReturnNoContent() throws Exception {
    // まず削除対象アイテムを作成 (テストの独立性を高めるため)
    Item itemToDelete = new Item("Item for Delete Test");
    String itemJson = objectMapper.writeValueAsString(itemToDelete);

     mockMvc.perform(post("/api/items")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(itemJson))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").exists());

    // TODO: POSTレスポンスから作成されたIDを取得し、そのIDを使ってDELETEリクエストを送信する
    // 現状は初期データID=1を削除するテストとする (テスト実行順序によっては失敗する可能性あり)
    // もしくは@MockBeanでリポジトリをモック化する

    mockMvc.perform(delete("/api/items/1")) // 初期データID=1を削除
            .andExpect(status().isNoContent()); // 204 No Content

    // 削除されたか確認 (GETで404が返るはず)
    mockMvc.perform(get("/api/items/1"))
             .andExpect(status().isNotFound());
}

 @Test
void deleteItem_nonExistingId_shouldReturnNotFound() throws Exception {
     mockMvc.perform(delete("/api/items/999"))
             .andExpect(status().isNotFound()); // 404 Not Found
}

}
“`

コードの解説

  1. ObjectMapper: Jacksonライブラリの一部で、JavaオブジェクトとJSONの間で変換を行います。テストでリクエストボディのJSONを生成したり、レスポンスボディのJSONをパースしたりするのに使います。
  2. @BeforeEach: JUnit 5のアノテーションで、各テストメソッドが実行される前に実行されます。テスト間の状態をリセットするのに使われます(例えば、データベースをクリアするなど)。
  3. @Test: JUnit 5のアノテーションで、テストメソッドであることを示します。
  4. @Autowired: Springコンテナが管理するBean(ここでは MockMvc, ObjectMapper)をこのフィールドに注入します。
  5. @AutoConfigureMockMvc: @SpringBootTest と組み合わせて使用することで、Spring MVCのテストに必要な MockMvc Beanを自動的に設定してくれます。
  6. @SpringBootTest: このアノテーションを付与したテストクラスは、Spring Bootアプリケーション全体のコンテキストをロードして実行されます。これにより、アプリケーションの本番に近い環境でテストを実行できます。ただし、アプリケーション全体をロードするため、テストの実行時間は長くなる傾向があります。
  7. MediaType: HTTPヘッダー(Content-TypeやAccept)でメディアタイプを指定するための定数クラスです。
  8. MockMvc: Spring MVCのテストユーティリティです。HTTPリクエストを模擬的に送信し、コントローラーメソッドがどのように処理し、どのようなレスポンスを返すかを、実際のHTTPサーバーを起動せずにテストできます。
  9. MockMvcRequestBuilders / get(), post(), put(), delete(): 模擬的なHTTPリクエストを作成するためのメソッドを提供します。
  10. MockMvcResultMatchers / status(), content(), jsonPath(): MockMvc によるリクエスト実行結果 (MvcResult) を検証するためのメソッドを提供します。status() でHTTPステータスコードを、content() でレスポンスボディの内容を、jsonPath() でJSONレスポンスの特定の要素を検証できます(JsonPathライブラリを使用)。

JPA利用時のテストデータの扱い:

上記のテストコードには、JPAを利用している場合のテストデータの扱いで注意すべき点があります。@SpringBootTest は実際のSpringコンテキストとH2データベースをロードするため、テスト間でデータが共有されてしまいます。ItemRepository のコンストラクタで初期データを入れている場合、それらのデータが存在する前提でテストを記述することになりますが、テストの実行順序によってはデータが削除されて失敗する可能性があります。

より堅牢なテストにするためには、以下のいずれか、あるいは両方のアプローチを取る必要があります。

  • テストごとにデータを準備/クリアする: @BeforeEach メソッドなどで、テスト実行前にH2データベースをクリアしたり、テストに必要なデータを投入したりします。Spring Testには @Transactional アノテーションもあり、テストメソッドの終了後にトランザクションをロールバックしてデータベースを元に戻すことができます。
  • リポジトリ層をモック化する: @MockBean アノテーションを使って ItemRepository を実際のBeanではなくMockitoによるモックオブジェクトに置き換えます。そして、Mockito.when().thenReturn() などを使って、モックのリポジトリメソッドが特定の引数に対して何を返すかを定義します。この方法ではデータベースへのアクセスは行われず、コントローラーとリポジトリ間の連携やコントローラー単体のロジックを高速にテストできます(スライステスト)。

上記のコード例では、簡易的に初期データに依存するテストと、テスト内でアイテムを作成してそれを利用するテストの両方の考え方を示しています。実際のプロジェクトでは、テストの目的(統合的な流れをテストするか、特定のコンポーネント単体をテストするか)に応じて適切な戦略を選択してください。

テストの実行

IDEからテストクラス(ItemControllerTests.java)を右クリックして「Run ‘ItemControllerTests’」などを選択するか、コマンドラインで以下のMavenコマンドを実行してテストを実行します。

bash
./mvnw test

テストが実行され、結果が表示されます。全てのテストがパスすることを確認してください。

13. APIのバージョン管理

APIは一度公開すると、その仕様を変更することが難しくなります。機能の追加や変更によってAPIの仕様が変わる場合、既存のクライアントアプリケーションに影響を与えないようにするために、APIのバージョン管理が必要になります。

APIのバージョン管理にはいくつかの一般的な方法があります。

  1. URIによるバージョン管理: APIパスにバージョンを含める方法です。例: /api/v1/items, /api/v2/items
  2. HTTP Headerによるバージョン管理: Accept ヘッダーなどを使ってクライアントが必要なAPIバージョンを指定する方法です。例: Accept: application/vnd.myapi.v1+json
  3. クエリパラメータによるバージョン管理: クエリパラメータでバージョンを指定する方法です。例: /api/items?version=1

最もシンプルで広く使われているのは、URIにバージョンを含める方法(1)です。ここでは、この方法でAPIにバージョンを追加する簡単な例を示します。

URIによるバージョン管理の実装例

既存の ItemController のパスを /api/v1/items に変更してみましょう。

src/main/java/com/example/demoapi/ItemController.java を開き、クラスレベルの @RequestMapping アノテーションを変更します。

“`java
// src/main/java/com/example/demoapi/ItemController.java
package com.example.demoapi;

// … 既存のimport …

@RestController
@RequestMapping(“/api/v1/items”) // 1. パスにバージョンv1を追加
@Tag(name = “Items (v1)”, description = “Item management API (Version 1)”) // Swagger UIのタグも変更
public class ItemController {

private final ItemRepository itemRepository;

// ... コンストラクタ、メソッド本体は変更なし ...

@Operation(summary = "Get all items (v1)", description = "Retrieve a list of all items (Version 1)")
// ... 他のアノテーション、メソッド本体は変更なし ...
@GetMapping
public List<Item> getAllItems() {
    return itemRepository.findAll();
}

// ... 他のCRUDメソッドも同様にパスが /api/v1/items/{id} などに変更される ...

}
“`

コードの解説

  1. @RequestMapping("/api/v1/items"): コントローラー全体のベースパスを /api/v1/items に変更しました。これにより、このコントローラーで定義されている全てのエンドポイントのURIが /api/v1/... となります。

アプリケーションを再起動し、APIエンドポイントにアクセスする際に /api/v1/items のようにバージョンパスを含める必要があることを確認してください。Swagger UIも新しいパスでドキュメントを生成し直します。

新しいバージョンのAPI(例えばv2)を開発する場合、例えば ItemV2.java エンティティ(仕様変更がある場合)や、新しい ItemControllerV2.java を作成し、そちらに @RequestMapping("/api/v2/items") を設定することで、v1とv2のAPIを並行して公開できます。

URIによるバージョン管理は分かりやすい反面、URIが長くなる、バージョンごとにコントローラークラスが増える可能性があるといったデメリットもあります。どのバージョン管理手法を採用するかは、APIの目的やクライアントの特性などを考慮して決定する必要があります。

14. まとめと次のステップ

この記事では、Spring Bootを使ってモダンなREST APIを開発するための基本的なステップを一通り解説しました。APIとは何か、REST APIの原則、なぜSpring Bootが適しているのかといった背景知識から始まり、プロジェクトの作成、最小限のAPI実装、CRUD操作、データ永続化(JPA/H2)、バリデーション、エラーハンドリング、APIドキュメンテーション(Swagger/OpenAPI)、そして簡単なセキュリティとテストまで、API開発における重要な要素を網羅しました。

Spring Bootは、これらの多くの定型的な設定やフレームワークの統合を自動で行ってくれるため、開発者はビジネスロジックの実装に集中できます。これはモダンなAPI開発において非常に強力なアドバンテージとなります。

今回構築したAPIはあくまで基本的なものです。実際のプロダクションレベルのAPIを開発するには、さらに考慮すべき点が数多くあります。

次のステップとして学ぶべきこと:

  • 認証・認可の強化: HTTP Basic認証だけでなく、OAuth2、JWT (JSON Web Token) を使った認証、ユーザーロールやパーミッションに基づいた詳細な認可制御。
  • 非同期処理: 長時間かかる処理をバックグラウンドで実行するための非同期処理(@Async やメッセージキューなど)。
  • レートリミット: APIへのアクセス頻度を制限し、サーバーを保護する。
  • キャッシュ: レスポンスやデータアクセス結果をキャッシュしてパフォーマンスを向上させる(Spring Cache)。
  • ロギングと監視: アプリケーションの挙動やエラーを記録し、運用状況を監視する(Spring Boot Actuator, Micrometer, ELK Stackなど)。
  • 国際化(i18n): 異なる言語のクライアントに対応するための多言語化。
  • データベースの選択と設定: H2以外の本番用データベース(PostgreSQL, MySQLなど)への接続設定。
  • トランザクション管理: 複数のデータベース操作を原子的に扱うためのトランザクション制御。
  • Dto (Data Transfer Object): エンティティクラスを直接コントローラーで扱わず、APIの入出力用に特化したDtoクラスを導入する(セキュリティ、柔軟性、ドキュメンテーションの観点から推奨)。MapStructなどのマッピングライブラリも便利です。
  • マイクロサービス: 巨大なアプリケーションを小さなサービスに分割するアーキテクチャや、Spring Cloudプロジェクトについて。
  • デプロイ: 開発したAPIをサーバーやクラウド環境にデプロイする方法(Jar実行、Dockerコンテナ、クラウドプラットフォーム固有のデプロイ方法など)。

Spring Bootのエコシステムは非常に広大で、これらの様々なニーズに対応するためのライブラリや機能が豊富に用意されています。

この記事が、Spring Bootを使ったモダンなAPI開発の旅を始めるための一歩となれば幸いです。実際にコードを書き、様々な機能を試し、公式ドキュメントや他の優れた資料を参照しながら学習を続けていくことが、スキル習得への一番の近道です。

Happy Coding!


参考資料:


(記事の終わり)

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール