DEV Community

Cover image for Getting Started with GQLoom - Building GraphQL Server Applications with Zod + Drizzle
⛵

Posted on

Getting Started with GQLoom - Building GraphQL Server Applications with Zod + Drizzle

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Next, we'll install GQLoom and Zod-related dependencies:

npm i graphql graphql-yoga @gqloom/core zod @gqloom/zod
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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")
})
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now let's run it:

npm run dev
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

Click the query button, and you can see the result:

{
  "data": {
    "hello": "Hello GQLoom!"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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],
  }),
}))
Enter fullscreen mode Exit fullscreen mode

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",
  },
})
Enter fullscreen mode Exit fullscreen mode

Then we run the drizzle-kit push command to create the defined tables in the database:

npx drizzle-kit push
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

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
    }),
})
Enter fullscreen mode Exit fullscreen mode
// 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]
Enter fullscreen mode Exit fullscreen mode

Great! Now let's try it in the playground:

GraphQL Mutation:

mutation {
  createUser(data: {name: "Bob", phone: "001"}) {
    id
    name
    phone
  }
}
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "data": {
    "createUser": {
      "id": 1,
      "name": "Bob",
      "phone": "001"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's continue to try retrieving the user we just created:

GraphQL Query:

{
  usersByName(name: "Bob") {
    id
    name
    phone
  }
}
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "data": {
    "usersByName": [
      {
        "id": 1,
        "name": "Bob",
        "phone": "001"
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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")
})
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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 as session or JWT.

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
    }),
})
Enter fullscreen mode Exit fullscreen mode

If we directly call this new query in the playground, the application will give us an unauthorized error:

GraphQL Query:

{
  mine {
    id
    name
    phone
  }
}
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "errors": [
    {
      "message": "Unauthorized",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "mine"
      ]
    }
  ],
  "data": null
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

GraphQL Query:

{
  mine {
    id
    name
    phone
  }
}
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "data": {
    "mine": {
      "id": 1,
      "name": "Bob",
      "phone": "001"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }),
})
Enter fullscreen mode Exit fullscreen mode
// 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]
Enter fullscreen mode Exit fullscreen mode

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,
      }))
    ),
  })
),
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

Headers:

{
  "authorization": "001"
}
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "data": {
    "createCats": [
      {
        "id": 1,
        "name": "Mittens",
        "age": 4
      },
      {
        "id": 2,
        "name": "Fluffy",
        "age": 3
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's use the cats query to confirm the data in the database again:

GraphQL Query:

{
  cats {
    id
    name   
    age
  }
}
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "data": {
    "cats": [
      {
        "id": 1,
        "name": "Mittens",
        "age": 4
      },
      {
        "id": 2,
        "name": "Fluffy",
        "age": 3
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
        }))
      ),
    })
  ),
})
Enter fullscreen mode Exit fullscreen mode
// 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
    }),
})
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
        }
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's try querying the cats of the current user:

GraphQL Query:

{
  mine {
    name
    cats {
      id
      name
      age
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Headers:

{
  "authorization": "001"
}
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "data": {
    "mine": {
      "name": "Bob",
      "cats": [
        {
          "id": 1,
          "name": "Mittens",
          "age": 4
        },
        {
          "id": 2,
          "name": "Fluffy",
          "age": 3
        }
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 the Drizzle table as the GraphQL 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

Top comments (0)