mcpField
R
rest-api-conventions

rest-api-conventions

github.com/rrezartprebreza开发328133 次安装6 天前更新
代码仓库

关于 rest-api-conventions

rest-api-conventions 是用于添加和审查 REST 端点的技能,强调保留项目现有 API 契约,并为编码 Agent 提供状态码、URL、分页、错误处理及常见实现陷阱方面的指导。

主要功能

  1. 01保留现有响应与版本契约
  2. 02将 HTTP 状态映射到 API 结果
  3. 03遵循 URL 与资源嵌套约定
  4. 04设计有上限的分页
  5. 05按既定策略处理错误
  6. 06避免常见 Spring API 陷阱

适用场景

  1. 01添加端点时不改动无关 API 契约
  2. 02设置状态码和资源 URL 模式
  3. 03配置分页限制与稳定的响应结构
  4. 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.yaml
examples/
bad-controller.java
good-controller.java
templates/
ApiResponse.java
GlobalExceptionHandler.java
PageResponse.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