Swift on Server Tour 6 关联 User 和 Post

在上一章中我们创建了 UserModel,并构建了 UserController,但尚未构建起 UserPost 之间的关系。在这一章中,我们将在 Vapor 中完成一对多的关系构建。

本章代码可以在 Github 中找到。

修改 Post 的数据结构

要修 Post 表结构,我们首先需要创建一个新的 Migration 文件。

创建 Sources/App/Migrations/3_AddUserIDToPost.swift 文件,添加如下代码:

import Fluent 

struct AddUserIDToPost: AsyncMigration { 
    func prepare(on database: Database) async throws { 
        try await database.schema(Post.schema)  
            .field("user_id", .uuid, .references(User.schema, "id")) 
            .update()
    }

    func revert(on database: Database) async throws { 
       try await database.schema(Post.schema)
          .deleteField("user_id")
          .update()
    }
}

这种方式将会给 Post 表添加一个可为空的 user_id 字段,虽然这种方式简单直接,但同时也意味着,我们容忍了 Post 可能不属于任何用户的情况。

prepare 方法中,我们使用 references 方法来创建了一个外键约束,这个外键将会引用 User 表中的 id 字段,确保了 user_id 在数据库层面的正确性,避免我们意外插入一个不存在的 user_id

revert 方法中,我们使用 deleteField 方法来删除 user_id 字段,这个方法会同时删除它的外键约束。

随后,我们应当修改 configure.swift 将这条 Migration 添加到其中

app.migrations.add([CreatePost(), CreateUser(), AddUserIDToPost()])

现在,运行 vapor run migrate 即可进行数据库的迁移。

修改 Post Model

完成了数据库 Schema 的修改后,我们需要进一步修改 Post Model

编辑 Sources/App/Models/Post.swift 文件如下:

final class Post: Model, Content {
    static let schema = "posts"

    @ID(key: .id)
    var id: UUID?

    @OptionalParent(key: "user_id")
    var user: User?

    @Field(key: "content")
    var content: String
    
    @Timestamp(key: "created_at", on: .create)
    var createdAt: Date?

    init() { }

    init(id: UUID? = nil, content: String) {
        self.id = id
        self.content = content
    }
}

因为我们在给 Post 的 Schema 添加 user_id 字段时允许其为空,因此在 Post Model 中,我们使用 @OptionalParent 来表明 user 是不一定存在的。

与此同时,@OptionalParent(key: "user_id") 表明了 user_id 和 User Model @ID 之间的对应关系,这与 Schema 中的 .field("user_id", .uuid, .references(User.schema, "id")) 形成对应关系。

修改 User Model

import Vapor
import Fluent

final class User: Model, Content {
    static let schema = "users"
    
    @ID(key: .id)
    var id: UUID?
    
    @Field(key: "username")
    var username: String
    
    @Field(key: "password_hash")
    var passwordHash: String

    @Children(for: \.$user)
    var posts: [Post]
    
    @Timestamp(key: "created_at", on: .create)
    var createdAt: Date?
    
    init() {}
    
    init(id: UUID? = nil, username: String, passwordHash: String) {
        self.id = id
        self.username = username
        self.passwordHash = passwordHash
    }
}

在 User Model 中,@Children(for: \.$user) 表明了 User 和 Post 之间的一对多关系,同时也说明了,这种关系是通过 Post Model 中的 user 属性来进行关联的。

现在,我们有两种方式可以创建 Post 了,一种是通过 Post Model 的 user 属性:

let user = User(username: "hellouser", passwordHash: try await app.password.async.hash("123456"))
try await user.save(on: app.db)
let post = Post(content: "Hello, World!")
post.$user.id = try user.requiredID()

另一种是通过 User Model 的 posts 属性

let user = User(username: "hellouser", passwordHash: try await app.password.async.hash("123456"))
try await user.save(on: app.db)
let post = Post(content: "Hello, World!")
user.$posts.create(post, on: app.db)

不管采用哪一种,我们都需要在保存 Post 前知道 user_id

使用 BasicAuthorization 进行用户验证

在 PostController 中,我们需要修改 create 方法,使其能够接收 user_id 参数,并将其与 Post 关联起来。

那么如何将 user_id 传入到 create 方法中呢?

直接让客户端提供 user_id 参数肯定是不可以的,因为这样的话,客户端只需要伪造 user_id 就可以假装成别的用户发布,这显然是不安全的。

