Vapor 框架学习记录(6)表单组件扩展与验证器

在本篇的第一部分,我们将稍微研究一下表单组件。 我们将实现更多的事件处理方法,将学习到调用它们的最佳方式,以便构建正确的创建或更新的工作流。 本篇的后半部分是关于为抽象表单构建异步验证机制。 我们将构建几个表单字段验证器,然后使用这些验证器显示用户错误以改善整体体验。

表单事件处理器

在上一章中,我们创建了一个用户登录表单。 主要想法是,我们将为每个输入字段创建一个带有contextview 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 方法更新fieldoutput 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作为表单字段错误的唯一标识

现在我们需要一个协议,我们可以用它来以通用的方式验证表单字段。 我们将需要keymessage以及一个异步验证函数,如果出现错误,该函数可以返回一个可选的 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 类型将实现 VaporAbortError 协议,这是一个可以抛出的错误,并且系统可以在需要时将其转换为正确的 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 类上添加更多验证器函数作为扩展,也可以使用自定义验证器

总结

本篇继续搭建了我们表单框架的基础,现在有了更多的事件处理程序和表单验证的设计了。可以支撑我们接入更多的表单字段了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容