Spring HATEOAS 入門

ハテオアスって読んで生きてきたけど、たぶん、そうは読まない。

# 1. HATEOAS とは

HATEOAS（**H**ypermedia **A**s **T**he **E**ngine **O**f **A**pplication **S**tate）は、Web API の成熟度モデルにおける最高レベル（Level 3）を実現するための設計、または、実装における思想です。

参考： [Web API 成熟度モデル](https://qiita.com/kannkyo/items/fc7f2e9933ded926a6b8)

API レスポンスに、関連リソースへのリンク情報を含めることで、クライアントは**事前にエンドポイントを知ることなく、API を探索的に利用できる**ようになります。
つまり、普段ブラウザでネットサーフィンをするときに Web ページのリンクを辿りながら、その Web サイトのいろんなリソースを利用できるように Web API を利用できるようにしよう、という考えです。

例えば、記事投稿サイトのユーザー情報を取得する API であれば、以下のようなレスポンスを返します。

```json
{
  "id": 1,
  "name": "ハテオアス太郎",
  "email": "hateoas.taro@example.com",
  "_links": {
    "self": {
      "href": "http://localhost:8080/api/users/1"
    },
    "articles": {
      "href": "http://localhost:8080/api/users/1/articles"
    }
  }
}
```

# 2. Spring HATEOAS

Spring Framework では、Spring HATEOAS ライブラリを使用して HATEOAS な Web API を実装できます。

公式ドキュメントはこちら。

https://spring.pleiades.io/spring-hateoas/docs/current/reference/html/

以下の依存関係を `pom.xml` に追加すれば利用できます。

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

※ 基本的には `spring-web` と一緒に使いますが、`spring-boot-starter-hateoas` 自体が `spring-web` に依存しているため、自動的に `spring-web` も使えるようになります。

# 3. 基本的な使い方

Spring HATEOAS を使った簡単なサンプルアプリケーションを実装します。

全体のサンプルコードは GitHub に置きました。

https://github.com/nimzo6689/spring-hateoas-demo

## エンティティの作成

まず、基本的なエンティティを作成します。

```java:User.java
// ユーザ情報
public record User(Long id, String name, String email) {}
```

```java:Article.java
// 記事情報
public record Article(Long id, String title, String content) {}
```

## RepresentationModel の作成

続いて、 HATEOAS のリンクを含む `RepresentationModel` を継承したモデルを作成します。
`RepresentationModel` は、Spring HATEOAS における最も基本的と言えるモデルクラスです。

HATEOAS のリンク情報を保持・管理し、リンクの追加・取得のための標準的なメソッドを提供します。また、JSON シリアライズ時に `_links` セクションを自動生成してくれます。

```java:UserModel.java
public class UserModel extends RepresentationModel<UserModel> {
    private final Long id;
    private final String name;
    private final String email;

    public UserModel(User user) {
        this.id = user.id();
        this.name = user.name();
        this.email = user.email();
    }

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

## コントローラーの実装

続いて、 Controller 層を実装します。
今回は DB などは用意していないので、Service 層で固定値の Response を返すようにしています。ただそこは重要でないので、本記事では Service 層の実装は割愛しました。

```java:UserController.java
@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;
    private final ArticleService articleService;

    public UserController(UserService userService, ArticleService articleService) {
        this.userService = userService;
        this.articleService = articleService;
    }

    /**
     * ユーザー情報を取得するAPI。
     * HATEOAS形式のレスポンスを返却するため、EntityModelでラップしています。
     * レスポンスには、自身へのリンク(self)と記事一覧へのリンク(articles)が含まれます。
     *
     * レスポンス例：
     * {
     *   "id": 1,
     *   "name": "user name",
     *   "_links": {
     *     "self": {
     *       "href": "http://localhost:8080/api/users/1"
     *     },
     *     "articles": {
     *       "href": "http://localhost:8080/api/users/1/articles"
     *     }
     *   }
     * }
     */
    @GetMapping("/{id}")
    public EntityModel<UserModel> getUser(@PathVariable Long id) {
        // ユーザー情報を取得
        var user = userService.findById(id);
        var userModel = new UserModel(user);

        // HATEOAS用のリンクを追加
        // methodOn: コントローラーメソッドへの参照を型安全に生成
        // linkTo: URLを生成
        // withSelfRel: 自身へのリンクであることを示す（"self"という関係名）
        // withRel: リンクの関係名を指定（ここでは"articles"）
        userModel.add(
                linkTo(methodOn(UserController.class).getUser(id)).withSelfRel(),
                linkTo(methodOn(UserController.class).getArticles(id)).withRel("articles"));

        // EntityModelでラップして返却
        return EntityModel.of(userModel);
    }

    /**
     * ユーザーに紐づく記事一覧を取得するAPI。
     * CollectionModelでラップすることで、コレクションに対するHATEOASリンクを付与できます。
     *
     * レスポンス例：
     * {
     *   "_embedded": {
     *     "articleList": [
     *       {
     *         "id": 1,
     *         "title": "記事タイトル"
     *       }
     *     ]
     *   },
     *   "_links": {
     *     "self": {
     *       "href": "http://localhost:8080/api/users/1/articles"
     *     }
     *   }
     * }
     */
    @GetMapping("/{userId}/articles")
    public CollectionModel<Article> getArticles(@PathVariable Long userId) {
        // ユーザーに紐づく記事一覧を取得
        var articles = this.articleService.findByUserId(userId);

        // CollectionModelでラップして返却
        // Spring HATEOASが自動的に_embedded構造を生成
        return CollectionModel.of(articles);
    }
}
```

ここまでで実装は完了です。

`./mvnw spring-boot:run` でアプリを起動し、 `curl http://localhost:8080/api/users/1` のように Web API をコールすれば、記事の冒頭で記載している JSON 文字列が取得できます。

# 4. テスト方法

HATEOAS な Web API をテストするサンプルコードも載せておきます。
一般的な Spring Web での API 向けテストコードと実装方法は変わりません。

```java
@SpringBootTest
@AutoConfigureMockMvc
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;
    @MockitoBean
    private UserService userService;

    @Test
    void getUserShouldReturnLinksTest() throws Exception {
        // モックの戻り値を設定
        var userId = 42L;
        var mockUser = new User(userId, "test user", "test@example.com");
        when(userService.findById(userId)).thenReturn(mockUser);

        mockMvc.perform(get("/api/users/" + userId))
                .andExpect(status().isOk())
                // ユーザー情報の検証
                .andExpect(jsonPath("$.id").value(mockUser.id()))
                .andExpect(jsonPath("$.name").value(mockUser.name()))
                .andExpect(jsonPath("$.email").value(mockUser.email()))
                // リンクの検証
                .andExpect(jsonPath("$._links.self.href")
                        .value("http://localhost/api/users/" + userId))
                .andExpect(jsonPath("$._links.articles.href")
                        .value("http://localhost/api/users/" + userId + "/articles"));

        // モックメソッドが呼ばれたことを検証
        verify(userService).findById(userId);
    }
}
```

# 5. おわりに

Spring HATEOAS 自体は前からあるライブラリですが、あまり普及していない印象。
従来の REST API よりも利用者側からすると使いやすいですが、提供者側からすると実装が複雑になる点がネックでこの域まで手を出しづらいと感じてます。

ただ、roadmap.sh の [API Design Roadmap](https://roadmap.sh/api-design) には HATEOAS が入っているので、Web API 開発者としては無視しにくい存在。