Middleware
Middleware is a function that intervenes in the processing flow of a parsed function. It provides a way to insert logic into the request and response flow to execute code before a response is sent or before a request is processed.
GQLoom
's middleware follows the onion middleware pattern of Koa.
Define Middleware
Middleware is a function that will be injected with an options
object as a parameter when called. The options
object contains the following fields:
outputSilk
: output silk, which includes the output type of the field currently being parsed;parent
: the parent node of the current field, equivalent touseResolverPayload().root
;parseInput
: a function used to obtain or modify the input of the current field;type
: the type of the current field, whose value can bequery
,mutation
,subscription
, orfield
;next
: a function used to call the next middleware;
The options
object can also be directly used as the next
function.
Additionally, we can use useContext()
and useResolverPayload()
to get the context and more information of the current resolver function.
A minimal middleware function is as follows:
import { Middleware } from '@gqloom/core';
const middleware: Middleware = async (next) => {
return await next();
}
Next, we'll introduce some common types of middleware.
Error catching
When using Valibot or Zod libraries for input validation, we can catch validation errors in the middleware and return customized error messages.
import { type Middleware } from "@gqloom/core"
import { ValiError } from "valibot"
import { GraphQLError } from "graphql"
export const valibotExceptionFilter: Middleware = async (next) => {
try {
return await next()
} catch (error) {
if (error instanceof ValiError) {
const { issues, message } = error
throw new GraphQLError(message, { extensions: { issues } })
}
throw error
}
}
import { type Middleware } from "@gqloom/core"
import { ZodError } from "zod"
import { GraphQLError } from "graphql"
export const zodExceptionFilter: Middleware = async (next) => {
try {
return await next()
} catch (error) {
if (error instanceof ZodError) {
throw new GraphQLError(error.format()._errors.join(", "), {
extensions: { issues: error.issues },
})
}
throw error
}
}
Validate output
In GQLoom
, validation of parser output is not performed by default. However, we can validate the output of parser functions through middleware.
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
}
Let's try to use this middleware:
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")
})
In the code above, we added the v.minLength(10)
requirement to the output of the hello
query and added the outputValidator
middleware to the parser function. We also added a global middleware ValibotExceptionFilter
to weave
.
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")
})
In the code above, we added a z.string().min(10)
requirement to the output of the hello
query and added the outputValidator
middleware to the parser function. We also added a global middleware ValibotExceptionFilter
to weave
.
Result
When we make the following query:
{
hello(name: "W")
}
A result similar to the following will be given:
{
"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
}
If we adjust the input so that the returned string is the required length:
{
hello(name: "World")
}
It will get a response with no exceptions:
{
"data": {
"hello": "Hello, World"
}
}
Authentication
Checking a user's permissions is a common requirement that we can easily implement with middleware.
Consider that our user has the roles “admin”
and “editor”
, and we want the administrator and editor to have access to their own actions, respectively. First, we implement an authGuard
middleware that checks the user's role:
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()
}
}
In the code above, we declare an authGuard
middleware that takes a role parameter and returns a middleware function. The middleware function checks that the user is authenticated and has the specified role, and throws a GraphQLError
exception if the requirements are not satisfied.
We can apply different middleware for different resolvers:
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")] }
)
In the code above, we have applied the authGuard
middleware to AdminResolver
and EditorResolver
and assigned different roles to them. In this way, only users with the corresponding roles can access the actions within the corresponding resolvers.
Logging
We can also implement logging functionality through middleware. For example, we can create a logger
middleware to log the execution time of each field parsing function:
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
}
Caching
We can implement caching functionality through middleware. For example, we can create a cache
middleware to cache the resolution results of each query:
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
}
Modifying Input
We can modify the request input through 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 { 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
}),
})
Using middleware
GQLoom is able to apply middleware in a variety of scopes, including resolver functions, resolver local middleware, and global middleware.
Resolve function middleware
We can use middleware directly in the resolver function by using the use
method during its construction, for example:
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}`),
})
Resolver-scoped middleware
We can also apply middleware at the resolver level, so the middleware will take effect for all operations within the resolver. We just need to use the use
method to add middlewares
to the resolver:
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"))
Global middleware
In order to apply global middleware, we need to pass in the middleware fields in the weave
function, for example:
import { weave } from "@gqloom/core"
import { exceptionFilter } from "./middlewares"
export const schema = weave(helloResolver, exceptionFilter)
Applying Middleware Based on Operation Type
We can specify for which operation types a middleware should take effect.
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
is an array of strings used to specify on which operation types the middleware should take effect. The available values are:
"query"
;"mutation"
;"subscription"
;"field"
;"subscription.resolve"
;"subscription.subscribe"
;
The default value for Middleware.operations
is ["field", "query", "mutation", "subscription.subscribe"]
.