ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

RedwoodJS Directives 完全指南:用 Validator 与 Transformer 定制你的 GraphQL 执行流

RedwoodJS Directives 完全指南:用 Validator 与 Transformer 定制你的 GraphQL 执行流 RedwoodJS Directives 完全指南用 Validator 与 Transformer 定制你的 GraphQL 执行流【免费下载链接】redwoodRedwoodGraphQL项目地址: https://gitcode.com/gh_mirrors/re/redwood导读RedwoodJS Directives 是 Redwood 框架中一个强大的 GraphQL 扩展机制你可以把它理解为运行在 GraphQL 执行过程中的中间件在字段、查询或变更被解析resolve的前后注入可复用的代码用来做鉴权校验、字段格式化、敏感数据脱敏等任务。本文以 RedwoodJS 官方文档docs/versioned_docs/version-4.x/directives.md为骨架并结合 redwoodjs/graphql-server 包的源码实现系统讲解 Validator 与 Transformer 两类指令的编写、注册、测试与组合技巧。读完本文你将能够自定义requireAuth之外的安全指令、写出带角色校验的字段脱敏 Transformer并理解指令在 GraphQL 执行阶段中的真实挂载原理。什么是 Redwood Directive在 GraphQL 的 SDLSchema Definition Language中指令以开头、紧邻字段声明例如type Bar { name: String! myDirective }它同样可以声明在 Query 或 Mutation 上type Query { bars: [Bar!]! myDirective } type Mutation { createBar(input: CreateBarInput!): Bar! myDirective }指令还可以接收参数这些参数会在指令求值阶段被提取出来使用type Bar { field: String! myDirective(roles: [ADMIN]) } type Query { bars: [Bar!]! myDirective(roles: [ADMIN]) }甚至可以用在关系relations字段上type Baz { name: String! } type Bar { name: String! bazzes: [Baz]! myDirective }Redwood 将指令分成两类并赋予它们明确的目的Validators校验器在字段值解析之前运行用于判断请求是否被允许。Redwood 用它来保护 API Service 免受未授权访问。Transformers转换器在字段值解析之后运行可以访问并修改解析结果——改字符串、格式化日期、屏蔽敏感数据等。任何 Redwood 指令都必须满足以下约束位于api/src/directives/{directiveName}目录中directiveName即指令目录名目录下必须有一个名为{directiveName}.{js,ts}的文件例如maskedEmail.ts必须导出一个schema并且实现validateValidator或transformTransformer函数。从源码上看这两类指令其实共享同一个底层描述结构。在 packages/graphql-server/src/plugins/useRedwoodDirective.ts 中RedwoodDirective是ValidatorDirective | TransformerDirective的联合类型二者都由schemaDocumentNodeonResolvedValue函数 type标记组成type由枚举DirectiveType.VALIDATOR/DirectiveType.TRANSFORMER区分见 useRedwoodDirective.ts。理解指令的执行流程了解一点 GraphQL 的执行阶段Execution phase有助于你真正理解指令的挂载点。Redwood 指令是通过 graphql-yoga 插件机制在 schema 变化时包裹wrap受影响的 resolver 来实现的核心逻辑在 packages/graphql-server/src/plugins/useRedwoodDirective.ts 的wrapAffectedResolvers中。Validator 流程以内置的requireAuth为例当请求到达时指令先于 resolver 执行。如果请求上下文context中存在currentUser且应用api/src/lib/auth.{js|ts}中的isAuthenticated()判定通过执行阶段才会继续最终调用 Service例如post({ id })经由 Prisma 查询数据库并返回数据。Transformer 流程以welcome为例GraphQL 执行阶段照常进行查询仍可受requireAuth保护字段被解析出原始值后指令才拿到resolvedValue这里是 Welcome to the blog!将其替换为插入当前用户名后的 Welcome, Tom, to the blog! 再返回给客户端。在源码层面Validator 的包裹方式是options.onResolvedValue({ root, args, context, info, directiveNode, directiveArgs })先执行若返回 Promise 则等待其 resolve 后再调用原始 resolveruseRedwoodDirective.tsTransformer 则是先调用原始 resolver 得到resolvedValue再把该值连同上下文一起交给onResolvedValue无论同步还是异步 resolve 都返回转换后的结果useRedwoodDirective.ts。这正对应文档中的结论Validators evaluate prior to resolving the field value校验先于解析、返回值被忽略与 Transformers runafterresolving the value转换后于解析、必须返回同型值。Validators在解析前拦截请求Validator 与 Redwood 的认证机制深度集成用来判断某个字段、查询或变更是否被允许——即请求上下文的currentUser是否已认证、是否属于允许的角色。Validators 应当抛出错误如AuthenticationError、ForbiddenError来拒绝访问或者直接return放行。下面是一个检查currentUser是否存在即已认证且拥有SUBSCRIBER角色的isSubscriber校验器import { AuthenticationError, ForbiddenError, createValidatorDirective, ValidatorDirectiveFunc, } from redwoodjs/graphql-server import { hasRole } from src/lib/auth export const schema gql directive isSubscriber on FIELD_DEFINITION const validate: ValidatorDirectiveFunc ({ context }) { if (!context.currentUser) { throw new AuthenticationError(You dont have permission to do that.) } if (!context.currentUser.roles?.includes(SUBSCRIBER)) { throw new ForbiddenError(You dont have access to do that.) } } const isSubscriber createValidatorDirective(schema, validate) export default isSubscriber由于 Validator 可以读取directiveArgs例如roles参数你可以快速为字段、查询和变更实现 RBAC基于角色的访问控制。Redwood 官方模板中的requireAuth正是这一模式的典型实现见 packages/create-redwood-app/templates/ts/api/src/directives/requireAuth/requireAuth.tsimport gql from graphql-tag import type { ValidatorDirectiveFunc } from redwoodjs/graphql-server import { createValidatorDirective } from redwoodjs/graphql-server import { requireAuth as applicationRequireAuth } from src/lib/auth export const schema gql Use to check whether or not a user is authenticated and is associated with an optional set of roles. directive requireAuth(roles: [String]) on FIELD_DEFINITION type RequireAuthValidate ValidatorDirectiveFunc{ roles?: string[] } const validate: RequireAuthValidate ({ directiveArgs }) { const { roles } directiveArgs applicationRequireAuth({ roles }) } const requireAuth createValidatorDirective(schema, validate) export default requireAuth注意类型提示的用法ValidatorDirectiveFunc{ roles?: string[] }通过泛型把directiveArgs的类型收窄从而在validate内部获得类型安全的roles。这正是 useRedwoodDirective.ts 中ValidatorDirectiveFuncTDirectiveArgs泛型声明的实战价值。所有 Redwood 应用自带两个内置 ValidatorrequireAuth与skipAuth。requireAuth可接收可选的roles参数用于保护敏感数据skipAuth则显式声明公开访问。注意Validators 在字段值解析之前求值因此你无法修改字段值任何返回值都会被忽略。Transformers在解析后改写结果Transformer 可以访问已解析的字段值修改后再替换进响应。它既可以作用于单个字段如User的email也可以作用于集合如一组Post或者作为查询的结果。因此Transformer 不能用于 Mutation——Mutation 没有字段解析结果可供转换。对于单个字段指令返回修改后的字段值即可对于集合指令可以遍历每个元素逐一修改例如逐条改写title。无论哪种情况指令必须返回 SDL 所期望的、与原始数据相同形状的结果。注意指令可以链式使用——先校验再转换例如requireAuth maskedEmail也可以级联多个转换例如uppercase配合truncate把标题转大写并截断到 10 个字符。Transformer 同样可以读取directiveArgs如permittedRoles或maxLength据此决定是否执行转换。下面是一个带permittedRoles参数、对 ADMIN 放行的脱敏指令type user { email: String! maskedEmail(permittedRoles: [ADMIN]) }如果currentUser是ADMIN就跳过脱敏、直接返回原始解析值import { createTransformerDirective, TransformerDirectiveFunc } from redwoodjs/graphql-server export const schema gql directive maskedEmail(permittedRoles: [String]) on FIELD_DEFINITION const transform: TransformerDirectiveFunc ({ context, resolvedValue }) { return resolvedValue.replace(/[a-zA-Z0-9]/i, *) } const maskedEmail createTransformerDirective(schema, transform) export default maskedEmail在 SDL 中使用type UserExample { id: Int! email: String! maskedEmail # 响应中会将字母数字字符替换为星号 name: String }️重要transform函数必须返回相同类型的值。如果resolvedValue是String就返回String是Date就返回Date否则数据将无法匹配 SDL 中声明的类型。源码层面createTransformerDirective与createValidatorDirective的差异仅仅体现在返回对象的type字段上makeDirectives.ts而TransformerDirectiveFunc的类型签名强制要求返回TFielduseRedwoodDirective.ts从类型系统层面保证了必须返回同型值这一约束。指令可以声明在哪里一个指令只能在 GraphQL schema 或操作中被允许的位置出现这些位置由指令定义directive definition列出。Redwood 指令如上文maskedEmail只能出现在FIELD_DEFINITION位置——即Type上的字段type UserExample { id: Int! email: String! requireAuth name: String maskedEmail # 会在响应中脱敏 name 字段 } type Query { userExamples: [UserExample!]! requireAuth # 拉取全部用户时强制鉴权 userExamples(id: Int!): UserExample requireAuth # 拉取单个用户时强制鉴权 }注意虽然 GraphQL 规范支持FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | ENUM_VALUE等位置但 Redwood Directives只能声明在FIELD_DEFINITION上——你不能把指令放进Input typeinput UserExampleInput { email: String! maskedEmail # 不允许出现在 input 上 name: String! requireAuth # 同样不允许 }什么时候应该使用 Redwood DirectiveGraphQL 规范指出指令在需要避免对查询进行字符串拼接来增删字段时非常有用服务端实现也可以通过定义全新指令来添加实验特性。原文档引用了 GraphQL 规范的官方说明本仓库内不包含该外部文档。下面这张决策表可以帮助你判断何时该用 Validator / Transformer何时不该用用途指令自定义类型✅检查请求是否已认证requireAuth内置Validator✅检查用户是否属于某个角色requireAuth(roles: [AUTHOR])内置Validator✅只允许管理员看到邮箱其他人看到#########.###这样的脱敏值maskedEmail(roles: [ADMIN])自定义Transformer判断当前登录用户能否编辑某条记录/某些值N/A —— 应在你的 Service 中检查校验输入是否为合法的邮箱格式N/A —— 应在 Service 中使用 Service Validations 或考虑 GraphQL Scalars 方案想从响应中移除某个字段做数据过滤例如不返回文章的 titleskip(if: true )或include(if: false)应使用 GraphQL 核心指令作用于客户端查询而非 SDLCore GraphQL要点判断当前用户是否有权编辑记录/值、校验输入格式都应在 Service 层完成而按条件在客户端查询中剔除字段应使用 GraphQL 核心指令skip/include它们作用于客户端查询语句而非服务端 SDL。组合、链式与级联指令Validator 和 Transformer 能否一起用能否对 Transformer 的结果再做一次转换答案都是可以在查询与类型字段上组合指令场景一只允许登录用户查询User详情且只有 ADMIN 能看到未脱敏的邮箱。把requireAuth放在user(id: Int!)查询上再组合一个带角色判断的maskedEmail放在email字段上type User { id: Int! name: String! email: String! maskedEmail(role: ADMIN) createdAt: DateTime! } type Query { user(id: Int!): User requireAuth }场景二只允许登录用户查询 User 详情且只有 ADMIN 能请求到 email 字段本身。把requireAuth同时放在查询和email字段上字段级带上角色参数type User { id: Int! name: String! email: String! requireAuth(role: ADMIN) createdAt: DateTime! } type Query { user(id: Int!): User requireAuth }此时非 ADMIN 用户查询以下字段组合是可以成功的query user(id: 1) { id name createdAt }但如果他们尝试请求emailquery user(id: 1) { id name email createdAt }他们在发起请求时就会被禁止Forbidden——因为字段上的requireAuth(role: ADMIN)在字段解析前就拦截了。这一点在 packages/graphql-server/src/plugins/tests/useRedwoodDirective.test.ts 中有对应的测试用例验证即使是 Type 字段上声明的requireAuth也会被强制施加到执行上该测试文件同时覆盖了skipAuth放行、带角色参数拦截、以及同一字段同时出现requireAuth skipAuth时以鉴权为准等场景。链式串联一个 Validator 与一个 Transformer如果转换本身不依赖认证或角色可以先校验再转换。下面这个例子里任何人要查询 User 并获取 email 都必须先通过认证认证通过后再对 email 字段施加脱敏type User { id: Int! name: String! email: String! requireAuth maskedEmail createdAt: DateTime! }级联多个 Transformer如果需要叠加多种字段格式化级联同样可行。例如自定义localTimezoneTransformer 读取请求头中的地理位置或时区信息把createdAt从 UTC 转换为本地时间通常是浏览器端才做的事再链上dateFormat只保留时间戳中的日期部分type User { id: Int! name: String! email: String! createdAt: DateTime! localTimezone dateFormat }注意这类指令未来也可以实现为操作指令operation directives让客户端在查询中直接使用而不是声明在 schema 层。这类指令是 Redwood 未来可能支持的指令特性方向。GraphQL Handler 的装配Redwood 让指令的编写、组织与映射变得非常简单只需把指令放进api/src/directives目录createGraphQLHandler会自动完成所有装配工作。以fixtures/empty-project/api/src/functions/graphql.ts 这类入口为模板完整的 handler 大致如下import { createGraphQLHandler } from redwoodjs/graphql-server import directives from src/directives/**/*.{js,ts} // 指令在这里 import sdls from src/graphql/**/*.sdl.{js,ts} import services from src/services/**/*.{js,ts} import { db } from src/lib/db import { logger } from src/lib/logger export const handler createGraphQLHandler({ loggerConfig: { logger, options: {} }, directives, // 指令在这里被加入 schema sdls, services, onException: () { // Disconnect from your database with an unhandled exception. db.$disconnect() }, })通配符src/directives/**/*.{js,ts}会把所有指令文件以 glob 形式导入。在 packages/graphql-server/src/directives/makeDirectives.ts 的makeDirectivesForPlugin中每个导入的 glob 名会被解析出指令文件名directiveNameFromFile随后从该模块的导出中优先取与文件名同名的具名导出否则取default导出如果取到的指令没有type字段即没有用createValidatorDirective或createTransformerDirective创建会直接抛出错误提示。也就是说只要你的指令文件正确导出createValidatorDirective/createTransformerDirective的结果createGraphQLHandler就会自动把它挂进 schema。注意Redwood 提供了生成器generator来做全部样板文件的搭建见下文自定义指令一节。默认安全的架构内置指令默认情况下你的 GraphQL 端点是对全世界开放的——任何人可以请求任何查询、调用任何 MutationSDL 中定义的任何类型和字段都是公开数据。Redwood 鼓励默认安全secure by default生成 SDL 或 Service 时所有查询和 Mutation 默认带requireAuth。当应用构建、服务启动时Redwood 会检查所有查询和 Mutation 是否带有requireAuth、skipAuth或自定义指令。如果没有构建会失败✖ Verifying graphql schema... Building API... Cleaning Web... Building Web... Prerendering Web... You must specify one of requireAuth, skipAuth or a custom directive for - contacts Query - posts Query - post Query - updatePost Mutation - deletePost Mutation或者服务器无法启动并提示 Schema validation failedgen | Generating TypeScript definitions and GraphQL schemas... gen | 47 files generated api | Building... Took 593 ms api | [GQL Server Error] - Schema validation failed api | ---------------------------------------- api | You must specify one of requireAuth, skipAuth or a custom directive for api | - posts Query api | - createPost Mutation api | - updatePost Mutation api | - deletePost Mutation修复方式很简单给相应的查询和 Mutation 补上合适的指令即可。requireAuthrequireAuth指令会调用应用api/src/lib/auth.{js|ts}中的requireAuth()函数来判断用户是否已认证、是否具备期望的角色。实现该函数是你的责任——新应用的 auth stub 文件大致如下// ... export const isAuthenticated (): boolean { return true // 替换为合适的检查逻辑 } // ... export const requireAuth ({ roles }: { roles: AllowedRoles }) { if (isAuthenticated()) { throw new AuthenticationError(You dont have permission to do that.) } if (!hasRole({ roles })) { throw new ForbiddenError(You dont have access to do that.) } }注意这里的auth.ts是新 RedwoodJS 应用的占位桩。一旦你用某个认证方案完成了 auth 配置这里就会执行真正的认证检查。更完整的requireAuth实现可参考 packages/graphql-server/src/functions/tests/fixtures/auth.ts 中的示例。skipAuth如果希望某个查询或 Mutation 对公众开放直接使用skipAuth即可。编写自定义指令当然你完全可以写自己的指令。用 Redwood CLI 生成即可它负责全部样板文件还会贴心地带上一份可直接运行的测试。生成器执行yarn redwood generate时会提示选择创建 Validator 还是 Transformer 指令yarn redwood generate directive myDirective ? What type of directive would you like to generate? › - Use arrow-keys. Return to submit. ❯ Validator - Implement a validation: throw an error if criteria not met to stop execution Transformer - Modify values of fields or query responses注意也可以直接传--type标志值为validator或transformer跳过交互选择。选好类型后文件会创建在你的api/src/directives目录✔ Generating directive file ... ✔ Successfully wrote file ./api/src/directives/myDirective/myDirective.test.ts ✔ Successfully wrote file ./api/src/directives/myDirective/myDirective.ts ✔ Generating TypeScript definitions and GraphQL schemas ... ✔ Next steps... After modifying your directive, you can add it to your SDLs e.g.: // example todo.sdl.js # Option A: Add it to a field type Todo { id: Int! body: String! myDirective } # Option B: Add it to query/mutation type Query { todos: [Todo] myDirective }生成器实际使用的模板文件位于 packages/cli/src/commands/generate/directive/templates/validator.directive.ts.template以及同目录下的transformer.directive.ts.template、对应的.test.ts.templateCLI 命令实现在 packages/cli/src/commands/generate/directive/directive.js并配套了销毁命令 packages/cli/src/commands/destroy/directive/directive.js。编写一个 Validator创建一个isSubscriber指令检查用户是否为订阅者yarn rw g directive isSubscriber --type validator然后在指令的validate函数里实现校验逻辑。Validator 指令拿不到字段值它在解析值之前被调用但可以访问context与directiveArgs它可以是异步的也可以是同步的如果想中断执行例如权限不足就抛出一个错误返回值会被忽略。directiveArgs的一个例子就是requireAuth(roles: ADMIN)中的roles参数。生成器产出的validate骨架如下const validate: ValidatorDirectiveFunc ({ context, directiveArgs }) { // 你也可以修改指令让它接收参数 // 然后通过传给此函数的 directiveArgs 对象取值 logger.debug(directiveArgs, directiveArgs in isSubscriber directive) throw new Error(Implementation missing for isSubscriber) }接下来访问context检查currentUser是否已认证、是否属于SUBSCRIBER角色// ... const validate: ValidatorDirectiveFunc ({ context }) { if (!context.currentUser) { throw new AuthenticationError(You dont have permission to do that.) } if (!context.currentUser.roles?.includes(SUBSCRIBER)) { throw new ForbiddenError(You dont have access to do that.) } }编写 Validator 测试编写 Validator 指令测试时你需要确保指令命名一致且正确这样指令名才能在校验时正确映射确认指令在非法情况下抛出错误——Validator 指令应当总是有理由抛错。生成指令时默认 stub 了Error(Implementation missing for isSubscriber)因此下面的测试可以直接通过但一旦你开始实现真正的校验逻辑就需要同步更新测试import { mockRedwoodDirective, getDirectiveName } from redwoodjs/testing/api import isSubscriber from ./isSubscriber describe(isSubscriber directive, () { it(declares the directive sdl as schema, with the correct name, () { expect(isSubscriber.schema).toBeTruthy() expect(getDirectiveName(isSubscriber.schema)).toBe(isSubscriber) }) it(has a isSubscriber throws an error if validation does not pass, () { const mockExecution mockRedwoodDirective(isSubscriber, {}) expect(mockExecution).toThrowError(Implementation missing for isSubscriber) }) })测试中用到的getDirectiveName正是 packages/graphql-server/src/directives/makeDirectives.ts 中的实现——它从 schema 的 AST 定义中提取DIRECTIVE_DEFINITION节点的name.value这也是createValidatorDirective内部解析指令名的同一套逻辑。:::tip 如果 Validator 指令是异步的改用mockAsyncRedwoodDirectiveimport { mockAsyncRedwoodDirective } from redwoodjs/testing/api // ... describe(isSubscriber directive, () { it(has a isSubscriber throws an error if validation does not pass, async () { const mockExecution mockAsyncRedwoodDirective(isSubscriber, {}) await expect(mockExecution()).rejects.toThrowError( Implementation missing for isSubscriber ) }) }):::编写一个 Transformer创建一个maskedEmail指令根据角色决定显示完整邮箱还是模糊化处理yarn rw g directive maskedEmail --type transformer然后在transform函数里实现转换逻辑。Transformer 指令提供context与resolvedValue参数在值解析之后运行它们必须是同步的并且必须返回一个值。你也可以抛出错误来中断执行但要注意此时值已经被解析过了。重点关注resolvedValueconst transform: TransformerDirectiveFunc ({ context, resolvedValue }) { return resolvedValue.replace(foo, bar) }它包含指令所在字段的值——这里就是email。所以resolvedValue是 User 模型中 email 属性的值即原始值。当你从transform函数返回一个修改后的值时它会作为结果替换响应中的email值。编写 Transformer 测试编写 Transformer 指令测试时你需要确保指令命名一致且正确这样指令名才能在转换时正确映射确认指令返回一个值且该值是预期的转换结果。生成时默认 mock 了mockedResolvedValue因此这些测试可以直接通过但一旦你开始实现真正的转换逻辑就需要同步更新测试。这里 mock 的原始值是foo生成的transform函数会把foo替换为bar因此期望执行后返回barimport { mockRedwoodDirective, getDirectiveName } from redwoodjs/testing/api import maskedEmail from ./maskedEmail describe(maskedEmail directive, () { it(declares the directive sdl as schema, with the correct name, () { expect(maskedEmail.schema).toBeTruthy() expect(getDirectiveName(maskedEmail.schema)).toBe(maskedEmail) }) it(has a maskedEmail implementation transforms the value, () { const mockExecution mockRedwoodDirective(maskedEmail, { mockedResolvedValue: foo, }) expect(mockExecution()).toBe(bar) }) }):::tip 如果 Transformer 指令是异步的改用mockAsyncRedwoodDirectiveimport { mockAsyncRedwoodDirective } from redwoodjs/testing/api // ... import maskedEmail from ./maskedEmail describe(maskedEmail directive, () { it(has a maskedEmail implementation transforms the value, async () { const mockExecution mockAsyncRedwoodDirective(maskedEmail, { mockedResolvedValue: foo, }) await expect(mockExecution()).resolves.toBe(bar) }) }):::小结Redwood Directives 把 GraphQL 生态中写指令这件容易变得复杂的事情收敛成了两条清晰的路径用createValidatorDirective在解析前拦截、用createTransformerDirective在解析后改写。所有指令统一存放在api/src/directives目录由createGraphQLHandler经 glob 导入自动装配进 schemarequireAuth/skipAuth内置指令配合默认安全的 schema 校验保证了任何查询和 Mutation 都无法在未授权的情况下裸奔。结合yarn rw g directive生成器、redwoodjs/testing/api提供的mockRedwoodDirective/mockAsyncRedwoodDirective测试工具以及redwoodjs/graphql-server中 makeDirectives.ts 与 useRedwoodDirective.ts 的插件实现你可以在完全理解底层执行时序的前提下写出生产级的鉴权与数据转换逻辑。【免费下载链接】redwoodRedwoodGraphQL项目地址: https://gitcode.com/gh_mirrors/re/redwood创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表