我们暂且通过最简单的 BasicAuthorization 来解决这个问题,BasicAuthorization 需要客户端提供 username 和 password 两个字段,并将其进行 Base64 编码后放在 HTTP Header 中:

计算格式如下

base64(username:password)

以上面的用户信息 happyuser:123456 为例,计算后的 Header 结果如下:

Authorization: Basic aGFwcHl1c2VyOjEyMzQ1Ng==

服务器端验证用户名和密码后,才会允许客户端创建对应用户的 Post。

BasicAuthorization 作为一种业界通用实践,Vapor 已经提供了 Model Authenticatable 来帮助我们完成这个功能

修改 Sources/App/Models/User.swift 在底部增加如下代码:

extension User: ModelAuthenticatable {
    static let usernameKey = \User.$username
    static let passwordHashKey = \User.$passwordHash

    func verify(password: String) throws -> Bool {
        try Bcrypt.verify(password, created: self.passwordHash)
    }
}

usernameKeypasswordHashKey 分别表明了 User Model 中的哪两个属性用于存储用户名和密码。当客户端提供用户名和密码时,Vapor 会自动将其与 User Model 中的 usernameKeypasswordHashKey 进行对应,从而完成用户验证。

接下来我们需要确保 Post 在创建前,用户已经通过了验证,这可以通过在 PostController 的路由中,加入权限验证相关的 Middleware 来完成,Vapor 已经提供了 User.authenticator() 来帮助我们完成 BasicAuthorization 验证的功能。

修改 Sources/App/Controllers/PostController.swift 中 boot 代码如下

func boot(routes: RoutesBuilder) throws {
    routes.group("posts") { posts in
        posts.get(use: index)
        posts.grouped(User.authenticator()).post(use: create)
    }
}

posts 路由组中,当客户端请求 posts 路由组中的 post 方法前,User.authenticator() 会进行 BasicAuthorization 验证,如果没有通过验证,Vapor 会返回 401 错误。

修改 PostController

修改 Sources/App/Controllers/PostController.swift 中的 create 方法如下:

func create(req: Request) async throws -> Post {
    let user = try req.auth.require(User.self)

    let postData = try req.content.decode(Post.CreateDTO.self)
        
    let post = Post(content: postData.content)

    post.$user.id = try user.requireID()
    
    try await post.create(on: req.db)
    
    return post
}

create 方法中,我们首先通过 req.auth.require(User.self) 获取到了已经通过验证的用户,然后通过 req.content.decode(Post.CreateDTO.self) 获取到了客户端传入的 Post 数据,最后通过 post.$user.id = try user.requireID() 将 Post 和 User 关联起来。

完善 Post 的单元测试

修改 Tests/AppTests/PostTests.swift 如下:

@testable import App
import XCTVapor

final class PostTests: XCTestCase {
    var app: Application!

    override func setUp() async throws {
        app = Application(.testing)
        try configure(app)

        try await app.autoRevert()
        try await app.autoMigrate()
    }

    
    override func tearDown() async throws {
        app.shutdown()
    }
    

    func testCreatePost() async throws {
        let user = User(username: "hellouser", passwordHash: try await app.password.async.hash("123456"))
        try await user.save(on: app.db)
        let postDTO = Post.CreateDTO(content: "Post created from test")

        try app.test(.POST, "posts", beforeRequest: { req in
            try req.content.encode(postDTO)
            req.headers.basicAuthorization = BasicAuthorization(username: user.username, password: "123456")
        }, afterResponse: { res in
            XCTAssertEqual(res.status, .ok)
            
            let post = try res.content.decode(Post.self)
            
            XCTAssertEqual(postDTO.content, post.content)
            XCTAssertEqual(try user.requireID(), post.$user.id)
        })
    }
}

testCreatePost 中,我们首先创建了一个用户,然后创建了一个 Post DTO,接着通过 app.test 方法,模拟了客户端的请求,最后验证了 Post 的 User 来判断是否创建成功。

总结

在这一章中,我们学习了如何在 Vapor 中构建一对多的关系,同时也学习了如何使用 BasicAuthorization 来进行用户验证。但这种方式虽然简单,但也有很多缺点,在下一章中,我们会学习使用 BearerAuthentication 来对用户进行验证,并进一步修复 User Model 的安全隐患。

本章代码可以在 Github 中找到。