中间件(Middleware)
中间件是一种函数,它介入了解析函数的处理流程。它提供了一种在请求和响应流程中插入逻辑的方式,以便在发送响应之前或在请求处理之前执行代码。
GQLoom 的中间件遵循了 Koa 的洋葱式中间件模式。
定义中间件
中间件是一个函数,它将在调用时被注入 options 对象作为参数,options 包含以下属性:
outputSilk: 输出丝线,包含了当前正在解析字段的输出类型;parent: 当前字段的父节点,相当于useResolverPayload().root;parseInput: 用于获取或修改当前字段的输入;type: 当前字段的类型,其值为query,mutation,subscription或field;next: 用于调用下一个中间件的函数;
options 还可以直接作为 next 函数使用。
另外,我们还可以通过 useContext() 和 useResolverPayload() 获取到当前解析函数的上下文以及更多信息。
一个最基础的中间件函数如下:
import { Middleware } from '@gqloom/core';
const middleware: Middleware = async (next) => {
return await next();
}接下来,我们将介绍一些常见的中间件形式。
JSON Schema 输入验证
小提示
对于遵循 Standard Schema 的输入验证库,GQLoom 将在内部调用其提供的验证函数来对输入进行验证,不需要额外的输入验证中间件。
当使用 JSON Schema 时,我们可以使用 Ajv 来对输入做运行时验证:
import { isSilk, type Middleware } from "@gqloom/core"
import type { JSONSchema, JSONSilk } from "@gqloom/json"
import Ajv, { type ValidateFunction } from "ajv"
import { GraphQLError } from "graphql"
// Initialize Ajv instance for JSON schema validation.
const ajv = new Ajv()
// Cache compiled validation functions to avoid recompilation for the same schema.
const validators = new WeakMap<JSONSchema & object, ValidateFunction>()
function validateAndThrow(schema: JSONSchema & object, value: any) {
// Retrieve compiled validator from cache or compile it if not found.
const validate = validators.get(schema) ?? ajv.compile(schema)
// Cache the compiled validator if it wasn't already there.
if (!validators.has(schema)) validators.set(schema, validate)
const valid = validate(value)
if (!valid) {
// If validation fails, throw a GraphQLError with details.
throw new GraphQLError(validate.errors?.[0]?.message ?? "Invalid input", {
extensions: { issues: validate.errors },
})
}
}
export const inputValidator: Middleware = async ({ next, parseInput }) => {
const schema:
| JSONSilk<JSONSchema, any>
| Record<string, JSONSilk<JSONSchema, any>>
| undefined = parseInput.schema
// If input is already parsed or no schema is provided, skip validation.
if (parseInput.result || !schema) return next()
// Handle single JSONSilk schema validation.
if (isSilk(schema)) {
validateAndThrow(schema, parseInput.value)
} else {
// Handle validation for a map of schemas (e.g., for multiple arguments).
for (const [key, argSchema] of Object.entries(schema)) {
validateAndThrow(argSchema, parseInput.value[key])
}
}
return next()
}验证输出
在 GQLoom中,默认不会对解析函数的输出执行验证。但我们可以通过中间件来验证解析函数的输出。
import { silk, type Middleware } from "@gqloom/core"
import { GraphQLError } from "graphql"
export const outputValidator: Middleware = async (opts) => {
const output = await opts.next()
const result = await silk.parse(opts.outputSilk, output)
if (result.issues) {
throw new GraphQLError(result.issues[0].message, {
extensions: { issues: result.issues },
})
}
return result.value
}让我们尝试使用这个中间件:
Valibot
import { ValibotWeaver, weave, resolver, query } from "@gqloom/valibot"
import * as v from "valibot"
import { createServer } from "node:http"
import { createYoga } from "graphql-yoga"
import { outputValidator, valibotExceptionFilter } from "./middlewares"
const helloResolver = resolver({
hello: query(v.pipe(v.string(), v.minLength(10)))
.input({ name: v.string() })
.use(outputValidator)
.resolve(({ name }) => `Hello, ${name}`),
})
export const schema = weave(ValibotWeaver, helloResolver, valibotExceptionFilter)
const yoga = createYoga({ schema })
createServer(yoga).listen(4000, () => {
// eslint-disable-next-line no-console
console.info("Server is running on http://localhost:4000/graphql")
})在上面的代码中,我们对 hello 查询的输出添加了 v.minLength(10) 的要求,并在解析函数中添加了 outputValidator 中间件。 我们还在 weave 中添加了一个全局中间件 ValibotExceptionFilter。
Zod
import { weave, resolver, query } from "@gqloom/zod"
import * as z from "zod"
import { createServer } from "node:http"
import { createYoga } from "graphql-yoga"
import { outputValidator, zodExceptionFilter } from "./middlewares"
const helloResolver = resolver({
hello: query(z.string().min(10))
.input({ name: z.string() })
.use(outputValidator)
.resolve(({ name }) => `Hello, ${name}`),
})
export const schema = weave(helloResolver, zodExceptionFilter)
const yoga = createYoga({ schema })
createServer(yoga).listen(4000, () => {
// eslint-disable-next-line no-console
console.info("Server is running on http://localhost:4000/graphql")
})在上面的代码中,我们对 hello 查询的输出添加了 z.string().min(10) 的要求,并在解析函数中添加了 outputValidator 中间件。 我们还在 weave 中添加了一个全局中间件 ValibotExceptionFilter。
结果
当我们进行以下查询时:
{
hello(name: "W")
}将会得到类似如下的结果:
{
"errors": [
{
"message": "Invalid length: Expected >=10 but received 8",
"locations": [
{
"line": 2,
"column": 3
}
],
"path": [
"hello"
],
"extensions": {
"issues": [
{
"kind": "validation",
"type": "min_length",
"input": "Hello, W",
"expected": ">=10",
"received": "8",
"message": "Invalid length: Expected >=10 but received 8",
"requirement": 10
}
]
}
}
],
"data": null
}{
"errors": [
{
"message": "String must contain at least 10 character(s)",
"locations": [
{
"line": 2,
"column": 3
}
],
"path": [
"hello"
],
"extensions": {
"issues": [
{
"code": "too_small",
"minimum": 10,
"type": "string",
"inclusive": true,
"exact": false,
"message": "String must contain at least 10 character(s)",
"path": []
}
]
}
}
],
"data": null
}如果我们调整输入,使返回的字符串长度符合要求:
{
hello(name: "World")
}将会得到没有异常的响应:
{
"data": {
"hello": "Hello, World"
}
}鉴权
对用户的权限进行校验是一个常见的需求,我们可以通过中间件来轻易实现。
考虑我们的用户有 "admin" 和 "editor" 两种角色,我们希望管理员和编辑员分别可以访问自己的操作。 首先,我们实现一个 authGuard 中间件,用于校验用户的角色:
import { type Middleware } from "@gqloom/core"
import { useUser } from "./context"
import { GraphQLError } from "graphql"
export function authGuard(role: "admin" | "editor"): Middleware {
return async (next) => {
const user = await useUser()
if (user == null) throw new GraphQLError("Not authenticated")
if (!user.roles.includes(role)) throw new GraphQLError("Not authorized")
return next()
}
}在上面的代码中,我们声明了一个 authGuard 中间件,它接受一个角色参数,并返回一个中间件函数。 中间件函数会检查用户是否已经认证,并且是否具有指定的角色,如果不符合要求,则抛出一个 GraphQLError 异常。
我们可以为不同的解析器应用不同的中间件:
import { resolver, mutation } from "@gqloom/core"
import * as v from "valibot"
import { authGuard } from "./middlewares"
const adminResolver = resolver(
{
deleteArticle: mutation(v.boolean(), () => true),
},
{
middlewares: [authGuard("admin")],
}
)
const editorResolver = resolver(
{
createArticle: mutation(v.boolean(), () => true),
updateArticle: mutation(v.boolean(), () => true),
},
{ middlewares: [authGuard("editor")] }
)import { resolver, mutation } from "@gqloom/zod"
import * as z from "zod"
import { authGuard } from "./middlewares"
const adminResolver = resolver(
{
deleteArticle: mutation(z.boolean(), () => true),
},
{
middlewares: [authGuard("admin")],
}
)
const editorResolver = resolver(
{
createArticle: mutation(z.boolean(), () => true),
updateArticle: mutation(z.boolean(), () => true),
},
{ middlewares: [authGuard("editor")] }
)在上面的代码中,我们为 adminResolver 和 editorResolver 分别应用了 authGuard 中间件,并指定了不同的角色。这样,只有具有相应角色的用户才能访问对应解析器内的操作。
日志
我们也可以通过中间件来实现日志记录功能。例如,我们可以创建一个 logger 中间件,用于记录每个字段解析函数的执行时间:
import { type Middleware } from "@gqloom/core"
import { useResolverPayload } from "@gqloom/core/context"
export const logger: Middleware = async (next) => {
const info = useResolverPayload()!.info
const start = Date.now()
const result = await next()
const resolveTime = Date.now() - start
console.log(`${info.parentType.name}.${info.fieldName} [${resolveTime} ms]`)
return result
}缓存
我们可以通过中间件来实现缓存功能。例如,我们可以创建一个 cache 中间件,用于缓存每个查询的解析结果:
import type { Middleware } from "@gqloom/core"
/** Simple in-memory cache implementation */
const cacheStore = new Map<string, { data: unknown; timestamp: number }>()
export interface CacheOptions {
/**
* Time to live in milliseconds
* @default 60000
*/
ttl?: number
}
export const cache = (options: CacheOptions = {}): Middleware => {
const { ttl = 60000 } = options
const middleware: Middleware = async ({ next, payload }) => {
if (!payload?.info) {
return next()
}
const { fieldName, parentType } = payload.info
const args = payload.args || {}
const cacheKey = `${parentType.name}.${fieldName}:${JSON.stringify(args)}`
const cached = cacheStore.get(cacheKey)
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.data
}
const result = await next()
cacheStore.set(cacheKey, { data: result, timestamp: Date.now() })
return result
}
// Only apply cache to queries by default
middleware.operations = ["query"]
return middleware
}修改输入
我们可以通过中间件来修改请求输入:
import { mutation, resolver } from "@gqloom/core"
import * as v from "valibot"
const Post = v.object({
__typename: v.nullish(v.literal("Post")),
id: v.number(),
title: v.string(),
content: v.string(),
authorId: v.number(),
})
interface IPost extends v.InferOutput<typeof Post> {}
const posts: IPost[] = []
export const postsResolver = resolver({
createPost: mutation(Post)
.input(
v.object({
title: v.string(),
content: v.string(),
authorId: v.number(),
})
)
.use(async ({ next, parseInput }) => {
const result = await parseInput.getResult()
result.authorId = (await useUser()).id
parseInput.setResult(result)
return next()
})
.resolve(({ title, content, authorId }) => {
const post = {
id: Math.random(),
title,
content,
authorId,
}
posts.push(post)
return post
}),
})import { mutation, resolver } from "@gqloom/core"
import * as z from "zod"
const Post = z.object({
__typename: z.literal("Post").nullish(),
id: z.number(),
title: z.string(),
content: z.string(),
authorId: z.number(),
})
interface IPost extends z.output<typeof Post> {}
const posts: IPost[] = []
export const postsResolver = resolver({
createPost: mutation(Post)
.input(
z.object({
title: z.string(),
content: z.string(),
authorId: z.number(),
})
)
.use(async ({ next, parseInput }) => {
const result = await parseInput.getResult()
result.authorId = (await useUser()).id
parseInput.setResult(result)
return next()
})
.resolve(({ title, content, authorId }) => {
const post = {
id: Math.random(),
title,
content,
authorId,
}
posts.push(post)
return post
}),
})使用中间件
GQLoom 能够在各种范围内应用中间件,包括解析函数、解析器局部中间件和全局中间件。
解析函数中间件
我们可以在解析函数中直接使用中间件,只需要在构造时使用 use 方法,例如:
import { resolver, query } from "@gqloom/core"
import * as v from "valibot"
import { outputValidator } from "./middlewares"
const helloResolver = resolver({
hello: query(v.pipe(v.string(), v.minLength(10)))
.input({ name: v.string() })
.use(outputValidator)
.resolve(({ name }) => `Hello, ${name}`),
})import { resolver, query } from "@gqloom/zod"
import * as z from "zod"
import { outputValidator } from "./middlewares"
const helloResolver = resolver({
hello: query(z.string().min(10))
.input({ name: z.string() })
.use(outputValidator)
.resolve(({ name }) => `Hello, ${name}`),
})解析器局部中间件
我们也可以在解析器级别应用中间件,这样中间件将对解析器内的所有操作生效。 只需要使用 use 方法为解析器构添加 middlewares:
import { resolver, mutation } from "@gqloom/core"
import * as v from "valibot"
import { authGuard } from "./middlewares"
const adminResolver = resolver({
deleteArticle: mutation(v.boolean(), () => true),
}).use(authGuard("admin"))
const editorResolver = resolver({
createArticle: mutation(v.boolean(), () => true),
updateArticle: mutation(v.boolean(), () => true),
}).use(authGuard("editor")) import { resolver, mutation } from "@gqloom/zod"
import * as z from "zod"
import { authGuard } from "./middlewares"
const adminResolver = resolver({
deleteArticle: mutation(z.boolean(), () => true),
}).use(authGuard("admin"))
const editorResolver = resolver({
createArticle: mutation(z.boolean(), () => true),
updateArticle: mutation(z.boolean(), () => true),
}).use(authGuard("editor")) 全局中间件
为了应用全局中间件,我们需要在 weave 函数中传入中间件字段,例如:
import { weave } from "@gqloom/core"
import { exceptionFilter } from "./middlewares"
export const schema = weave(helloResolver, exceptionFilter) 根据操作类型应用中间件
我们可以为中间件指定在哪些操作类型上生效。
import type { Middleware } from "@gqloom/core"
import { GraphQLError } from "graphql"
export const transaction: Middleware = async ({ next }) => {
try {
await db.beginTransaction()
const result = await next()
await db.commit()
return result
} catch (error) {
await db.rollback()
throw new GraphQLError("Transaction failed", {
extensions: { originalError: error },
})
}
}
transaction.operations = ["mutation"]Middleware.operations 是一个字符串数组,用于指定中间件在哪些操作类型上生效,可选值为:
"query";"mutation";"subscription";"field";"subscription.resolve";"subscription.subscribe";
Middleware.operations 的默认值为 ["field", "query", "mutation", "subscription.subscribe"]。