Chapter 04

请求与响应

深入 Vapor 的 Content 协议,掌握 JSON 解码、文件上传、自定义状态码与统一错误响应的最佳实践。

HTTP 请求的结构

每一个到达 Vapor 服务器的 HTTP 请求都被封装成 Request 对象。理解 Request 的内部结构,是编写正确处理逻辑的前提。

HTTP 请求的组成部分: POST /api/v1/users HTTP/1.1 Host: api.example.com Content-Type: application/json Authorization: Bearer eyJhbG... X-Request-ID: abc-123 { "name": "Alice", "email": "alice@example.com", "password": "secret123" } 在 Vapor Request 对象中的对应关系: ┌─────────────────────────────────────────┐ │ req.method → .POST │ │ req.url.path → /api/v1/users │ │ req.headers["Content-Type"] │ │ → application/json │ │ req.headers.bearerAuthorization?.token │ │ → eyJhbG... │ │ req.content.decode(UserDTO.self) │ │ → UserDTO 对象 │ │ req.parameters.get("id") │ │ → 路径参数 │ │ req.query[Int.self, at: "page"] │ │ → 查询参数 │ └─────────────────────────────────────────┘

核心概念词典

Content 协议
Vapor 的内容序列化/反序列化协议,任何实现了 Content(同时继承自 Codable)的类型都可以直接通过 req.content.decode() 从请求体解码,或作为路由处理函数的返回值自动编码为响应体。
Decodable / Encodable
Swift 标准库协议,Codable = Decodable & Encodable。只要结构体/类实现这两个协议,Swift 编译器会自动合成 JSON 序列化代码,无需手写。Vapor 的 Content 协议基于此扩展,支持 JSON、URL-encoded form、multipart 多种内容类型。
Abort / AbortError
Abort 是 Vapor 的内置错误类型,构造时传入 HTTP 状态码和可选的错误原因。在路由处理函数中 throw Abort(.notFound, reason: "User not found") 会自动生成对应的 HTTP 错误响应(状态码 + JSON 错误体),是统一错误处理的核心工具。
HTTPStatus
HTTP 状态码的类型安全枚举,覆盖所有标准状态码:.ok(200)、.created(201)、.noContent(204)、.badRequest(400)、.unauthorized(401)、.notFound(404)、.internalServerError(500)等。
Response
HTTP 响应对象,包含状态码、响应头和响应体。路由处理函数可以直接返回 Response 对象来精确控制响应内容,也可以返回任何 AsyncResponseEncodable 类型由 Vapor 自动转换。

请求体解码:DTO 模式

DTO(Data Transfer Object,数据传输对象)是服务端开发的重要设计模式:用单独的结构体表示 API 的输入输出格式,与数据库 Model 分离。这样可以避免 API 直接暴露数据库字段(如 passwordHash),也方便对输入做验证和转换。

// 输入 DTO:客户端发来的数据
struct CreateUserDTO: Content {
    let name:     String
    let email:    String
    let password: String

    // 自定义验证
    func validate() throws {
        guard email.contains("@") else {
            throw Abort(.badRequest, reason: "Invalid email format")
        }
        guard password.count >= 8 else {
            throw Abort(.badRequest, reason: "Password must be at least 8 characters")
        }
    }
}

// 输出 DTO:返回给客户端的数据(不含 passwordHash!)
struct UserResponse: Content {
    let id:        UUID
    let name:      String
    let email:     String
    let createdAt: Date?

    init(from user: User) {
        self.id        = user.id!
        self.name      = user.name
        self.email     = user.email
        self.createdAt = user.createdAt
    }
}

// 路由处理函数
app.post("users") { req async throws -> Response in
    // 1. 解码请求体(失败自动返回 400)
    let dto = try req.content.decode(CreateUserDTO.self)

    // 2. 业务验证
    try dto.validate()

    // 3. 检查邮箱是否已存在
    if try await User.query(on: req.db).filter(\.$email == dto.email).first() != nil {
        throw Abort(.conflict, reason: "Email already in use")
    }

    // 4. 创建 Model(密码哈希处理)
    let hash = try Bcrypt.hash(dto.password)
    let user = User(name: dto.name, email: dto.email, passwordHash: hash)
    try await user.save(on: req.db)

    // 5. 返回 201 Created + 资源 URL
    let response = UserResponse(from: user)
    return try req.response(.created, content: response)
}
DTO 与 Model 分离是好习惯 永远不要直接将数据库 Model 作为 API 的输入输出。输入 DTO 做验证和清洗,输出 DTO 控制暴露字段,这是安全和可维护性的基础。当数据库结构变更时,只需调整 DTO 的转换逻辑,不影响 API 契约。

统一错误响应

Vapor 的 ErrorMiddleware(默认已注册)会捕获所有从路由函数中抛出的错误,将其转换为统一的 JSON 错误响应。你也可以实现 AbortError 协议来定制错误格式:

// 自定义错误类型
enum AppError: AbortError {
    case userNotFound(UUID)
    case emailAlreadyExists(String)
    case insufficientPermissions

    var status: HTTPResponseStatus {
        switch self {
        case .userNotFound:          return .notFound
        case .emailAlreadyExists:    return .conflict
        case .insufficientPermissions: return .forbidden
        }
    }

    var reason: String {
        switch self {
        case .userNotFound(let id): return "User \(id) not found"
        case .emailAlreadyExists(let email): return "Email '\(email)' is already registered"
        case .insufficientPermissions: return "You don't have permission to perform this action"
        }
    }
}

// 使用自定义错误
app.get("users", ":id") { req async throws -> UserResponse in
    let id = try req.parameters.require("id", as: UUID.self)
    guard let user = try await User.find(id, on: req.db) else {
        throw AppError.userNotFound(id)
    }
    return UserResponse(from: user)
}

