关于 api-contract-review
用于审查 REST API 正确性、一致性和兼容性的 Skill,覆盖 HTTP 语义、端点设计、响应、错误处理与向后兼容。
主要功能
- 01检查 HTTP 方法和状态码用法
- 02审查 URL 命名与版本管理
- 03检查 DTO、分页和响应一致性
- 04检查错误格式与信息泄露
- 05识别破坏性变更及弃用需求
适用场景
- 01发布前审查 API 变更
- 02检查 Pull Request 中的控制器变更
- 03评估向后兼容性
- 04审计端点与错误响应设计
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ decebals/ claude-code-java --skill api-contract-review安装后在 Claude Code 中输入/api-contract-review确认已加载。
附带文件README.md
详细介绍
API 契约审查技能
审查 REST API 设计的正确性、一致性和兼容性。
适用场景
- 用户要求“审查此 API”/“检查 REST 端点”
- 发布 API 变更前
- 审查包含控制器变更的 PR
- 检查向后兼容性
快速参考:常见问题
| 问题 | 症状 | 影响 |
|---|---|---|
| HTTP 动词错误 | 对幂等操作使用 POST | 造成混淆、引发缓存问题 |
| 缺少版本控制 | 使用 /users 而不是 /v1/users | 破坏性变更会影响所有客户端 |
| 实体泄漏 | 在响应中返回 JPA 实体 | 暴露内部实现,存在 N+1 风险 |
| 出错时返回 200 | {"status": 200, "error": "..."} | 导致错误处理失效 |
| 命名不一致 | /getUsers 和 /users 混用 | API 难以理解 |
HTTP 动词语义
动词选择指南
| 动词 | 用途 | 幂等 | 安全 | 请求体 |
|---|---|---|---|---|
| GET | 获取资源 | 是 | 是 | 否 |
| POST | 创建新资源 | 否 | 否 | 是 |
| PUT | 替换整个资源 | 是 | 否 | 是 |
| PATCH | 部分更新 | 否* | 否 | 是 |
| DELETE | 删除资源 | 是 | 否 | 可选 |
*PATCH 是否幂等取决于具体实现
常见错误
// ❌ POST for retrieval
@PostMapping("/users/search")
public List<User> searchUsers(@RequestBody SearchCriteria criteria) { }
// ✅ GET with query params (or POST only if criteria is very complex)
@GetMapping("/users")
public List<User> searchUsers(
@RequestParam String name,
@RequestParam(required = false) String email) { }
// ❌ GET for state change
@GetMapping("/users/{id}/activate")
public void activateUser(@PathVariable Long id) { }
// ✅ POST or PATCH for state change
@PostMapping("/users/{id}/activate")
public ResponseEntity<Void> activateUser(@PathVariable Long id) { }
// ❌ POST for idempotent update
@PostMapping("/users/{id}")
public User updateUser(@PathVariable Long id, @RequestBody UserDto dto) { }
// ✅ PUT for full replacement, PATCH for partial
@PutMapping("/users/{id}")
public User replaceUser(@PathVariable Long id, @RequestBody UserDto dto) { }
@PatchMapping("/users/{id}")
public User updateUser(@PathVariable Long id, @RequestBody UserPatchDto dto) { }
API 版本控制
策略
| 策略 | 示例 | 优点 | 缺点 |
|---|---|---|---|
| URL 路径 | /v1/users | 清晰,易于路由 | URL 会变化 |
| 标头 | Accept: application/vnd.api.v1+json | URL 简洁 | 不易察觉,较难测试 |
| 查询参数 | /users?version=1 | 易于添加 | 容易忘记 |
推荐:URL 路径
// ✅ Versioned endpoints
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 { }
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 { }
// ❌ No versioning
@RestController
@RequestMapping("/api/users") // Breaking changes affect everyone
public class UserController { }
版本检查清单
- 所有公共 API 的路径中都包含版本号
- 内部 API 标注为内部 API(或也进行版本控制)
- 为旧版本制定弃用策略
请求/响应设计
DTO 与实体
// ❌ Entity in response (leaks internals)
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
return userRepository.findById(id).orElseThrow();
// Exposes: password hash, internal IDs, lazy collections
}
// ✅ DTO response
@GetMapping("/{id}")
public UserResponse getUser(@PathVariable Long id) {
User user = userService.findById(id);
return UserResponse.from(user); // Only public fields
}
响应一致性
// ❌ Inconsistent responses
@GetMapping("/users")
public List<User> getUsers() { } // Returns array
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) { } // Returns object
@GetMapping("/users/count")
public int countUsers() { } // Returns primitive
// ✅ Consistent wrapper (optional but recommended for large APIs)
@GetMapping("/users")
public ApiResponse<List<UserResponse>> getUsers() {
return ApiResponse.success(userService.findAll());
}
// Or at minimum, consistent structure:
// - Collections: always wrapped or always raw (pick one)
// - Single items: always object
// - Counts/stats: always object { "count": 42 }
分页
// ❌ No pagination on collections
@GetMapping("/users")
public List<User> getAllUsers() {
return userRepository.findAll(); // Could be millions
}
// ✅ Paginated
@GetMapping("/users")
public Page<UserResponse> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return userService.findAll(PageRequest.of(page, size));
}
HTTP 状态码
成功状态码
| 代码 | 使用场景 | 响应体 |
|---|---|---|
| 200 OK | GET、PUT、PATCH 成功 | 资源或结果 |
| 201 Created | POST 成功(已创建) | 已创建的资源 + Location 标头 |
| 204 No Content | DELETE 成功,或 PUT 无响应体 | 空 |
错误状态码
| 代码 | 使用场景 | 常见错误 |
|---|---|---|
| 400 Bad Request | 输入无效、验证失败 | 用于“未找到” |
| 401 Unauthorized | 未通过身份验证 | 与 403 混淆 |
| 403 Forbidden | 已通过身份验证,但无权访问 | 错误地使用 401 |
| 404 Not Found | 资源不存在 | 错误地使用 400 |
| 409 Conflict | 重复、并发修改 | 错误地使用 400 |
| 422 Unprocessable | 语义错误(语法有效,含义无效) | 错误地使用 400 |
| 500 Internal Error | 意外的服务端错误 | 暴露堆栈跟踪 |
反模式:错误响应体却返回 200
// ❌ NEVER DO THIS
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> getUser(@PathVariable Long id) {
try {
User user = userService.findById(id);
return ResponseEntity.ok(Map.of("status", "success", "data", user));
} catch (NotFoundException e) {
return ResponseEntity.ok(Map.of( // Still 200!
"status", "error",
"message", "User not found"
));
}
}
// ✅ Use proper status codes
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
错误响应格式
一致的错误结构
// ✅ Standard error response
public class ErrorResponse {
private String code; // Machine-readable: "USER_NOT_FOUND"
private String message; // Human-readable: "User with ID 123 not found"
private Instant timestamp;
private String path;
private List<FieldError> errors; // For validation errors
}
// In GlobalExceptionHandler
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(
ResourceNotFoundException ex, HttpServletRequest request) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.builder()
.code("RESOURCE_NOT_FOUND")
.message(ex.getMessage())
.timestamp(Instant.now())
.path(request.getRequestURI())
.build());
}
安全:不要暴露内部信息
// ❌ Exposes stack trace
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleAll(Exception ex) {
return ResponseEntity.status(500)
.body(ex.getStackTrace().toString()); // Security risk!
}
// ✅ Generic message, log details server-side
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleAll(Exception ex) {
log.error("Unexpected error", ex); // Full details in logs
return ResponseEntity.status(500)
.body(ErrorResponse.of("INTERNAL_ERROR", "An unexpected error occurred"));
}
向后兼容性
破坏性变更(避免在同一版本中进行)
| 变更 | 是否有破坏性? | 迁移方式 |
|---|---|---|
| 移除端点 | 是 | 先弃用,在下一个版本中移除 |
| 从响应中移除字段 | 是 | 保留字段,返回 null/默认值 |
| 在请求中添加必填字段 | 是 | 设为可选并提供默认值 |
| 更改字段类型 | 是 | 添加新字段,弃用旧字段 |
| 重命名字段 | 是 | 暂时同时支持新旧字段 |
| 更改 URL 路径 | 是 | 将旧路径重定向到新路径 |
非破坏性变更(安全)
- 在请求中添加可选字段
- 在响应中添加字段
- 添加新端点
- 添加新的可选查询参数
弃用模式
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
@Deprecated
@GetMapping("/by-email") // Old endpoint
public UserResponse getByEmailOld(@RequestParam String email) {
return getByEmail(email); // Delegate to new
}
@GetMapping(params = "email") // New pattern
public UserResponse getByEmail(@RequestParam String email) {
return userService.findByEmail(email);
}
}
API 审查清单
1. HTTP 语义
- GET 仅用于获取(不产生副作用)
- POST 用于创建(返回 201 + Location)
- PUT 用于完整替换(幂等)
- PATCH 用于部分更新
- DELETE 用于删除(幂等)
2. URL 设计
- 带版本号(
/v1/、/v2/) - 使用名词,而非动词(
/users,而非/getUsers) - 集合使用复数形式(
/users,而非/user) - 使用层级结构表示关系(
/users/{id}/orders) - 命名保持一致(选择 kebab-case 或 camelCase)
3. 请求处理
- 使用
@Valid进行验证 - 验证失败时提供清晰的错误消息
- 使用请求 DTO(而非实体)
- 设置合理的大小限制
4. 响应设计
- 使用响应 DTO(而非实体)
- 各端点的结构保持一致
- 集合支持分页
- 使用恰当的状态码(错误时不要返回 200)
5. 错误处理
- 错误格式保持一致
- 错误代码可供机器读取
- 消息可供人类阅读
- 不暴露堆栈跟踪
- 正确区分 4xx 和 5xx
6. 兼容性
- 当前版本中不引入不兼容的变更
- 记录已弃用的端点
- 为不兼容的变更提供迁移路径
令牌优化
对于大型 API:
- 列出所有控制器:
find . -name "*Controller.java" - 抽查 2-3 个控制器,分析其模式
- 检查一次
@ExceptionHandler配置 - 使用 grep 查找特定的反模式:
# Find potential entity leaks grep -r "public.*Entity.*@GetMapping" --include="*.java" # Find 200 with error patterns grep -r "ResponseEntity.ok.*error" --include="*.java" # Find unversioned APIs grep -r "@RequestMapping.*api" --include="*.java" | grep -v "/v[0-9]"
评论
支持 Markdown