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)"])
}
本章小结 Content 协议统一了 JSON、form 数据和 multipart 的编解码;DTO 模式将 API 层与数据库层解耦;Abort 和 AbortError 提供了优雅的错误传播机制。这三者共同构成了 Vapor API 层的完整工具链。下一章将在此基础上加入认证与 JWT,让 API 具备身份验证能力。