在JavaScript堆栈中简化表单验证
#javascript #网络开发人员 #教程 #fullstack

现代Web应用程序通常会严格处理接受和存储用户生成的数据。交互可以通过提交表单,上传文件或任何其他形式的交互性来实现。数据验证对于确保应用程序正确,安全起作用至关重要。但是,在应用程序堆栈中的不同位置具有单独的验证逻辑使应用程序更加昂贵,更难维护。

理想的解决方案是在中心位置(靠近数据定义的位置)表达验证规则,并在每个应用程序层中生效。在这篇文章中,我将演示如何使用ZenStack工具包实现此目标。

场景

让我们以一个简单的注册页面为例。该页面是为了提交个人信息以注册即将举行的一方:

Signup form

这是业务规则:

  • 所有字段都是必需的。
  • “电子邮件”字段必须是以“@zenstack.dev”结尾的有效电子邮件。
  • 饮料选择是苏打水,咖啡,熊和鸡尾酒。只有成年提交者才能选择酒精饮料。

我们将使用Next.js构建应用程序(具有新的"app router"目录结构)。

运行以下命令来创建一个项目,并在提示时选择使用Typescript,parwindcss和App Router:

npx create-next-app@latest

我们将使用Zenstack与数据验证与数据库进行交谈。 Zenstack建于Awesome Prisma Orm上方,并通过强大的访问控制和数据验证层扩展它。在新创建的项目目录中运行以下命令,以初始化您的项目以使用Zenstack:

npx zenstack@latest init

它应该安装一些依赖项,并在项目的根部生成“ schema.zmodel”文件。现在,我们已经完成了脚手架的项目并准备添加功能。

建模数据

让我们以自下而上的方法从数据库层开始,然后向上移动到UI。 Zenstack提供了一种称为Zmodel的建模语言,以定义数据架构和验证规则。 Zmodel语言基于Prisma的模式语言。如果您熟悉Prisma,您会发现自己在家,但不必担心,如果语法简单而直观。

将“/schema.zmodel”的内容更改为以下:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = 'sqlite'
  url      = 'file:./dev.db'
}

model Signup {
  id Int @id @default(autoincrement())
  name String
  email String @email @endsWith("@zenstack.dev", 'Must be a @zenstack.dev email')
  adult Boolean
  beverage String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@allow('create,read', true)

  // sqlite doesn't support enum, you should use enum in a real application
  @@validate(beverage in ['SODA', 'COFFEE', 'BEER', 'COCKTAIL'], 'Please choose a valid beverage')

  @@validate(adult || beverage in ['SODA', 'COFFEE'], 'You must be an adult to drink alcohol')
}

一些快速注释:

  • 我们使用@email@endsWith之类的属性将验证规则附加到字段。
  • 我们还使用模型级属性@@validate来表示涉及多个字段和条件的验证规则。
  • @@allow属性赠款“创建”和“读取”访问表。默认情况下,所有操作都被拒绝。访问控制不是这篇文章的重点,但是如果有兴趣,您可以学习更多here

运行以下命令以生成Prisma架构并将其同步到数据库:

npx zenstack generate
npx prisma db push

实施后端

在构建UI之前,让我们实现接受注册提交的API。使用以下内容创建文件“/src/app/api/signup/route.ts”:

import { PrismaClient } from '@prisma/client';
import { withPresets } from '@zenstackhq/runtime';
import { NextResponse } from 'next/server';

// create a database client enhanced by ZenStack that enforces data validation
const db = withPresets(new PrismaClient());

export async function POST(request: Request) {
    const data = await request.json();
    try {
        const result = await db.signup.create({ data });
        return NextResponse.json(result, { status: 201 });
    } catch (err: any) {
        return NextResponse.json({ error: err.message }, { status: 400 });
    }
}

实现相当简单,以下行是关键:

const db = withPresets(new PrismaClient());

我们使用Zenstack包装器创建了一个Prismaclient并“增强”了它,该包装器在运行时注入数据行为逻辑。

构建表格

用内容here替换文件“/src/app/page.tsx”。下面的代码是if的缩写副本。您可以检查一下是否只是阅读而不是遵循建筑过程:

'use client';

import { ReactNode, useState } from 'react';
import { FieldError, useForm } from 'react-hook-form';

function FormRow({
    label,
    children,
    error,
}: {
    name: string;
    label: string;
    error?: FieldError;
    children: ReactNode;
}) {
    return (
        <div className="w-full">
            <div className="flex gap-4 items-center">
                <label className="w-32">{label}</label>
                <div className="flex-grow">{children}</div>
            </div>
            {error && (
                <p className="text-red-600 text-sm text-right pt-1">
                    {error.message}
                </p>
            )}
        </div>
    );
}

interface Input {
    name: string;
    email: string;
    adult: boolean;
    beverage: string;
}

