mcpField
A
api-contract-review

api-contract-review

github.com/decebals测试与质量761291 次安装7 周前更新
代码仓库

关于 api-contract-review

用于审查 REST API 正确性、一致性和兼容性的 Skill,覆盖 HTTP 语义、端点设计、响应、错误处理与向后兼容。

主要功能

  1. 01检查 HTTP 方法和状态码用法
  2. 02审查 URL 命名与版本管理
  3. 03检查 DTO、分页和响应一致性
  4. 04检查错误格式与信息泄露
  5. 05识别破坏性变更及弃用需求

适用场景

  1. 01发布前审查 API 变更
  2. 02检查 Pull Request 中的控制器变更
  3. 03评估向后兼容性
  4. 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+jsonURL 简洁不易察觉,较难测试
查询参数/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 OKGET、PUT、PATCH 成功资源或结果
201 CreatedPOST 成功(已创建)已创建的资源 + Location 标头
204 No ContentDELETE 成功,或 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:

  1. 列出所有控制器:find . -name "*Controller.java"
  2. 抽查 2-3 个控制器,分析其模式
  3. 检查一次 @ExceptionHandler 配置
  4. 使用 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