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 中找到。