// 客户端收到的响应:
// HTTP/1.1 404 Not Found
// {"error": true, "reason": "User abc-123 not found"}

文件上传处理

文件上传使用 multipart/form-data 内容类型。Vapor 内置了 multipart 解析支持,通过 File 类型接收上传的文件数据:

struct UploadDTO: Content {
    var file:        File       // 文件字段
    var description: String?   // 额外文本字段
}

app.post("upload") { req async throws -> Response in
    // 限制上传大小(默认 16KB body size,需要调大)
    app.routes.defaultMaxBodySize = "10mb"

    let upload = try req.content.decode(UploadDTO.self)
    let file   = upload.file

    // 验证文件类型
    guard let ext = file.extension, ["jpg", "png", "gif"].contains(ext) else {
        throw Abort(.badRequest, reason: "Only image files are allowed")
    }

    // 保存文件到磁盘
    let filename   = "\(UUID()).\(ext)"
    let uploadPath = req.application.directory.publicDirectory + "uploads/" + filename
    try await req.fileio.writeFile(ByteBuffer(buffer: file.data), at: uploadPath)

    return Response(status: .created, headers: ["Location": "/uploads/\(filename)"])
}
文件上传:默认请求体大小限制只有 16KB Vapor 默认限制请求体为 16KB,上传文件时必须显式调大:app.routes.defaultMaxBodySize = "10mb" 或在具体路由上用 .body(.collect(maxSize: "50mb"))。超过限制的请求会返回 413 Payload Too Large。注意:app.routes.defaultMaxBodySize 的赋值应在 configure.swift 中完成,而不是在路由处理函数内部(函数内部每次请求都会重新赋值,虽然有效但不优雅)。

Vapor Validation:内置验证框架

除了手动在 DTO 里写 validate() 方法,Vapor 提供了内置的 Validatable 协议,通过声明式方式定义验证规则,错误信息自动聚合:

import Vapor

struct CreateUserDTO: Content, Validatable {
    let name:     String
    let email:    String
    let password: String
    let age:      Int

    // 声明验证规则(Validatable 协议要求实现此静态方法)
    static func validations(_ validations: inout Validations) {
        validations.add("name",     as: String.self, is: .count(2...50))
        validations.add("email",    as: String.self, is: .email)
        validations.add("password", as: String.self, is: .count(8...) & .ascii)
        validations.add("age",      as: Int.self,    is: .range(18...))
    }
}

// 路由中调用 validate() — 失败自动抛出包含所有错误的 400 响应
app.post("users") { req async throws -> UserResponse in
    try CreateUserDTO.validate(content: req)  // 一行验证全部规则
    let dto = try req.content.decode(CreateUserDTO.self)
    // 验证通过后才会执行到这里
    ...
}

// 客户端收到的验证失败响应示例:
// HTTP/1.1 400 Bad Request
// {
//   "error": true,
//   "reason": "email is not a valid email address, password is less than minimum of 8 character(s)"
// }
内置验证器(Validator)
Vapor 提供了丰富的内置验证器:.email(邮箱格式)、.count(n...m)(字符数/元素数范围)、.range(n...)(数值范围)、.ascii(ASCII 字符)、.alphanumeric(字母数字)、.url(URL 格式)、.nil(允许为空)。多个验证器可以用 &(且)和 ||(或)组合。
自定义验证器
实现 ValidatorResultsValidator 协议可以创建自定义验证逻辑,例如验证中国手机号格式、验证业务规则(如用户名不允许包含特定词汇)。自定义验证器可以与内置验证器通过 & 组合使用。
Content 协议与 Validatable
Content 协议(继承自 Codable)负责序列化;Validatable 协议负责业务验证。两者通常配合使用——先用 Content 解码,再用 Validatable 验证。如果解码失败(字段缺失、类型错误),Vapor 自动返回 400,不会进入验证阶段。

分页响应模式

列表 API 通常需要分页。Vapor 内置了 Page 类型支持分页查询,与 Fluent 的 .paginate() 配合使用非常方便:

// GET /users?page=1&per=20
app.get("users") { req async throws -> Page<UserResponse> in
    // Fluent 自动从查询参数读取 page 和 per,生成 LIMIT/OFFSET SQL
    let page = try await User.query(on: req.db)
        .sort(\.$createdAt, .descending)
        .paginate(for: req)
    // 将 Page<User> 映射为 Page<UserResponse>
    return page.map { UserResponse(from: $0) }
}

// 客户端收到的响应结构:
// {
//   "items": [...],
//   "metadata": {
//     "page": 1,
//     "per": 20,
//     "total": 143
//   }
// }
永远不要在生产环境暴露 errorIdentifier 和内部错误详情 Vapor 的默认 ErrorMiddleware 在 development 环境下会在错误响应中包含详细的错误描述(堆栈信息等),但在 production 环境自动隐藏。确保部署时设置正确的环境变量:APP_ENV=production 或启动参数 --env production。内部错误信息泄露(如数据库查询失败的 SQL)是严重的安全风险。此外,密码相关字段(password、passwordHash、token)永远不应出现在任何 DTO 的输出结构(UserResponse)中。
本章小结 Content 协议统一了 JSON、form 数据和 multipart 的编解码;DTO 模式将 API 层与数据库层解耦,防止敏感字段泄露;Abort 和 AbortError 提供了优雅的错误传播机制;Vapor 内置的 Validatable 框架通过声明式规则(.email.count().range())简化验证逻辑,失败时自动聚合所有错误信息返回 400;.paginate(for: req) 与 Fluent 无缝集成,自动处理 LIMIT/OFFSET 分页。这些工具共同构成 Vapor API 层的完整工具链,下一章将在此基础上加入 JWT 认证。