在本篇的第一部分,我们将稍微研究一下表单组件。 我们将实现更多的事件处理方法,将学习到调用它们的最佳方式,以便构建正确的创建或更新的工作流。 本篇的后半部分是关于为抽象表单构建异步验证机制。 我们将构建几个表单字段验证器,然后使用这些验证器显示用户错误以改善整体体验。
表单事件处理器
在上一章中,我们创建了一个用户登录表单。 主要想法是,我们将为每个输入字段创建一个带有context和 view model 的模板,因此我们可以使用几行代码组成各种表单。
现在我们有了能够处理用户输入这方面的基础 blocks,但是我们还没有实现 FormFieldComponent
协议的一些其他方法。 让我们通过使用一个通用模式来完善它,我们将为几乎所有单个事件方法遵循该模式。
/// FILE: Sources/App/Framework/Form/AbstractFormField.swift
import Vapor
open class AbstractFormField<Input: Decodable, Output: TemplateRepresentable>: FormComponent {
public var key: String
public var input: Input
public var output: Output
public var error: String?
//MARK: - event blocks
public typealias FormFieldBlock = (Request, AbstractFormField<Input, Output>) async throws -> Void
private var readBlock: FormFieldBlock?
private var writeBlock: FormFieldBlock?
private var loadBlock: FormFieldBlock?
private var saveBlock: FormFieldBlock?
//MARK: - init & config
public init(key: String,
input: Input,
output: Output,
error: String? = nil) {
self.key = key
self.input = input
self.output = output
self.error = error
}
open func config(_ block: (AbstractFormField<Input, Output>) -> Void) -> Self {
block(self)
return self
}
// MARK: - Block setters
public func read(_ block: @escaping FormFieldBlock) -> Self {
readBlock = block
return self
}
public func write(_ block: @escaping FormFieldBlock) -> Self {
writeBlock = block
return self
}
public func load(_ block: @escaping FormFieldBlock) -> Self {
loadBlock = block
return self
}
public func save(_ block: @escaping FormFieldBlock) -> Self {
saveBlock = block
return self
}
// MARK: - FormComponent
public func process(req: Request) async throws {
if let value = try? req.content.get(Input.self, at: key) {
input = value
}
}
public func validate(req: Request) async throws -> Bool {
return true
}
public func read(req: Request) async throws {
try await readBlock?(req, self)
}
public func write(req: Request) async throws {
try await writeBlock?(req, self)
}
public func load(req: Request) async throws {
try await loadBlock?(req, self)
}
public func save(req: Request) async throws {
try await saveBlock?(req, self)
}
public func render(req: Request) -> TemplateRepresentable {
return output
}
}
我们创建了四个新的可选 FormFieldBlock 变量来处理各种事件。 这些变量是私有的,因此我们需要四个新的 setter
方法才能为它们赋予新值。 setter
方法将像构建器或修改器一样,在设置值之后,我们将返回当前实例。这种模式将允许我们设置表单字段并立即为它们定义事件处理程序,下面是一个例子:
InputField("name")
.load {
$1.output.context.value = "John Doe"
}
.save {
print("Hello, my name is \($1.input)!")
}
在这种情况下,我们可以使用 load 方法更新field的 output context value,并且在 save 方法中我们还可以执行操作来处理输入。 read / write 方法的用途也一样,不同之处在于执行顺序。
下面是 FormFieldComponent
事件的建议执行顺序:
显示表格时:
- load
- read
- render
处理提交事件时:
- load
- process
- validate
- render if invalid write
- write
- save
这将是我们在 admin controllers中的工作流,在我们实现 CMS 之前,我们仍然需要处理表单验证。
异步表单验证
验证传入的表单字段一个重要的事情。 Vapor 有一个内置的验证 API 来验证所有类型的输入数据,但是这个系统有一些问题:
- 你不能提供自定义错误消息
- 验证错误详细信息始终是一个串联的字符串(如果有多个错误)
- 无法从错误详细信息字符串中获取确定的错误标识
- 验证是同步的(不能基于数据库查询进行验证)
这是非常不幸的,因为 Vapor 有一些非常好的验证器方法,但他们更多地关注 API 验证而不是表单验证。
我们将构建一组统一的异步验证 helpers,可用于表单和 API 验证目的。 我们还将更多地讨论 API 验证,不过现在让我们只关注 HTML 表单和验证输入字段。
我们首先需要的是带有相关错误消息的对象。 在 Framework 目录下创建一个 Validation 文件夹,并创建一个的 ValidationErrorDetail 文件
/// FILE: Sources/App/Framework/Validation/ValidationErrorDetail.swift
import Vapor
public struct ValidationErrorDetail: Codable {
public var key: String
public var message: String
public init(key: String, message: String) {
self.key = key
self.message = message
}
}
extension ValidationErrorDetail: Content {}
我们将使用此对象根据key作为表单字段错误的唯一标识
现在我们需要一个协议,我们可以用它来以通用的方式验证表单字段。 我们将需要key和message以及一个异步验证函数,如果出现错误,该函数可以返回一个可选的 ValidationErrorDetail 对象
请注意,此函数可以抛出,但我们只会在发生系统错误时抛出错误,例如数据库故障或类似情况。
/// FILE: Sources/App/Framework/Validation/AsyncValidator.swift
import Vapor
public protocol AsyncValidator {
var key: String { get }
var message: String { get }
func validate(_ req: Request) async throws -> ValidationErrorDetail?
}
public extension AsyncValidator {
var error: ValidationErrorDetail {
.init(key: key, message: message)
}
}
我们要创建一个新的 ValidationAbort 结构体,因为默认的验证响应不会包含有关错误的必要信息,但我们的 ValidationErrorDetail 具有有关有问题的键的更多详细信息,并且还具有适当的错误消息。
/// FILE: Sources/App/Framework/Validation/ValidationAbort.swift
import Vapor
public struct ValidationAbort: AbortError {
public var abort: Abort
public var message: String?
public var details: [ValidationErrorDetail]
public var reason: String { abort.reason }
public var status: HTTPStatus { abort.status }
public init(abort: Abort, message: String? = nil, details: [ValidationErrorDetail]) {
self.abort = abort
self.message = message
self.details = details
}
}
ValidationAbort 类型将实现 Vapor 的 AbortError 协议,这是一个可以抛出的错误,并且系统可以在需要时将其转换为正确的 HTTP 响应。 我们添加了一个 abort 属性,这样我们就可以返回一个自定义状态代码和一个通用错误消息,就像我们为 AbstractForm 对象所做的那样。 我们还包括详细信息,该数组将包含我们在请求中遇到的所有问题
在 RequestValidator 中,我们将调用 AsyncValidator 协议对象数组上的 validate 方法。 我们可以通过检查结果数组中的keys来优化过程,因此如果与给定key关联的字段已经无效,我们不必运行剩余的验证器。 此外,如果请求验证器失败,这意味着结果数组中有错误,我们可以抛出 ValidationAbort
/// FILE: Sources/App/Framework/Validation/RequestValidator.swift
import Vapor
public struct RequestValidator {
public var validators: [AsyncValidator]
public init(_ validators: [AsyncValidator]) {
self.validators = validators
}
public func validate(_ req: Request, message: String? = nil) async throws {
var result: [ValidationErrorDetail] = []
for validator in validators {
if result.contains(where: { $0.key == validator.key }) {
continue
}
if let res = try await validator.validate(req) {
result.append(res)
}
}
if !result.isEmpty {
throw ValidationAbort(abort: Abort(.badRequest, reason: message), details: result)
}
}
public func isValid(_ req: Request) async -> Bool {
do {
try await validate(req, message: nil)
return true
}
catch {
return false
}
}
}
这次我们总是抛出一个错误,而不是返回 ValidationErrorDetail 对象数组,因为我们需要 JSON 相关 API 的中止错误。 我们仍然可以使用此方法并通过尝试 validate 方法来检查请求是否有效。 如果调用失败,我们可以返回 false 值,否则我们返回 true
现在我们有能力异步验证事物并且我们可以验证整个请求对象,是时候用一个验证器来检查输入值并将错误消息作为输出传递给给定的表单字段。 我们将其称为 FormFieldValidator 对象,它是一个通用结构,具有关联的 Decodable 输入和 TemplateRepresentable 输出(就像 AbstractFormField 一样)类型,当然它符合 AsyncValidator 协议
/// FILE: Sources/App/Validation/FormFieldValidator.swift
import Vapor
public struct FormFieldValidator<Input: Decodable, Output: TemplateRepresentable>: AsyncValidator {
public let field: AbstractFormField<Input, Output>
public let message: String
public let validation: ((Request, AbstractFormField<Input, Output>) async throws -> Bool)
public var key: String { field.key }
public init(_ field: AbstractFormField<Input, Output>,
_ message: String,
_ validation: @escaping ((Request, AbstractFormField<Input, Output>) async throws -> Bool)) {
self.field = field
self.message = message
self.validation = validation
}
public func validate(_ req: Request) async throws -> ValidationErrorDetail? {
let isValid = try await validation(req, field)
if isValid {
return nil
}
field.error = message
return error
}
}
init
方法将接受三个参数,第一个是指向 AbstractFormField 实例的指针,第二个是错误消息,第三个是我们将在必须验证输入时运行的验证闭包。 在 validate
方法中,我们简单地调用存储的验证块。 如果输入有效,我们将返回 nil 值,如果有错误,我们会使用引用在字段上设置错误消息,并返回错误详细信息作为结果
这种方法的好处是我们仍然可以使用内置的 Vapor 验证器方法并创建辅助方法来根据输入类型验证我们的表单字段。 例如,字符串验证是一个非常常见的case,因此定义扩展是有意义的
/// FILE: Sources/App/Validation/FormFieldValidator.swift
public extension FormFieldValidator where Input == String {
static func required(_ field: AbstractFormField<Input, Output>, _ message: String? = nil) -> FormFieldValidator<Input, Output> {
.init(field, message ?? "\(field.key.capitalized) is required") { _, field in !field.input.isEmpty }
}
static func min(_ field: AbstractFormField<Input, Output>, length: Int, message: String? = nil) -> FormFieldValidator<Input, Output> {
let msg = message ?? "\(field.key.capitalized) is too short (min: \(length) characters)"
return .init(field, msg) { _, field in field.input.count >= length }
}
static func max(_ field: AbstractFormField<Input, Output>, length: Int, message: String? = nil) -> FormFieldValidator<Input, Output> {
let msg = message ?? "\(field.key.capitalized) is too short (min: \(length) characters)"
return .init(field, msg) { _, field in field.input.count <= length }
}
static func alphanumeric(_ field: AbstractFormField<Input, Output>, message: String? = nil) -> FormFieldValidator<Input, Output> {
let msg = message ?? "\(field.key.capitalized) should be only alphanumeric characters"
return .init(field, msg) { _, field in Validator.characterSet(.alphanumerics).validate(field.input).isFailure }
}
static func email(_ field: AbstractFormField<Input, Output>, message: String? = nil) -> FormFieldValidator<Input, Output> {
let msg = message ?? "\(field.key.capitalized) should be a valid email address"
return .init(field, msg) { _, field in Validator.email.validate(field.input).isFailure }
}
}
在我们更改 AbstractFormField 组件之前,我们将添加一个更方便的枚举,我们可以使用它通过结果构建器返回一组 AsyncValidator 对象
/// FILE: Sources/App/Validation/AsyncValidatorBuilder.swift
@resultBuilder
public enum AsyncValidatorBuilder {
public static func buildBlock(_ components: AsyncValidator...) -> [AsyncValidator] {
components
}
}
现在我们就可以回去改造 AbstractFormField。
/// FILE: Sources/App/Framework/Form/AbstractFormField.swift
import Vapor
open class AbstractFormField<Input: Decodable, Output: TemplateRepresentable>: FormComponent {
public var key: String
public var input: Input
public var output: Output
public var error: String?
//MARK: - event blocks
public typealias FormFieldBlock = (Request, AbstractFormField<Input, Output>) async throws -> Void
public typealias FormFieldValidatorsBlock = ((Request, AbstractFormField<Input, Output>) -> [AsyncValidator])
private var readBlock: FormFieldBlock?
private var writeBlock: FormFieldBlock?
private var loadBlock: FormFieldBlock?
private var saveBlock: FormFieldBlock?
private var validatorsBlock: FormFieldValidatorsBlock?
//MARK: - init & config
public init(key: String,
input: Input,
output: Output,
error: String? = nil) {
self.key = key
self.input = input
self.output = output
self.error = error
}
open func config(_ block: (AbstractFormField<Input, Output>) -> Void) -> Self {
block(self)
return self
}
// MARK: - Block setters
public func read(_ block: @escaping FormFieldBlock) -> Self {
readBlock = block
return self
}
public func write(_ block: @escaping FormFieldBlock) -> Self {
writeBlock = block
return self
}
public func load(_ block: @escaping FormFieldBlock) -> Self {
loadBlock = block
return self
}
public func save(_ block: @escaping FormFieldBlock) -> Self {
saveBlock = block
return self
}
open func validators(@AsyncValidatorBuilder _ block: @escaping FormFieldValidatorsBlock) -> Self {
validatorsBlock = block
return self
}
// MARK: - FormComponent
public func process(req: Request) async throws {
if let value = try? req.content.get(Input.self, at: key) {
input = value
}
}
public func validate(req: Request) async throws -> Bool {
guard let validators = validatorsBlock else {
return true
}
return await RequestValidator(validators(req, self)).isValid(req)
}
public func read(req: Request) async throws {
try await readBlock?(req, self)
}
public func write(req: Request) async throws {
try await writeBlock?(req, self)
}
public func load(req: Request) async throws {
try await loadBlock?(req, self)
}
public func save(req: Request) async throws {
try await saveBlock?(req, self)
}
public func render(req: Request) -> TemplateRepresentable {
return output
}
}
现在让我们更新我们的 UserLoginForm 并验证电子邮件地址和密码字段是否正确
/// FILE: Sources/App/Modules/User/Forms/UserLoginForm.swift
import Vapor
final class UserLoginForm: AbstractForm {
public convenience init() {
self.init(action: .init(method: .post, url: "/sign-in/"),
submit: "Sign in")
self.fields = createFields()
}
@FormComponentBuilder
func createFields() -> [FormComponent] {
InputField("email")
.config {
$0.output.context.label.required = true
$0.output.context.type = .email
}
.validators {
FormFieldValidator.required($1)
FormFieldValidator.email($1)
}
InputField("password")
.config {
$0.output.context.label.required = true
$0.output.context.type = .password
}
.validators {
FormFieldValidator.required($1)
}
}
}
回到 UserFrontendController ,我们仍然需要调用表单上的 validate
方法。
/// FILE: Sources/App/Modules/User/Controllers/UserFrontendController.swift
import Vapor
struct UserFrontendController {
func renderSignInView(_ req: Request, _ form: UserLoginForm) -> Response {
let template = UserLoginTemplate(context: .init(icon: "⬇️",
title: "Sign in",
message: "Please log in with your existing account",
form: form.render(req: req)))
return req.templates.renderHtml(template)
}
func signInView(_ req: Request) async throws -> Response {
return renderSignInView(req, .init())
}
func signInAction(_ req: Request) async throws -> Response {
/// the user is authenticated, we can store the user data inside the session too
if let user = req.auth.get(AuthenticatedUser.self) {
req.session.authenticate(user)
return req.redirect(to: "/")
}
let form = UserLoginForm()
try await form.process(req: req)
if try await form.validate(req: req) {
form.error = "Invalid email or password."
}
return renderSignInView(req, form)
}
func signOut(req: Request) throws -> Response {
req.auth.logout(AuthenticatedUser.self)
req.session.unauthenticate(AuthenticatedUser.self)
return req.redirect(to: "/")
}
}
现在,如果你运行该项目,你应该会看到我们有更好的用户体验,如果登录表单缺少输入值,用户将知道它。 如果两个字段均已填写,但凭据不正确,则我们将仅显示一条错误消息
使用这种方法设置验证器非常简单,你可以在 AbstractFormField 类上添加更多验证器函数作为扩展,也可以使用自定义验证器
总结
本篇继续搭建了我们表单框架的基础,现在有了更多的事件处理程序和表单验证的设计了。可以支撑我们接入更多的表单字段了。