REST APIを設計する際、クライアントがAPIの構造を事前に知らなくても、レスポンスに含まれるリンクを辿ることで必要なリソースにアクセスできる設計が理想的です。この「ハイパーメディア駆動」のアプローチを実現するのがHATEOAS(Hypermedia as the Engine of Application State)です。Spring HATEOASライブラリを使用することで、Spring Boot REST APIにリンク情報を簡潔に付与でき、自己記述的なAPIを構築できます。
本記事では、HATEOASの概念とREST成熟度モデルから、EntityModel・CollectionModelによるレスポンス構築、WebMvcLinkBuilderでのリンク生成、HAL Explorer活用まで、実践的なHATEOAS実装を解説します。
実行環境#
| 項目 |
バージョン |
| Java |
21 |
| Spring Boot |
3.4.x |
| Spring HATEOAS |
2.4.x |
前提条件として、Spring Boot Webプロジェクトが作成済みであることを想定しています。また、基本的なSpring Boot REST APIの実装経験があることを前提としています。
HATEOASの概念とREST成熟度モデル#
REST成熟度モデル(Richardson Maturity Model)#
REST APIの設計品質を評価する指標として、Leonard Richardsonが提唱した成熟度モデルがあります。
graph TB
L0[Level 0: The Swamp of POX<br>単一URI、単一HTTPメソッド]
L1[Level 1: Resources<br>リソース指向のURI設計]
L2[Level 2: HTTP Verbs<br>HTTPメソッドの適切な使用]
L3[Level 3: Hypermedia Controls<br>HATEOAS - リンクによるナビゲーション]
L0 --> L1 --> L2 --> L3
style L3 fill:#4CAF50,color:#fff
| レベル |
特徴 |
例 |
| Level 0 |
単一エンドポイント、RPCスタイル |
POST /api ですべて処理 |
| Level 1 |
リソース指向のURI |
/users, /orders |
| Level 2 |
HTTPメソッドの活用 |
GET, POST, PUT, DELETE |
| Level 3 |
ハイパーメディアコントロール |
レスポンスにリンク情報を含む |
多くのREST APIはLevel 2で止まっていますが、Level 3のHATEOASを実装することで、以下のメリットが得られます。
HATEOASのメリット#
クライアントとサーバーの疎結合: クライアントはハードコードされたURLに依存せず、レスポンス内のリンクを辿ってリソースにアクセスします。サーバー側でURLが変更されても、リンク関係(rel)が維持されていればクライアントへの影響を最小限に抑えられます。
APIの自己記述性: レスポンスに含まれるリンクが、そのリソースから可能なアクションを示します。例えば、注文リソースにcancelリンクがあれば、その注文はキャンセル可能であることがわかります。
発見可能性: APIのエントリーポイントから、リンクを辿ることでAPIの全機能を発見できます。
HAL(Hypertext Application Language)形式#
Spring HATEOASはデフォルトでHAL形式をサポートします。HALは、リソースの表現にリンクと埋め込みリソースを追加するための規約です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
{
"id": 1,
"name": "山田太郎",
"email": "yamada@example.com",
"_links": {
"self": {
"href": "http://localhost:8080/api/users/1"
},
"users": {
"href": "http://localhost:8080/api/users"
},
"orders": {
"href": "http://localhost:8080/api/users/1/orders"
}
}
}
|
_linksオブジェクトには、リソースに関連するリンクが格納されます。各リンクはrel(関係)をキーとし、href(ハイパーテキスト参照)を値として持ちます。
Spring HATEOASの導入#
依存関係の追加#
Spring Boot 3.xプロジェクトにSpring HATEOASを導入するには、以下の依存関係を追加します。
Gradle(build.gradle):
1
2
3
|
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-hateoas'
}
|
Maven(pom.xml):
1
2
3
4
|
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
|
spring-boot-starter-hateoasにはspring-boot-starter-webが含まれているため、別途追加する必要はありません。
RepresentationModelクラス階層#
Spring HATEOASは、リンク情報を付与するための表現モデルクラスを提供しています。
classDiagram
class RepresentationModel {
+Links links
+add(Link... links)
+add(Iterable~Link~ links)
+getLinks()
+getLink(LinkRelation rel)
}
class EntityModel~T~ {
+T content
+of(T content, Link... links)
+getContent()
}
class CollectionModel~T~ {
+Collection~T~ content
+of(Iterable~T~ content, Link... links)
+getContent()
}
class PagedModel~T~ {
+PageMetadata metadata
+of(Collection~T~ content, PageMetadata metadata, Link... links)
}
RepresentationModel <|-- EntityModel
RepresentationModel <|-- CollectionModel
CollectionModel <|-- PagedModel
| クラス |
用途 |
RepresentationModel |
リンク情報のみを持つ基底クラス。カスタムモデルの基底として使用 |
EntityModel<T> |
単一リソースをラップし、リンク情報を付与 |
CollectionModel<T> |
コレクションリソースをラップし、リンク情報を付与 |
PagedModel<T> |
ページネーション情報を含むコレクションリソース |
EntityModelによる単一リソースの構築#
基本的なEntityModelの使用#
まず、シンプルなドメインクラスを定義します。
1
2
3
4
5
6
7
|
package com.example.demo.domain;
public record User(
Long id,
String name,
String email
) {}
|
次に、コントローラーでEntityModelを使用してリンク付きのレスポンスを返します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
package com.example.demo.controller;
import com.example.demo.domain.User;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.Link;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public EntityModel<User> getUser(@PathVariable Long id) {
// 実際のアプリケーションではリポジトリから取得
User user = new User(id, "山田太郎", "yamada@example.com");
return EntityModel.of(user,
linkTo(methodOn(UserController.class).getUser(id)).withSelfRel(),
linkTo(methodOn(UserController.class).getAllUsers()).withRel("users")
);
}
@GetMapping
public String getAllUsers() {
// 後ほど実装
return "users";
}
}
|
このエンドポイントにGETリクエストを送ると、以下のようなHAL形式のレスポンスが返されます。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
{
"id": 1,
"name": "山田太郎",
"email": "yamada@example.com",
"_links": {
"self": {
"href": "http://localhost:8080/api/users/1"
},
"users": {
"href": "http://localhost:8080/api/users"
}
}
}
|
WebMvcLinkBuilderの活用#
WebMvcLinkBuilderは、コントローラーのメソッドを参照してリンクを構築するためのビルダーです。主要なメソッドを解説します。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
// コントローラークラスを指定してリンクを構築
Link link1 = linkTo(UserController.class).withSelfRel();
// メソッド呼び出しを模擬してリンクを構築(推奨)
Link link2 = linkTo(methodOn(UserController.class).getUser(1L)).withSelfRel();
// カスタムrel名を指定
Link link3 = linkTo(methodOn(UserController.class).getAllUsers()).withRel("users");
// パスセグメントを追加
Link link4 = linkTo(UserController.class).slash("orders").withRel("orders");
|
methodOn()を使用すると、コンパイル時に型安全性が保証され、リファクタリング時にもリンクが追従します。
LinkRelationの活用#
標準的なリンク関係はIANA(Internet Assigned Numbers Authority)で定義されています。Spring HATEOASはIanaLinkRelationsクラスでこれらを提供しています。
1
2
3
4
5
6
7
8
9
10
11
12
|
import org.springframework.hateoas.IanaLinkRelations;
import org.springframework.hateoas.Link;
// IANA標準のリンク関係を使用
Link selfLink = linkTo(methodOn(UserController.class).getUser(id))
.withRel(IanaLinkRelations.SELF);
Link nextLink = linkTo(methodOn(UserController.class).getUsers(page + 1))
.withRel(IanaLinkRelations.NEXT);
Link prevLink = linkTo(methodOn(UserController.class).getUsers(page - 1))
.withRel(IanaLinkRelations.PREV);
|
よく使用されるIANAリンク関係:
| rel |
用途 |
self |
現在のリソースへのリンク |
next |
次のページへのリンク |
prev |
前のページへのリンク |
first |
最初のページへのリンク |
last |
最後のページへのリンク |
edit |
リソースを編集するためのリンク |
collection |
親コレクションへのリンク |
item |
コレクション内のアイテムへのリンク |
CollectionModelによるコレクションリソースの構築#
基本的なCollectionModelの使用#
複数のリソースを返す場合はCollectionModelを使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
@GetMapping
public CollectionModel<EntityModel<User>> getAllUsers() {
List<User> users = List.of(
new User(1L, "山田太郎", "yamada@example.com"),
new User(2L, "鈴木花子", "suzuki@example.com"),
new User(3L, "佐藤次郎", "sato@example.com")
);
List<EntityModel<User>> userModels = users.stream()
.map(user -> EntityModel.of(user,
linkTo(methodOn(UserController.class).getUser(user.id())).withSelfRel(),
linkTo(methodOn(UserController.class).getAllUsers()).withRel("users")
))
.toList();
return CollectionModel.of(userModels,
linkTo(methodOn(UserController.class).getAllUsers()).withSelfRel()
);
}
|
レスポンス例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
{
"_embedded": {
"userList": [
{
"id": 1,
"name": "山田太郎",
"email": "yamada@example.com",
"_links": {
"self": { "href": "http://localhost:8080/api/users/1" },
"users": { "href": "http://localhost:8080/api/users" }
}
},
{
"id": 2,
"name": "鈴木花子",
"email": "suzuki@example.com",
"_links": {
"self": { "href": "http://localhost:8080/api/users/2" },
"users": { "href": "http://localhost:8080/api/users" }
}
}
]
},
"_links": {
"self": { "href": "http://localhost:8080/api/users" }
}
}
|
@Relationアノテーションによるrel名のカスタマイズ#
デフォルトでは、HAL形式の_embedded内のキー名はクラス名から自動生成されます(例: userList)。@Relationアノテーションでカスタマイズできます。
1
2
3
4
5
6
7
8
9
10
|
package com.example.demo.domain;
import org.springframework.hateoas.server.core.Relation;
@Relation(collectionRelation = "users", itemRelation = "user")
public record User(
Long id,
String name,
String email
) {}
|
これにより、レスポンスは以下のように変わります。
1
2
3
4
5
6
7
8
|
{
"_embedded": {
"users": [
{ "id": 1, "name": "山田太郎", ... }
]
},
"_links": { ... }
}
|
RepresentationModelAssemblerによるモデル変換の共通化#
リソースからモデルへの変換ロジックが複数箇所で必要になる場合、RepresentationModelAssemblerSupportを継承したアセンブラを作成すると、コードの重複を避けられます。
アセンブラの実装#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
package com.example.demo.assembler;
import com.example.demo.controller.UserController;
import com.example.demo.domain.User;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.server.RepresentationModelAssembler;
import org.springframework.stereotype.Component;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
@Component
public class UserModelAssembler implements RepresentationModelAssembler<User, EntityModel<User>> {
@Override
public EntityModel<User> toModel(User user) {
return EntityModel.of(user,
linkTo(methodOn(UserController.class).getUser(user.id())).withSelfRel(),
linkTo(methodOn(UserController.class).getAllUsers()).withRel("users"),
linkTo(methodOn(UserController.class).getUserOrders(user.id())).withRel("orders")
);
}
}
|
コントローラーでの使用#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserModelAssembler userModelAssembler;
public UserController(UserModelAssembler userModelAssembler) {
this.userModelAssembler = userModelAssembler;
}
@GetMapping("/{id}")
public EntityModel<User> getUser(@PathVariable Long id) {
User user = new User(id, "山田太郎", "yamada@example.com");
return userModelAssembler.toModel(user);
}
@GetMapping
public CollectionModel<EntityModel<User>> getAllUsers() {
List<User> users = List.of(
new User(1L, "山田太郎", "yamada@example.com"),
new User(2L, "鈴木花子", "suzuki@example.com")
);
return userModelAssembler.toCollectionModel(users)
.add(linkTo(methodOn(UserController.class).getAllUsers()).withSelfRel());
}
@GetMapping("/{id}/orders")
public String getUserOrders(@PathVariable Long id) {
return "orders for user " + id;
}
}
|
toCollectionModel()メソッドはRepresentationModelAssemblerインターフェースで定義されており、各要素に対してtoModel()を適用したCollectionModelを返します。
条件付きリンクの追加#
リソースの状態に応じて、利用可能なアクションを示すリンクを動的に追加することが重要です。
注文リソースの例#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
package com.example.demo.domain;
public record Order(
Long id,
Long userId,
String status,
java.math.BigDecimal totalAmount
) {
public boolean isCancellable() {
return "PENDING".equals(status) || "PROCESSING".equals(status);
}
public boolean isPayable() {
return "PENDING".equals(status);
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
package com.example.demo.assembler;
import com.example.demo.controller.OrderController;
import com.example.demo.domain.Order;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.server.RepresentationModelAssembler;
import org.springframework.stereotype.Component;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
@Component
public class OrderModelAssembler implements RepresentationModelAssembler<Order, EntityModel<Order>> {
@Override
public EntityModel<Order> toModel(Order order) {
EntityModel<Order> model = EntityModel.of(order,
linkTo(methodOn(OrderController.class).getOrder(order.id())).withSelfRel(),
linkTo(methodOn(OrderController.class).getAllOrders()).withRel("orders")
);
// 状態に応じたリンクを条件付きで追加
if (order.isCancellable()) {
model.add(linkTo(methodOn(OrderController.class).cancelOrder(order.id())).withRel("cancel"));
}
if (order.isPayable()) {
model.add(linkTo(methodOn(OrderController.class).payOrder(order.id())).withRel("pay"));
}
return model;
}
}
|
PENDING状態の注文に対するレスポンス例:
1
2
3
4
5
6
7
8
9
10
11
12
|
{
"id": 1,
"userId": 100,
"status": "PENDING",
"totalAmount": 15000,
"_links": {
"self": { "href": "http://localhost:8080/api/orders/1" },
"orders": { "href": "http://localhost:8080/api/orders" },
"cancel": { "href": "http://localhost:8080/api/orders/1/cancel" },
"pay": { "href": "http://localhost:8080/api/orders/1/pay" }
}
}
|
SHIPPED状態の注文ではキャンセルや支払いリンクが含まれません。
1
2
3
4
5
6
7
8
9
10
|
{
"id": 2,
"userId": 100,
"status": "SHIPPED",
"totalAmount": 8000,
"_links": {
"self": { "href": "http://localhost:8080/api/orders/2" },
"orders": { "href": "http://localhost:8080/api/orders" }
}
}
|
PagedModelによるページネーション#
Spring Data JPAのPageオブジェクトと連携して、ページネーション情報を含むレスポンスを生成できます。
PagedResourcesAssemblerの使用#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
package com.example.demo.controller;
import com.example.demo.assembler.UserModelAssembler;
import com.example.demo.domain.User;
import com.example.demo.repository.UserRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PagedResourcesAssembler;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.PagedModel;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserRepository userRepository;
private final UserModelAssembler userModelAssembler;
private final PagedResourcesAssembler<User> pagedResourcesAssembler;
public UserController(
UserRepository userRepository,
UserModelAssembler userModelAssembler,
PagedResourcesAssembler<User> pagedResourcesAssembler) {
this.userRepository = userRepository;
this.userModelAssembler = userModelAssembler;
this.pagedResourcesAssembler = pagedResourcesAssembler;
}
@GetMapping
public PagedModel<EntityModel<User>> getAllUsers(Pageable pageable) {
Page<User> userPage = userRepository.findAll(pageable);
return pagedResourcesAssembler.toModel(userPage, userModelAssembler);
}
}
|
レスポンス例(/api/users?page=0&size=2):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
{
"_embedded": {
"users": [
{
"id": 1,
"name": "山田太郎",
"email": "yamada@example.com",
"_links": {
"self": { "href": "http://localhost:8080/api/users/1" },
"users": { "href": "http://localhost:8080/api/users" }
}
},
{
"id": 2,
"name": "鈴木花子",
"email": "suzuki@example.com",
"_links": {
"self": { "href": "http://localhost:8080/api/users/2" },
"users": { "href": "http://localhost:8080/api/users" }
}
}
]
},
"_links": {
"first": { "href": "http://localhost:8080/api/users?page=0&size=2" },
"self": { "href": "http://localhost:8080/api/users?page=0&size=2" },
"next": { "href": "http://localhost:8080/api/users?page=1&size=2" },
"last": { "href": "http://localhost:8080/api/users?page=4&size=2" }
},
"page": {
"size": 2,
"totalElements": 10,
"totalPages": 5,
"number": 0
}
}
|
HAL Explorerの活用#
HAL Explorerは、HAL形式のAPIを視覚的にナビゲートできるブラウザベースのツールです。
依存関係の追加#
1
2
3
|
dependencies {
implementation 'org.springframework.data:spring-data-rest-hal-explorer'
}
|
Maven:
1
2
3
4
|
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-rest-hal-explorer</artifactId>
</dependency>
|
HAL Explorerへのアクセス#
アプリケーションを起動後、ブラウザでhttp://localhost:8080にアクセスすると、HAL Explorerが表示されます。
HAL Explorerでは以下のことができます:
- APIエンドポイントの探索
- レスポンスのリンクをクリックしてナビゲート
- フォームを使ったPOST/PUT/DELETEリクエストの送信
- レスポンスのJSON構造の確認
APIルートエンドポイントの作成#
HAL Explorerを効果的に活用するには、APIのエントリーポイントを設けることが推奨されます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
package com.example.demo.controller;
import org.springframework.hateoas.RepresentationModel;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
@RestController
@RequestMapping("/api")
public class RootController {
@GetMapping
public RepresentationModel<?> root() {
RepresentationModel<?> model = new RepresentationModel<>();
model.add(linkTo(methodOn(RootController.class).root()).withSelfRel());
model.add(linkTo(methodOn(UserController.class).getAllUsers(null)).withRel("users"));
model.add(linkTo(methodOn(OrderController.class).getAllOrders()).withRel("orders"));
return model;
}
}
|
レスポンス例:
1
2
3
4
5
6
7
|
{
"_links": {
"self": { "href": "http://localhost:8080/api" },
"users": { "href": "http://localhost:8080/api/users" },
"orders": { "href": "http://localhost:8080/api/orders" }
}
}
|
実装時の注意点とベストプラクティス#
リンクの一貫性を保つ#
すべてのレスポンスにselfリンクを含めることで、クライアントは常に現在のリソースの正確な場所を知ることができます。
適切なrel名を選択する#
可能な限りIANA標準のリンク関係を使用し、カスタムrel名が必要な場合は明確で一貫性のある命名規則を採用します。
リンクの過不足に注意する#
すべてのリソースにすべてのリンクを含める必要はありません。クライアントが実際に必要とするリンクのみを含めます。
URIテンプレートの活用#
動的なパラメータを持つリンクにはURIテンプレートを使用できます。
1
2
|
Link searchLink = Link.of("/api/users{?name,email}")
.withRel("search");
|
レスポンス:
1
2
3
4
5
6
7
8
|
{
"_links": {
"search": {
"href": "/api/users{?name,email}",
"templated": true
}
}
}
|
パフォーマンスへの配慮#
リンクの生成は追加のオーバーヘッドを伴います。大量のリソースを返す場合は、必要最小限のリンクに絞ることを検討してください。
まとめ#
本記事では、Spring Boot REST APIにおけるHATEOASの実装方法を解説しました。
主なポイントは以下の通りです:
- HATEOASはREST成熟度モデルのLevel 3に位置し、ハイパーメディアによるAPIナビゲーションを実現する
EntityModelで単一リソース、CollectionModelでコレクションリソースにリンク情報を付与できる
WebMvcLinkBuilderのlinkTo()とmethodOn()で型安全なリンク構築が可能
RepresentationModelAssemblerでモデル変換ロジックを共通化できる
- 条件付きリンクによりリソースの状態に応じた操作を示せる
- HAL Explorerを使用してAPIを視覚的にナビゲートできる
HATEOASを適切に実装することで、クライアントとサーバーの疎結合を実現し、APIの変更に対する柔軟性を高められます。
参考リンク#