跳至主要内容

HATEOAS Link 使用指南

Package: io.leandev.appfuse.web.link.* 狀態: 穩定


簡介

LinkBuilder 是 AppFuse Server 提供的 HATEOAS 連結建構工具,採用相對路徑設計,專為 Reverse Proxy 和雲原生架構優化。

核心特色

特色說明
相對路徑不含 host/port,支援 Reverse Proxy
Builder 模式流暢的鏈式 API
不可變設計執行緒安全
Spring 整合使用 Spring HATEOAS Link

為何使用相對路徑?

// AppFuse LinkBuilder 生成相對路徑
"api/users/123"

// 傳統做法生成絕對 URL
"http://localhost:8080/api/users/123"

相對路徑優勢

  • ✅ 支援 Reverse Proxy 架構
  • ✅ 無需配置 Forwarded Headers
  • ✅ 同一回應適用所有環境(dev/staging/prod)
  • ✅ 避免暴露內部網路資訊

快速開始

基本使用

import io.leandev.appfuse.web.link.LinkBuilder;
import org.springframework.hateoas.Link;

// 基於 Controller 建立連結
Link selfLink = LinkBuilder.linkTo(UserController.class)
.slash(123)
.withSelfRel();

// 結果: Link{rel='self', href='api/users/123'}

完整範例

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

@GetMapping("/{id}")
public ResponseEntity<UserResource> getUser(@PathVariable Long id) {
User user = userService.findById(id);
UserResource resource = toResource(user);
return ResponseEntity.ok(resource);
}

private UserResource toResource(User user) {
UserResource resource = new UserResource(user);

// 自我連結
resource.add(
LinkBuilder.linkTo(UserController.class)
.slash(user.getId())
.withSelfRel()
);

// 相關資源連結
resource.add(
LinkBuilder.linkTo(UserController.class)
.slash(user.getId())
.slash("orders")
.withRel("orders")
);

// 集合資源連結
resource.add(
LinkBuilder.linkTo(UserController.class)
.withRel("all-users")
);

return resource;
}
}

JSON 回應範例

{
"id": 123,
"name": "John Doe",
"email": "john@example.com",
"_links": {
"self": { "href": "api/users/123" },
"orders": { "href": "api/users/123/orders" },
"all-users": { "href": "api/users" }
}
}

核心 API

靜態工廠方法

LinkBuilder.linkTo(Class<?> controller)

自動發現 Controller 的 @RequestMapping 路徑作為基礎 URI。

slash(Object path)

添加路徑段:

LinkBuilder.linkTo(UserController.class)
.slash("123") // api/users/123
.slash("orders") // api/users/123/orders
.slash("latest") // api/users/123/orders/latest

withContext(String context)

添加可選的 context 前綴:

LinkBuilder.linkTo(UserController.class)
.withContext("v1")
.slash("123")
.withSelfRel();
// 結果: v1/api/users/123

適用場景:API 版本隔離、多應用共享域名、租戶路徑隔離

withSelfRel() / withRel(String rel)

生成連結:

Link selfLink = builder.withSelfRel();      // rel="self"
Link ordersLink = builder.withRel("orders"); // rel="orders"

toUri() / toHref()

URI uri = builder.toUri();       // URI 物件
String href = builder.toHref(); // 字串 "api/users/123"

常見場景

場景 1: 多層路徑

// 使用者的訂單中的特定商品
Link link = LinkBuilder.linkTo(UserController.class)
.slash(userId)
.slash("orders")
.slash(orderId)
.slash("items")
.slash(itemId)
.withSelfRel();
// 結果: api/users/123/orders/456/items/789

場景 2: 巢狀資源

@GetMapping("/{userId}/orders")
public ResponseEntity<List<OrderResource>> getUserOrders(@PathVariable Long userId) {
List<Order> orders = orderService.findByUserId(userId);

List<OrderResource> resources = orders.stream()
.map(order -> {
OrderResource resource = new OrderResource(order);

// 訂單的 self 連結
resource.add(
LinkBuilder.linkTo(UserController.class)
.slash(userId)
.slash("orders")
.slash(order.getId())
.withSelfRel()
);

// 返回到使用者
resource.add(
LinkBuilder.linkTo(UserController.class)
.slash(userId)
.withRel("user")
);

return resource;
})
.collect(Collectors.toList());

return ResponseEntity.ok(resources);
}

場景 3: 分頁連結

// self 連結
resource.addLink(LinkBuilder.linkTo(controller).withSelfRel());

// 下一頁
if (page.hasNext()) {
resource.addLink(
LinkBuilder.linkTo(controller)
.slash("?page=" + (page.getNumber() + 1))
.withRel("next")
);
}

// 上一頁
if (page.hasPrevious()) {
resource.addLink(
LinkBuilder.linkTo(controller)
.slash("?page=" + (page.getNumber() - 1))
.withRel("prev")
);
}

前端整合

React/TypeScript

interface Link {
href: string;
}

interface UserResource {
id: number;
name: string;
_links: {
self: Link;
orders: Link;
};
}

async function fetchUser(userId: string): Promise<UserResource> {
const response = await fetch(`/api/users/${userId}`);
const user: UserResource = await response.json();

// 直接使用相對連結
const ordersResponse = await fetch(user._links.orders.href);
const orders = await ordersResponse.json();

return user;
}

// 或使用 URL API 明確組合
function getFullUrl(link: Link, baseUrl: string): string {
return new URL(link.href, baseUrl).toString();
}

與 Spring WebMvcLinkBuilder 的差異

特性Spring WebMvcLinkBuilderAppFuse LinkBuilder
方法引用methodOn()❌ 僅支援 Class
路徑建構自動提取手動 slash()
輸出格式絕對 URL相對路徑
Reverse Proxy需配置原生支援

為何自建?

  1. 輕量級:避免完整 Spring MVC 依賴
  2. 雲原生:相對路徑更適合容器化部署
  3. 彈性:整合 Apache URIBuilder 提供更多控制

常見問題

Q: 前端如何處理相對路徑?

A: 瀏覽器的 fetch() API 會自動基於當前頁面 URL 解析。或使用 new URL(relativeUrl, baseUrl) 明確組合。

Q: 如何處理查詢參數?

A: 目前可通過 slash() 手動添加:

LinkBuilder.linkTo(UserController.class)
.slash("?page=2&size=10")
.withSelfRel()

API 參考

詳細的類別設計,請參閱 API 參考: LinkJavadoc