Swift on Server Tour 6 关联 User 和 Post
在上一章中我们创建了 User
的 Model
,并构建了 UserController
,但尚未构建起 User
和 Post
之间的关系。在这一章中,我们将在 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)
}
}
usernameKey
和 passwordHashKey
分别表明了 User Model 中的哪两个属性用于存储用户名和密码。当客户端提供用户名和密码时,Vapor 会自动将其与 User Model 中的 usernameKey
和 passwordHashKey
进行对应,从而完成用户验证。
接下来我们需要确保 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 中找到。