Introduction
In the Node.js ecosystem, building GraphQL APIs typically requires writing a lot of boilerplate code manually: defining schemas, writing resolvers, handling type validation, database operations, and more. Today, I'd like to introduce you to a brand new solution — GQLoom — that allows you to build type-safe GraphQL APIs using your favorite TypeScript type libraries (like Valibot or Zod), significantly boosting development efficiency.
What is GQLoom?
GQLoom is a Code First GraphQL Schema framework that weaves runtime types from the TypeScript/JavaScript ecosystem into GraphQL schemas. Simply put, you can use types defined with Valibot or Zod to directly generate GraphQL APIs without duplicating schema definitions.
Hands-on Project: Cattery Management System
To help you better understand the power of GQLoom, we'll build a complete cattery management system together. This project will cover:
- 🐱 Cat Information Management: Enter, query, and update basic cat information (name, birthday, etc.)
- 👤 User (Cat Owner) Management: User registration, login authentication, and viewing their own cats
- 🔗 Relationship Queries: Query cat owner information and view all cats belonging to a user
- 🛡️ Access Control: Only logged-in users can add cats, ensuring data security
Technology Stack
We'll use the following modern technology stack:
- 🚀 TypeScript - Type-safe JavaScript superset
- 🟢 Node.js - JavaScript runtime environment
- 📊 GraphQL - Powerful API query language
- 🧘 GraphQL Yoga - Modern GraphQL server
- 🗄️ Drizzle ORM - Lightweight, type-safe ORM
- ⚡ Zod - Powerful TypeScript-first validation library
- 🧵 GQLoom - Framework for weaving runtime types into GraphQL schemas
Prerequisites
Before we begin, make sure your development environment meets the following requirements:
- Node.js 20+ - Recommended to use the latest LTS version
- npm/yarn/pnpm - Package manager (this article uses npm)
- Code Editor - VS Code or other TypeScript-supporting editor
Creating the Application
Project Structure
Our application will have the following structure:
cattery/
├── src/
│ ├── contexts/
│ │ └── index.ts
│ ├── providers/
│ │ └── index.ts
│ ├── resolvers/
│ │ ├── cat.ts
│ │ ├── index.ts
│ │ └── user.ts
│ ├── schema/
│ │ └── index.ts
│ └── index.ts
├── drizzle.config.ts
├── package.json
└── tsconfig.json
The functions of each folder or file under the src
directory are as follows:
-
contexts
: Store contexts, such as the current user -
providers
: Store functions that need to interact with external services, such as database connections and Redis connections -
resolvers
: Store GraphQL resolvers -
schema
: Store schemas, mainly database table structures -
index.ts
: Used to run the GraphQL application as an HTTP service
Tip
GQLoom has no requirements for the project's file structure. This is just a reference, and in practice, you can organize files according to your needs and preferences.
Initialize the Project
First, let's create a new folder and initialize the project:
mkdir cattery
cd ./cattery
npm init -y
Then, we'll install some necessary dependencies to run a TypeScript application in Node.js:
npm i -D typescript @types/node tsx
npx tsc --init
Next, we'll install GQLoom and Zod-related dependencies:
npm i graphql graphql-yoga @gqloom/core zod @gqloom/zod
Hello World
Let's write our first resolver:
// src/resolvers/index.ts
import { query, resolver } from "@gqloom/core"
import * as z from "zod"
const helloResolver = resolver({
hello: query(z.string())
.input({
name: z
.string()
.nullish()
.transform((x) => x ?? "World"),
})
.resolve(({ name }) => `Hello ${name}!`),
})
export const resolvers = [helloResolver]
We need to weave this resolver into a GraphQL schema and run it as an HTTP server:
// src/index.ts
import { createServer } from "node:http"
import { weave } from "@gqloom/core"
import { ZodWeaver } from "@gqloom/zod"
import { createYoga } from "graphql-yoga"
import { resolvers } from "src/resolvers"
const schema = weave(ZodWeaver, ...resolvers)
const yoga = createYoga({ schema })
createServer(yoga).listen(4000, () => {
console.info("Server is running on http://localhost:4000/graphql")
})
Great! We've created a simple GraphQL application.
Next, let's try running this application. Add the dev
script to package.json
:
{
"scripts": {
"dev": "tsx watch src/index.ts"
}
}
Now let's run it:
npm run dev
Open http://localhost:4000/graphql in your browser to see the GraphQL playground.
Let's try sending a GraphQL query. Enter the following in the playground:
{
hello(name: "GQLoom")
}
Click the query button, and you can see the result:
{
"data": {
"hello": "Hello GQLoom!"
}
}
So far, we've created the simplest GraphQL application.
Next, we'll use Drizzle ORM to interact with the database and add complete functionality.
Initialize Database and Tables
First, let's install Drizzle ORM. We'll use it to operate the SQLite database.
npm i @gqloom/drizzle drizzle-orm @libsql/client dotenv
npm i -D drizzle-kit
Define Database Tables
Next, define the database tables in the src/schema/index.ts
file. We'll define two tables, users
and cats
, and establish the relationship between them:
// src/schema/index.ts
import { drizzleSilk } from "@gqloom/drizzle"
import { relations } from "drizzle-orm"
import * as t from "drizzle-orm/sqlite-core"
export const users = drizzleSilk(
t.sqliteTable("users", {
id: t.int().primaryKey({ autoIncrement: true }),
name: t.text().notNull(),
phone: t.text().notNull().unique(),
})
)
export const usersRelations = relations(users, ({ many }) => ({
cats: many(cats),
}))
export const cats = drizzleSilk(
t.sqliteTable("cats", {
id: t.integer().primaryKey({ autoIncrement: true }),
name: t.text().notNull(),
birthday: t.integer({ mode: "timestamp" }).notNull(),
ownerId: t
.integer()
.notNull()
.references(() => users.id),
})
)
export const catsRelations = relations(cats, ({ one }) => ({
owner: one(users, {
fields: [cats.ownerId],
references: [users.id],
}),
}))
Initialize the Database
We need to create a configuration file:
// drizzle.config.ts
import "dotenv/config"
import { defineConfig } from "drizzle-kit"
export default defineConfig({
out: "./drizzle",
schema: "./src/schema/index.ts",
dialect: "sqlite",
dbCredentials: {
url: process.env.DB_FILE_NAME ?? "file:local.db",
},
})
Then we run the drizzle-kit push
command to create the defined tables in the database:
npx drizzle-kit push
Use the Database
To use the database in the application, we need to create a database instance:
// src/providers/index.ts
import { drizzle } from "drizzle-orm/libsql"
import * as schema from "src/schema"
export const db = drizzle(process.env.DB_FILE_NAME ?? "file:local.db", {
schema,
})
Resolvers
Now, we can use the database in resolvers. We'll create a user resolver and add the following operations:
-
usersByName
: Find users by name -
userByPhone
: Find users by phone number -
createUser
: Create a user
After completing the user resolver, we also need to add it to the resolvers
in the src/resolvers/index.ts
file:
// src/resolvers/user.ts
import { mutation, query, resolver } from "@gqloom/core"
import { eq } from "drizzle-orm"
import { db } from "src/providers"
import { users } from "src/schema"
import * as z from "zod"
export const userResolver = resolver.of(users, {
usersByName: query(users.$list())
.input({ name: z.string() })
.resolve(({ name }) => {
return db.query.users.findMany({
where: eq(users.name, name),
})
}),
userByPhone: query(users.$nullable())
.input({ phone: z.string() })
.resolve(({ phone }) => {
return db.query.users.findFirst({
where: eq(users.phone, phone),
})
}),
createUser: mutation(users)
.input({
data: z.object({
name: z.string(),
phone: z.string(),
}),
})
.resolve(async ({ data }) => {
const [user] = await db.insert(users).values(data).returning()
return user
}),
})
// src/resolvers/index.ts
import { query, resolver } from "@gqloom/core"
import { userResolver } from "src/resolvers/user"
import * as z from "zod"
const helloResolver = resolver({
hello: query(z.string())
.input({
name: z
.string()
.nullish()
.transform((x) => x ?? "World"),
})
.resolve(({ name }) => `Hello ${name}!`),
})
export const resolvers = [helloResolver, userResolver]
Great! Now let's try it in the playground:
GraphQL Mutation:
mutation {
createUser(data: {name: "Bob", phone: "001"}) {
id
name
phone
}
}
Response:
{
"data": {
"createUser": {
"id": 1,
"name": "Bob",
"phone": "001"
}
}
}
Let's continue to try retrieving the user we just created:
GraphQL Query:
{
usersByName(name: "Bob") {
id
name
phone
}
}
Response:
{
"data": {
"usersByName": [
{
"id": 1,
"name": "Bob",
"phone": "001"
}
]
}
}
Current User Context
First, let's add the asyncContextProvider
middleware to enable asynchronous context:
// src/index.ts
import { createServer } from "node:http"
import { weave } from "@gqloom/core"
import { asyncContextProvider } from "@gqloom/core/context"
import { ZodWeaver } from "@gqloom/zod"
import { createYoga } from "graphql-yoga"
import { resolvers } from "src/resolvers"
const schema = weave(asyncContextProvider, ZodWeaver, ...resolvers)
const yoga = createYoga({ schema })
createServer(yoga).listen(4000, () => {
console.info("Server is running on http://localhost:4000/graphql")
})
Next, let's try adding a simple login function and add a query operation to the user resolver:
-
mine
: Return current user information
To implement this query, we first need to have a login function. Let's write a simple one:
// src/contexts/index.ts
import { createMemoization, useContext } from "@gqloom/core/context"
import { eq } from "drizzle-orm"
import { GraphQLError } from "graphql"
import type { YogaInitialContext } from "graphql-yoga"
import { db } from "src/providers"
import { users } from "src/schema"
export const useCurrentUser = createMemoization(async () => {
const phone =
useContext<YogaInitialContext>().request.headers.get("authorization")
if (phone == null) throw new GraphQLError("Unauthorized")
const user = await db.query.users.findFirst({ where: eq(users.phone, phone) })
if (user == null) throw new GraphQLError("Unauthorized")
return user
})
In the above code, we created a context function for getting the current user, which will return the information of the current user. We use createMemoization()
to memoize this function, which ensures that this function is only executed once within the same request to avoid unnecessary database queries.
We used useContext()
to get the context provided by Yoga, and obtained the user's phone number from the request header, and found the user according to the phone number. If the user does not exist, a GraphQLError
will be thrown.
Note
As you can see, this login function is very simple and is only used for demonstration purposes, and it does not guarantee security at all. In practice, it is usually recommended to use solutions such assession
orJWT
.
Now, we add the new query operation in the resolver:
// src/resolvers/user.ts
import { mutation, query, resolver } from "@gqloom/core"
import { eq } from "drizzle-orm"
import { useCurrentUser } from "src/contexts"
import { db } from "src/providers"
import { users } from "src/schema"
import * as z from "zod"
export const userResolver = resolver.of(users, {
mine: query(users).resolve(() => useCurrentUser()),
usersByName: query(users.$list())
.input({ name: z.string() })
.resolve(({ name }) => {
return db.query.users.findMany({
where: eq(users.name, name),
})
}),
userByPhone: query(users.$nullable())
.input({ phone: z.string() })
.resolve(({ phone }) => {
return db.query.users.findFirst({
where: eq(users.phone, phone),
})
}),
createUser: mutation(users)
.input({
data: z.object({
name: z.string(),
phone: z.string(),
}),
})
.resolve(async ({ data }) => {
const [user] = await db.insert(users).values(data).returning()
return user
}),
})
If we directly call this new query in the playground, the application will give us an unauthorized error:
GraphQL Query:
{
mine {
id
name
phone
}
}
Response:
{
"errors": [
{
"message": "Unauthorized",
"locations": [
{
"line": 2,
"column": 3
}
],
"path": [
"mine"
]
}
],
"data": null
}
Open the Headers
at the bottom of the playground and add the authorization
field to the request header. Here we use the phone number of Bob
created in the previous step, so we are logged in as Bob
:
Headers:
{
"authorization": "001"
}
GraphQL Query:
{
mine {
id
name
phone
}
}
Response:
{
"data": {
"mine": {
"id": 1,
"name": "Bob",
"phone": "001"
}
}
}
Resolver Factory
Next, we'll add business logic related to cats.
We use the resolver factory to quickly create interfaces:
// src/resolvers/cat.ts
import { field, resolver } from "@gqloom/core"
import { drizzleResolverFactory } from "@gqloom/drizzle"
import { db } from "src/providers"
import { cats } from "src/schema"
import * as z from "zod"
const catResolverFactory = drizzleResolverFactory(db, cats)
export const catResolver = resolver.of(cats, {
cats: catResolverFactory.selectArrayQuery(),
age: field(z.number())
.derivedFrom("birthday")
.input({
currentYear: z.number().int().nullish().transform((x) => x ?? new Date().getFullYear()),
})
.resolve((cat, { currentYear }) => {
return currentYear - cat.birthday.getFullYear()
}),
})
// src/resolvers/index.ts
import { query, resolver } from "@gqloom/core"
import { catResolver } from "src/resolvers/cat"
import { userResolver } from "src/resolvers/user"
import * as z from "zod"
const helloResolver = resolver({
hello: query(z.string())
.input({
name: z
.string()
.nullish()
.transform((x) => x ?? "World"),
})
.resolve(({ name }) => `Hello ${name}!`),
})
export const resolvers = [helloResolver, userResolver, catResolver]
In the above code, we used drizzleResolverFactory()
to create catResolverFactory
for quickly building resolvers.
We added a query that uses catResolverFactory
to select data and named it cats
. This query will provide full query operations on the cats
table.
In addition, we also added an additional age
field for cats to get the age of the cats.
Next, let's try adding a createCat
mutation. We want only logged-in users to access this interface, and the created cats will belong to the current user:
// src/resolvers/cat.ts (partial code)
createCats: catResolverFactory.insertArrayMutation().input(
z.object({
values: z.array(
z.object({
name: z.string(),
birthday: z.string().transform((x) => new Date(x)),
}).transform(async ({ name, birthday }) => ({
name,
birthday,
ownerId: (await useCurrentUser()).id,
}))
),
})
),
In the above code, we used catResolverFactory
to create a mutation that adds more data to the cats
table, and we overwrote the input of this mutation. When validating the input, we used useCurrentUser()
to get the ID of the currently logged-in user and pass it as the value of ownerId
to the cats
table.
Now let's try adding a few cats in the playground:
GraphQL Mutation:
mutation {
createCats(data: {values: [{name: "Whiskers", birthday: "2020-01-01"}, {name: "Whiskers", birthday: "2020-01-01"}]}) {
id
name
age
}
}
Headers:
{
"authorization": "001"
}
Response:
{
"data": {
"createCats": [
{
"id": 1,
"name": "Mittens",
"age": 4
},
{
"id": 2,
"name": "Fluffy",
"age": 3
}
]
}
}
Let's use the cats
query to confirm the data in the database again:
GraphQL Query:
{
cats {
id
name
age
}
}
Response:
{
"data": {
"cats": [
{
"id": 1,
"name": "Mittens",
"age": 4
},
{
"id": 2,
"name": "Fluffy",
"age": 3
}
]
}
}
Associated Objects
We want to be able to get the owner of a cat when querying the cat, and also be able to get all the cats of a user when querying the user.
This is very easy to achieve in GraphQL.
Let's add an additional owner
field to cats
and an additional cats
field to users
:
// src/resolvers/cat.ts
import { field, resolver } from "@gqloom/core"
import { drizzleResolverFactory } from "@gqloom/drizzle"
import { useCurrentUser } from "src/contexts"
import { db } from "src/providers"
import { cats } from "src/schema"
import * as z from "zod"
const catResolverFactory = drizzleResolverFactory(db, cats)
export const catResolver = resolver.of(cats, {
cats: catResolverFactory.selectArrayQuery(),
age: field(z.number())
.derivedFrom("birthday")
.input({
currentYear: z.number().int().nullish().transform((x) => x ?? new Date().getFullYear()),
})
.resolve((cat, { currentYear }) => {
return currentYear - cat.birthday.getFullYear()
}),
owner: catResolverFactory.relationField("owner"),
createCats: catResolverFactory.insertArrayMutation().input(
z.object({
values: z.array(
z.object({
name: z.string(),
birthday: z.string().transform((x) => new Date(x)),
}).transform(async ({ name, birthday }) => ({
name,
birthday,
ownerId: (await useCurrentUser()).id,
}))
),
})
),
})
// src/resolvers/user.ts
import { mutation, query, resolver } from "@gqloom/core"
import { drizzleResolverFactory } from "@gqloom/drizzle"
import { eq } from "drizzle-orm"
import { useCurrentUser } from "src/contexts"
import { db } from "src/providers"
import { users } from "src/schema"
import * as z from "zod"
const userResolverFactory = drizzleResolverFactory(db, users)
export const userResolver = resolver.of(users, {
cats: userResolverFactory.relationField("cats"),
mine: query(users).resolve(() => useCurrentUser()),
usersByName: query(users.$list())
.input({ name: z.string() })
.resolve(({ name }) => {
return db.query.users.findMany({
where: eq(users.name, name),
})
}),
userByPhone: query(users.$nullable())
.input({ phone: z.string() })
.resolve(({ phone }) => {
return db.query.users.findFirst({
where: eq(users.phone, phone),
})
}),
createUser: mutation(users)
.input({
data: z.object({
name: z.string(),
phone: z.string(),
}),
})
.resolve(async ({ data }) => {
const [user] = await db.insert(users).values(data).returning()
return user
}),
})
In the above code, we used the resolver factory to create the owner
field for cats
; similarly, we also created the cats
field for users
.
Behind the scenes, the relationship fields created by the resolver factory will use DataLoader
to query from the database to avoid the N+1 problem.
Let's try querying the owner of a cat in the playground:
GraphQL Query:
{
cats {
id
name
age
owner {
id
name
phone
}
}
}
Response:
{
"data": {
"cats": [
{
"id": 1,
"name": "Mittens",
"age": 4,
"owner": {
"id": 1,
"name": "Bob",
"phone": "001"
}
},
{
"id": 2,
"name": "Fluffy",
"age": 3,
"owner": {
"id": 1,
"name": "Bob",
"phone": "001"
}
}
]
}
}
Let's try querying the cats of the current user:
GraphQL Query:
{
mine {
name
cats {
id
name
age
}
}
}
Headers:
{
"authorization": "001"
}
Response:
{
"data": {
"mine": {
"name": "Bob",
"cats": [
{
"id": 1,
"name": "Mittens",
"age": 4
},
{
"id": 2,
"name": "Fluffy",
"age": 3
}
]
}
}
}
Conclusion
In this article, we created a simple GraphQL server-side application. We used the following tools:
-
Zod
: Used to define and validate inputs -
Drizzle
: Used to operate the database, and directly use theDrizzle
table as theGraphQL
output type - Context: Used to share data between different parts of the program, which is very useful for scenarios such as implementing login and tracking logs
- Resolver factory: Used to quickly create resolvers and operations
-
GraphQL Yoga
: Used to create a GraphQL HTTP service and provides a GraphiQL playground
Our application has implemented the functions of adding and querying users
and cats
, but due to space limitations, the update and delete functions have not been implemented. They can be quickly added through the resolver factory.
Next Steps
- Check out the core concepts of GQLoom: Silk, Resolver, Weave
- Learn about common functions: Context, DataLoader, Middleware
- Add a GraphQL client to the front-end project: gql.tada, Urql, Apollo Client, TanStack Query, Graffle
Top comments (0)