export default function Signup() {
    const {
        register,
        handleSubmit,
        formState: { errors },
    } = useForm<Input>({
        defaultValues: { beverage: '' },
    });

    const [serverError, setServerError] = useState('');

    async function onSubmit(data: Input) {
        const resp = await fetch('/api/signup', {
            method: 'POST',
            body: JSON.stringify(data),
            headers: { 'content-type': 'application/json' },
        });

        if (resp.status !== 201) {
            setServerError(await resp.text());
        } else {
            alert('Thank you for signing up!');
        }
    }

    return (
        <main className="flex min-h-screen flex-col items-center justify-center p-24">
            <div className="container max-w-lg items-center justify-center font-mono flex flex-col">
                <h1 className="text-3xl font-semibold mb-16">
                    🎉 Join the Party 🥳
                </h1>
                <form
                    className="flex flex-col w-full text-lg gap-6"
                    onSubmit={handleSubmit(onSubmit)}
                >
                    <FormRow name="name" label="Name" error={errors.name}>
                        <input
                            type="text"
                            placeholder="You name"
                            className={`input input-bordered w-full ${
                                errors.name ? 'input-error' : ''
                            }`}
                            {...register('name', {
                                required: true,
                            })}
                        />
                    </FormRow>

                    {/* 
                        code of other rows abbreviated, find full content at: 
                        https://github.com/ymc9/zenstack-form-validation/blob/backend-only-validation/src/app/page.tsx 
                    */}

                    <input className="btn btn-outline" type="submit" />

                    {serverError && (
                        <p className="text-red-600 text-sm">{serverError}</p>
                    )}
                </form>
            </div>
        </main>
    );
}

一些注释:

  • 我们正在使用Daisyui进行简单的样式。您可以找到安装指南here
  • 我们正在使用React-Hooks-form来构建具有易于状态绑定的形式。使用npm install react-hook-form安装。您可以在其website上找到更多信息。

让我们运行该应用程序,看看如果我们提交违反规则的内容会发生什么。

npm run dev

这就是发生的事情:

Signup form with server errors

由于我们没有实施任何客户端检查,因此数据直接发布到服务器API。幸运的是,我们的API设法拒绝了该请求,因为它使用了启用数据验证的PRISMACLIENT。但是,这种经验并不理想,原因有两个:

  1. 需要网络往返来获得错误,这可能很慢。
  2. 可能需要在可能的情况下解码并将其附加到场上。

客户端验证

客户端验证在将其发布到服务器之前先检查表格。使用React-Hooks形式的一个好处是,它允许您在字段中添加验证规则。但是,我们真的不需要这样做,因为我们已经在zmodel中表达了所有规则,我们想重新使用它!

还记得我们运行了zenstack generate命令将zmodel编译到“ schema.prisma”中吗? zenstack CLI不仅仅是Prisma模式编译器。它具有可扩展的体系结构,并允许通过插件生成不同种类的工件。当它在ZMODEL中检测数据验证规则时,内置的“ ZOD”插件将自动启用。为每个模型生成Zod schemas,可以从@zenstackhq/runtime/zod导入。

幸运的是,React-Hooks-Form具有一个附加软件包,该软件包允许您配置ZOD架构以进行表单验证。让我们看看如何将它们连接起来:

  1. 安装附加软件包

    npm i @hookform/resolvers
    
  2. 将导入添加到â/src/app/page.tsxâ

    import { SignupCreateSchema } from '@zenstackhq/runtime/zod/models';
    import { zodResolver } from '@hookform/resolvers/zod';
    
  3. 初始化反应钩形式时设置ZOD解析器

    const {
        register,
        handleSubmit,
        formState: { errors },
    } = useForm<Input>({
        defaultValues: { beverage: '' },
        resolver: zodResolver(SignupCreateSchema), // <-- add this
    });
    

就是这样!简单,对吗?

现在,如果您再次使用相同的数据提交,您将收到电子邮件字段上的一个不错的错误消息:

Signup form with one client error

但是,等等,我们的“不饮酒,除非您是成人”的错误?

由于该规则是在模型级别(而不是字段级别)表示的,因此,当该规则违反时,错误不会附加到任何字段,而是在空键下停留在“ root”。

model Signup {
  ...
  @@validate(adult || beverage in [SODA, COFFEE], 'Must be an adult to drink alcohol')
}

但是,修复很容易;只需提取并单独显示:

const toplevelError = (errors as FieldErrors)['']?.message?.toString();

<form>
  ...
  {toplevelError && (
    <p className="text-red-600 text-sm">{toplevelError}</p>
  )}
</form>

Signup form with both client errors

现在完美!

结论

我希望您喜欢阅读,并发现这种方法很有趣。您可以在https://github.com/ymc9/zenstack-form-validation上找到完整的项目代码。

我们构建了Zenstack工具包,认为强大的模式可以带来许多好处,以简化堆栈应用程序的构建。同构数据验证是一个很好的例子,但是还有很多。查看我们的GitHub page以获取更多详细信息!


人们说,如果您用猫模因结束帖子,您可以让读者做任何事情。但是我是一个狗人,所以你去...

Help me out

如果您喜欢Zenstack背后的想法,如果您能给它一个明星,我会很感激,以便更多的人可以从中找到并受益!

https://github.com/zenstackhq/zenstack