关于 rest-api-conventions
rest-api-conventions 是用于添加和审查 REST 端点的技能,强调保留项目现有 API 契约,并为编码 Agent 提供状态码、URL、分页、错误处理及常见实现陷阱方面的指导。
主要功能
- 01保留现有响应与版本契约
- 02将 HTTP 状态映射到 API 结果
- 03遵循 URL 与资源嵌套约定
- 04设计有上限的分页
- 05按既定策略处理错误
- 06避免常见 Spring API 陷阱
适用场景
- 01添加端点时不改动无关 API 契约
- 02设置状态码和资源 URL 模式
- 03配置分页限制与稳定的响应结构
- 04审查 Spring 控制器的异常处理
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ rrezartprebreza/ spring-boot-skills --skill rest-api-conventions安装后在 Claude Code 中输入/rest-api-conventions确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
agents/openai.yamlexamples/bad-controller.javagood-controller.javatemplates/ApiResponse.javaGlobalExceptionHandler.javaPageResponse.java详细介绍
REST API 约定
项目契约
选择响应契约前,先检查现有控制器、测试和 OpenAPI。 保留项目现有的 ID、响应结构和版本策略。添加路由时,不要迁移无关端点。下面的信封格式是采用该格式的项目可选的约定;直接使用成功 DTO 同样有效。204 响应没有响应体。
成功和错误格式彼此独立:现有的成功信封可以与 RFC 9457 错误格式共存。统一采用一种错误策略;仅当项目已有要求时,才选择下面的旧版错误示例。
成功信封示例:
{
"success": true,
"data": { },
"error": null,
"timestamp": "2026-04-13T10:00:00Z"
}
错误响应:
{
"success": false,
"data": null,
"error": {
"code": "ORDER_NOT_FOUND",
"message": "Order with id 123 not found",
"details": []
},
"timestamp": "2026-04-13T10:00:00Z"
}
ApiResponse 包装器
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ApiResponse<T>(
boolean success,
T data,
ApiError error,
Instant timestamp
) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(true, data, null, Instant.now());
}
public static <T> ApiResponse<T> error(String code, String message) {
return new ApiResponse<>(false, null, new ApiError(code, message, List.of()), Instant.now());
}
}
public record ApiError(String code, String message, List<String> details) {}
HTTP 状态码映射
| 场景 | 状态码 |
|---|---|
| GET — 已找到 | 200 |
| POST — 已创建资源 | 201 |
| PUT/PATCH — 已更新 | 200 |
| DELETE — 已删除 | 204(无响应体) |
| 验证失败 | 400 |
| 未认证 | 401 |
| 禁止访问 | 403 |
| 未找到 | 404 |
| 冲突(重复) | 409 |
| 未处理的服务器错误 | 500 |
URL 约定
- 资源使用复数名词:
/orders、/users、/products - 多词名称使用 kebab-case:
/order-items,而不是/orderItems - 在路径中进行版本控制:
/api/v1/orders - 嵌套资源最多 2 层:
/orders/{id}/items✅,/orders/{id}/items/{itemId}/notes❌ — 扁平化为/order-item-notes/{id} - ID:保留现有 ID 类型。UUID 是一种选择,但不是授权机制。
GET /api/v1/orders → list (paginated)
POST /api/v1/orders → create
GET /api/v1/orders/{id} → get one
PUT /api/v1/orders/{id} → full update
PATCH /api/v1/orders/{id} → partial update
DELETE /api/v1/orders/{id} → delete
GET /api/v1/orders/{id}/items → nested resource
分页
{
"success": true,
"data": {
"content": [...],
"page": 0,
"size": 20,
"totalElements": 150,
"totalPages": 8,
"last": false
}
}
查询参数:?page=0&size=20&sort=createdAt,desc
在控制器中使用 Spring Data Pageable:
@GetMapping
public ApiResponse<PageResponse<OrderResponse>> list(Pageable pageable) {
return ApiResponse.ok(PageResponse.from(orderService.findAll(pageable).map(OrderResponse::from)));
}
限制页面大小。 直接使用 Pageable 会接受任何客户端传入的 ?size=100000——一次请求就能把整张表拖入内存。Spring 的默认上限是 2000,对大多数 API 来说仍然太高:
spring:
data:
web:
pageable:
default-page-size: 20
max-page-size: 100 # requests above this are silently clamped
已编译的 PageResponse 固定了 JSON 分页契约。仅将实体映射为 DTO 并不能稳定 Spring Data PageImpl 的序列化结果。如果其结构适合 API,Spring Data 的 org.springframework.data.web.PagedModel 也是一种选择。请通过允许列表验证排序字段,并为非唯一排序添加 ID 作为次级排序条件。
旧版信封异常处理器
// Extends ResponseEntityExceptionHandler: without it the Exception.class catch-all turns
// malformed JSON, 405, bad path variables and unknown URLs into 500. Full version, verified
// against Boot 3 and 4: templates/GlobalExceptionHandler.java
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNotFound(EntityNotFoundException ex) {
return ResponseEntity.status(404).body(ApiResponse.error("NOT_FOUND", ex.getMessage()));
}
@ExceptionHandler(AccessDeniedException.class) // @PreAuthorize denials surface here, not in the filter
public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException ex) {
return ResponseEntity.status(403).body(ApiResponse.error("FORBIDDEN", "Access denied"));
}
@Override // an @ExceptionHandler for this type would clash with the superclass at startup
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
List<String> details = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage()).toList();
return ResponseEntity.status(400).body(ApiResponse.validationError(details));
}
@Override // keep the framework's status + headers, return the envelope
protected ResponseEntity<Object> handleExceptionInternal(
Exception ex, Object body, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
return ResponseEntity.status(status).headers(headers)
.body(ApiResponse.error("HTTP_" + status.value(), "Request could not be processed"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGeneric(Exception ex) {
return ResponseEntity.status(500).body(ApiResponse.error("INTERNAL_ERROR", "An unexpected error occurred"));
}
}
易错点
- Agent 在未继承
ResponseEntityExceptionHandler的 advice 中编写了一个捕获所有异常的Exception.class处理器——格式错误的 JSON、405、类型不匹配的路径变量和未知 URL 都会变成 500 - Agent 强加了新的信封格式——保留现有的成功契约。
- Agent 擅自引入了相互竞争的错误处理器——使用项目既定的错误契约。
- Agent 将异常处理器放在控制器中——始终使用
@RestControllerAdvice - Agent 添加端点时更改了 ID 类型——保留现有 ID 方案。
- Agent 接受了无上限的
Pageable——设置spring.data.web.pageable.max-page-size,否则一次请求就能拉取整张表 - Agent 直接序列化了 PageImpl——先将实体映射为 DTO,再包装在 PageResponse 中。
评论
支持 Markdown