DEV Community

dekimasoon
dekimasoon

Posted on • Updated on

GraphQL API in 43 minutes with TypeScript, Prisma and PlanetGraphQL

Introduction

If you are reading this article, you are probably familiar with GraphQL and TypeScript (and perhaps Prisma). However, I doubt that many of you have heard of PlanetGraphQL.

PlanetGraphQL is a library aimed at quickly creating feature-rich and flexible GraphQL APIs when used in combination with TypeScript and Prisma. In this tutorial, I hope to give you an overview of PlanetGraphQL by creating a GraphQL API for a simple TODO app.

I assume readers have a basic understanding of Node.js and TypeScript. I think that there are some of you who have never actually used GraphQL or Prisma, so I will explain these in the tutorial along with PlanetGraphQL.

The full source code can be found in this repository

Creating a project

node.js (v16 or higher) must be installed

First, create an empty TypeScript project.

mkdir tutorial && cd tutorial
npm init --yes
npm install typescript ts-node --save-dev
Enter fullscreen mode Exit fullscreen mode

Next, create tsconfig.json and configure as follows:

touch tsconfig.json
Enter fullscreen mode Exit fullscreen mode
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "lib": ["es2021"],
    "module": "commonjs",
    "target": "es2021",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Add Prisma and PlanetGraphQL to your dependencies.

npm install prisma @planet-graphql/core
Enter fullscreen mode Exit fullscreen mode

Prepare the Prisma environment with the Prisma initialization command.

npx prisma init --datasource-provider sqlite
Enter fullscreen mode Exit fullscreen mode

This time, we will use sqlite to simplify the explanation and environment construction.
Of course, you can also use PostgreSQL or MySQL.

Defining the DB schema

In this tutorial, we are going to create an API for a simple TODO application with the following functionality:

  • Users can create and edit their own tasks
  • Users can list their own tasks
  • In addition to the above, admin users can list all users and tasks for each user

This time, let's simply define the User and Task tables.
In Prisma, model definitions are described in the schema.prisma file.
Please add the following contents to prisma/schema.prisma.

// ...

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  tasks     Task[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Task {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  status    String
  dueAt     DateTime 
  user      User     @relation(fields: [userId], references: [id])
  userId    Int
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
Enter fullscreen mode Exit fullscreen mode

Creation and execution of migration files

Prisma has a DB schema migration feature.
Let's create a migration file with the following command.

npx prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

If the command succeeds, the file prisma/migrations/${yyyyMMddHHmmss}_init/migration.sql will be created. This contains the SQL generated by Prisma. At the same time the SQL file is created, it is also applied to the DB, and the User table and Task table are created.

Adding a generator for PlanetGraphQL

Generator is the feature of Prisma. By using a generator, you can generate the corresponding JSON Schema or TypeScript type definition based on the model definition described in schema.prisma.

If you look at schema.prisma again, you will notice the following description:

generator client {
  provider = "prisma-client-js"
}
Enter fullscreen mode Exit fullscreen mode

This is the generator definition for generating the type definitions needed by the PrismaClient to call Prisma from TypeScript.
PlanetGraphQL also needs a generator to generate the type definition for TypeScript, the same as PrismaClient. Add the following definition to schema.prisma.

generator pg {
  provider = "planet-graphql"
}
Enter fullscreen mode Exit fullscreen mode

After adding it, execute the following command.
Any generators configured in schema.prisma will be re-run to generate the type definitions required by PlanetGraphQL.

npx prisma generate
Enter fullscreen mode Exit fullscreen mode

Setting up GraphQL API server

Now that the Prisma side is ready, let's implement a GraphQL API server using PlanetGraphQL.
First, convert the Prisma model to a GraphQL object type using PlanetGraphQL. Create an index.ts file and fill it as follows.

touch index.ts
Enter fullscreen mode Exit fullscreen mode
import { dmmf, getPGBuilder, getPGPrismaConverter, PrismaTypes } from '@planet-graphql/core'

const pg = getPGBuilder<{ Prisma: PrismaTypes }>()
const pgpc = getPGPrismaConverter(pg, dmmf)
const { objects } = pgpc.convertTypes()
Enter fullscreen mode Exit fullscreen mode

The pgpc.convertTypes method return value objects contains User and Task, which are GraphQL object types corresponding to Prisma models. Let's use objects.User in it to define a usersQuery that returns a list of users. Please add the following contents to index.ts.

import { PrismaClient } from '@prisma/client'

// ...

const prisma = new PrismaClient({ log: ['query'] })
const usersQuery = pg.query({
  name: 'users',
  field: (b) =>
    b
      .object(() => objects.User)
      .list()
      .resolve(() => prisma.user.findMany())
})
Enter fullscreen mode Exit fullscreen mode

Here we implement the following:

  • Get PrismaClient instance with new PrismaClient()
  • Defines a GraphQL query with the pg.query method
  • b.object(() => objects.User) defines that the return type of UsersQuery is User
  • list() changes the return type to List type of User
  • resolve(() => prisma.user.findMany()) defines what to do when UsersQuery is actually called. Here, PrismaClient is used to retrieve all user records that exist in the DB.

Let's start up the GraphQL API server now. Since PlanetGraphQL is just a GraphQL Schema Builder, a separate GraphQL Server is required to launch it as an API server. In this tutorial, we will use graphql-yoga, the GraphQL Server that is said to be trendy these days. Of course, you can also use other libraries such as apollo-server, which is the most popular and well-known.

npm install @graphql-yoga/node
Enter fullscreen mode Exit fullscreen mode

Also add the following to index.ts.

import { createServer } from '@graphql-yoga/node'

// ...

const server = createServer({
  schema: pg.build([usersQuery]),
  maskedErrors: false,
})
server.start()
Enter fullscreen mode Exit fullscreen mode

Let's add the startup script to package.json and run it.

 {
   "scripts": {
+    "start": "ts-node index.ts",
     "test": "echo \"Error: no test specified\" && exit 1"
   },
 }
Enter fullscreen mode Exit fullscreen mode
npm run start
Enter fullscreen mode Exit fullscreen mode

Accessing http://localhost:4000/graphql opens an IDE for developers called graphiql. Let's call the usersQuery we just implemented on graphiql. Write the following query in the left pane of graphiql and press the execute button.

query {
  users {
    id
    name
  }
}
Enter fullscreen mode Exit fullscreen mode

Since the DB is empty, the following results would be returned.

{
  "data": {
    "users": []
  }
}
Enter fullscreen mode Exit fullscreen mode

Adding records to DB

If the DB is empty, it is not possible to sufficiently check the operation, so let's create a seed script to put data into the DB. You can execute SQL directly, but since this is a good opportunity, let's create data via PrismaClient. We will install facker.js for creating test data.

npm install @faker-js/faker --save-dev
Enter fullscreen mode Exit fullscreen mode

Create a seed.ts and add a script that creates users and tasks for each user.

touch seed.ts
Enter fullscreen mode Exit fullscreen mode
import { PrismaClient } from '@prisma/client'
import { faker } from '@faker-js/faker'

const prisma = new PrismaClient()

async function clean() {
  await prisma.task.deleteMany()
  await prisma.user.deleteMany()
}

async function createUsersAndTasks(userCount = 100, taskCount = 10) {
  for (let userId of [...Array(userCount).keys()]) {
    const firstName = faker.name.firstName()
    await prisma.user.create({
      data: {
        id: userId,
        name: faker.name.fullName({ firstName }),
        email: faker.internet.email(firstName),
        tasks: {
          create: [...Array(taskCount).keys()].map((taskId) => ({
            id: userId * userCount + taskId,
            title: faker.lorem.sentence(),
            content: faker.lorem.paragraph(),
            status: faker.helpers.arrayElement(['new', 'in_progress', 'done']),
            dueAt: faker.date.future()
          }))
        }
      }
    })
  }
}

async function seed() {
  await clean()
  await createUsersAndTasks()
}

seed()
Enter fullscreen mode Exit fullscreen mode

Prisma provides the function to call the seed command set in package.json with prisma db seed. Please add the following settings to package.json and execute the command.

 {
   "scripts": {
     "start": "ts-node index.ts",
     "test": "echo \"Error: no test specified\" && exit 1"
   },
+  "prisma": {
+    "seed": "ts-node seed.ts"
+  },
   "keywords": [],
 }
Enter fullscreen mode Exit fullscreen mode
npx prisma db seed
Enter fullscreen mode Exit fullscreen mode

Start up the API server again and run the previous query.
If the seed is successful, you should get a list of users this time.

npm run start
Enter fullscreen mode Exit fullscreen mode
query {
  users {
    id
    name
  }
}
Enter fullscreen mode Exit fullscreen mode

Getting Relations

Let's change the previous query so that we can also retrieve tasks for each user at one time.

query {
  users {
    id
    name
    tasks {
      id
      title
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

When you call the above query, you should get an error Cannot return null for non-nullable field User.tasks. Let's change the implementation of usersQuery slightly to eliminate this error.

 const usersQuery = pg.query({
   name: 'users',
   field: (b) =>
     b
       .object(() => objects.User)
       .list()
-      .resolve(() => prisma.user.findMany())
+      .resolve(() => prisma.user.findMany({
+        include: { tasks: true }
+      }))
 })
Enter fullscreen mode Exit fullscreen mode

Restart the server and run the query again, this time you should also get all per-user tasks. By passing { include: { tasks: true } }, the tasks associated with the user are also retrieved from the DB.

We would like to solve all problems with this, but there is a performance problem with always passing { include: { tasks: true } }. This is because tasks are selected from the DB even when they are not needed. Ideally, you should pass { include: { tasks: true } } when you need to retrieve tasks, and not pass it when you don't.

Let's solve this problem using PlanetGraphQL's prismaArgs. let's change the implementation of usersQuery a little bit.

 const usersQuery = pg.query({
   name: 'users',
   field: (b) =>
     b
       .object(() => objects.User)
       .list()
-      .resolve(() => prisma.user.findMany({
-        include: { tasks: true }
-      }))
+      .resolve(({ prismaArgs }) => prisma.user.findMany(prismaArgs))
 })
Enter fullscreen mode Exit fullscreen mode

You should still get tasks for each user after the change. The prismaArgs received by the resolve method callback contains an include clause automatically generated from the GraphQL query.

If your query is like this:

query {
  users {
    id
    name
    tasks {
      id
      title
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The value of prismaArgs will be { include: { tasks: true } }. On the other hand, if the tasks field is not specified as follows:

query {
  users {
    id
    name
  }
}
Enter fullscreen mode Exit fullscreen mode

The value of prismaArgs will be empty.

Filtering and Sorting

Prisma has filtering and sorting features similar to SQL. For example, if you want to retrieve users whose name contains "e", you can write:

const users = await prisma.user.findMany({
  where: {
    name: { contains: "e" }
  }
})
Enter fullscreen mode Exit fullscreen mode

If you want to retrieve the 11th through 20th user, you can write:

const users = await prisma.user.findMany({
  take: 10,
  skip: 10,
})
Enter fullscreen mode Exit fullscreen mode

Let's use PlanetGraphQL to add the offset-based pagination feature to usersQuery. Modify the usersQuery as follows.

 const usersQuery = pg.query({
   name: 'users',
   field: (b) =>
     b
       .object(() => objects.User)
       .list()
+      .args((b) => ({
+        take: b.int().default(10),
+        skip: b.int().default(0),
+      }))
-      .resolve(({ prismaArgs }) => prisma.user.findMany(prismaArgs))
+      .resolve(({ args, prismaArgs }) => prisma.user.findMany({
+        ...prismaArgs,
+        take: args.take,
+        skip: args.skip,
+      }))
 })
Enter fullscreen mode Exit fullscreen mode

After restarting the server, you should be able to call usersQuery passing any take and skip as follows:

query {
  users(take: 10, skip: 10) {
    id
    name
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's optimize the previous implementation slightly. Modify it as follows:

 const usersQuery = pg.query({
   name: 'users',
   field: (b) =>
     b
       .object(() => objects.User)
       .list()
-      .args((b) => ({
+      .prismaArgs((b) => ({
         take: b.int().default(10),
         skip: b.int().default(0),
       }))
-      .resolve(({ args, prismaArgs }) => prisma.user.findMany({
-        ...prismaArgs,
-        take: args.take,
-        skip: args.skip,
-      }))
+      .resolve(({ prismaArgs }) => prisma.user.findMany(prismaArgs))
 })
Enter fullscreen mode Exit fullscreen mode

You should be able to call call usersQuery by passing any take and skip as before.

Using the prismaArgs method instead of the args method simplified the implementation a bit. Whether you use the args method or the prismaArgs method, the generated GraphQL schema stay s the same. The only difference is whether it is passed as args or prismaArgs at resolve time.

For values that you want to pass directly to PrismaClient, it may be simpler to use the prismaArgs method.

Introducing PlanetGraphQL ArgBuilder

In the previous example, the args were simple, such as take and skip, so you could define those by yourself. But what if you want to define more complex args? In such a case, the PGArgBuilder provided by PlanetGraphQL is very useful.
Try changing the usersQuery as follows and restart the server.

+ const { args } = pgpc.convertBuilders()

  const usersQuery = pg.query({
    name: 'users',
    field: (b) =>
      b
        .object(() => objects.User)
        .list()
-       .prismaArgs((b) => ({
-        take: b.int().default(10),
-         skip: b.int().default(0),
-       }))
+       .prismaArgs(() => args.findManyUser.build())
        .resolve(({ prismaArgs }) => prisma.user.findMany(prismaArgs))
  })
Enter fullscreen mode Exit fullscreen mode

Then a bunch of args will be added to usersQuery. For example, it becomes possible to call usersQuery as follows:

query {
  users(
    where: {
      email: { contains: "e" },
      tasks: { some: { status: { equals: "done" } } }
    },
    orderBy: [ { updatedAt: desc }, { id: desc } ],
    take: 10,
  ) {
    id
    name
    tasks {
      status
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

By calling the pgpc.convertBuilders method, you can receive PGArgBuilders corresponding to PrismaClient's findManyUser, findUniqueTask, etc. By using PGArgBuilder, it is possible to convert PrismaClient's findManyUser or findUniqueTask to GraphQL args.

Additionally, PGArgBuilder can adjust its contents using the edit method. For example, let's edit as follows:

 const usersQuery = pg.query({
   name: 'users',
   field: (b) =>
     b
       .object(() => objects.User)
       .list()
       .auth(({ context }) => context.isAdmin, { strict: true })
-      .prismaArgs(() => args.findManyUser.build())
+      .prismaArgs(() => args.findManyUser.edit((f) => ({
+        where: f.where.edit((f) => ({
+          email: f.email.select('StringFilter').edit((f) => ({
+            equals: f.equals,
+            contains: f.contains,
+          })),
+          tasks: f.tasks.edit((f) => ({
+            some: f.some.edit((f) => ({
+              status: f.status
+                .select('String')
+                .validation((schema) => schema.regex(/new|in_progress|done/)),
+            }))
+          })),
+        })),
+        orderBy: f.orderBy,
+        take: f.take.default(10),
+        skip: f.skip.default(0),
+      })).build({ type: true }))
       .resolve(({ prismaArgs }) => prisma.user.findMany(prismaArgs))
Enter fullscreen mode Exit fullscreen mode

Here, we have made the following adjustments:

  • Only use the part of the args provided in findManyUser that we think is acceptable to expose as GraphQL API
  • Validation is set so that values other than new, in_progress, and done are not accepted in the task status filtering conditions
  • Set default values for take and skip

As you can see, you can easily implement sophisticated filtering and sorting functions as a GraphQL API. It is also nice that type completion works and you can fully benefit from TypeScript.

Customizing Object Types

So far we have used objects.User generated from Prisma's schema as is, but let's customize this User. Please add the following contents to index.ts.

+ const user = pgpc.redefine({
+   name: 'User',
+   fields: (f, b) => ({
+     ...f,
+     taskCount: b.int()
+   }),
+   relations: () => getRelations('User'),
+ })

+ const task = pgpc.redefine({
+   name: 'Task',
+   fields: (f) => {
+     const { user, ...rest } = f
+     return { ...rest }
+   },
+   relations: () => getRelations('Task'),
+ })

+ user.implement((f) => ({
+   taskCount: f.taskCount.resolve((params) => {
+     const user = params.source
+     return prisma.task.count({
+       where: {
+         userId: user.id,
+       }
+     })
+   })
+ }))

- const { objects } = pgpc.convertTypes()
+ const { objects, getRelations } = pgpc.convertTypes({
+   User: () => user,
+   Task: () => task,
+ })
Enter fullscreen mode Exit fullscreen mode

This might seem complicated. I will try to explain it one by one.

  • Redefining User and Task with pgpc.redefine method
    • In User, we added an Int type field taskCount to get the number of tasks the user has
    • The user field is removed from Task
    • The relations: () => getRelations('User') part will be explained later
  • Implement the taskCount field using the user.implement method
    • For fields that do not exist in the DB, it is necessary to set the resolver and implement how to return the value
    • In this case, the count method of PrismaClient is used to get the number of tasks for the target user
  • The redefined User and Task are passed to the pgpc.convertTypes method in the form of { User: () => user, Task () => task }
    • By doing this, the returned values objects.User and objects.Task will be the redefined User and Task
    • The getRelations method and the relations field in the redefine method are needed to replace the object type of relations with redefined object types.
    • In this example, it is necessary to replace the Task in the User's tasks field with the redefined Task

In summary, the redefine method allows you to add and remove fields from Prisma's schema definition. Please restart the server and make sure you can get taskCount from the usersQuery.

query {
  users {
    id
    name
    taskCount 
  }
}
Enter fullscreen mode Exit fullscreen mode

Resolving the N+1 problem by introducing DataLoader

By adding the taskCount field, we can now get the number of tasks per user. However, there is one problem. The SQL is not optimized and the Select statement to retrieve the number of tasks is being issued for the number of users. This is the so-called N+1 problem.

GraphQL uses a mechanism called DataLoader to solve the N+1 problem, and PlanetGraphQL has built-in DataLoader functionality, so you can easily use dataloader. Modify index.ts as follows:

 user.implement((f) => ({
   taskCount: f.taskCount.resolve((params) => 
     pg.dataloader(params, async (userList) => {
-    const user = params.source
-    return prisma.task.count({
-      where: {
-        userId: user.id,
-      }
-    })
+    return pg.dataloader(params, async (userList) => {
+      const userIds = userList.map((x) => x.id)
+      const resp = await prisma.task.groupBy({
+        by: ['userId'],
+        _count: { _all: true },
+        where: { userId: { in: userIds } },
+      })
+      return userIds.map((id) => resp.find((x) => x.userId === id)?._count._all ?? 0)
+    })
   )
 }))
Enter fullscreen mode Exit fullscreen mode

Try restarting the server and retrieving taskCount from usersQuery. It is clear from the logs that the SQL that was issued for each user has changed to a single SQL as shown below.

SELECT COUNT(*), `main`.`Task`.`userId` FROM `main`.`Task` WHERE `main`.`Task`.`userId` IN (?,?,?,?,?,?,?,?,?,?) GROUP BY `main`.`Task`.`userId` LIMIT ? OFFSET ?
Enter fullscreen mode Exit fullscreen mode

Introducing Relay

In GraphQL, the most common implementation of pagination is a cursor-based implementation called Relay-style. While cursor-based pagination is easy to use, it can be difficult to implement. However, with the combination of Prisma and PlanetGraphQL, it is very easy to create an API that supports the Relay-style.

Let's try to implement an API to obtain a list of tasks in the form of Relay-style. Please add the following to your index.ts.

const tasksQuery = pg.query({
  name: 'tasks',
  field: (b) =>
    b
      .object(() => objects.Task)
      .relay()
      .resolve(({ prismaArgs }) => prisma.task.findMany(prismaArgs))
})
Enter fullscreen mode Exit fullscreen mode

Also, add an argument to the pg.build method so that the newly defined tasksQuery is included in the schema.

 const server = createServer({
-  schema: pg.build([usersQuery]),
+  schema: pg.build([usersQuery, tasksQuery]),
   maskedErrors: false,
 })
Enter fullscreen mode Exit fullscreen mode

This is all that is needed to call tasksQuery in Relay-style.

query {
  tasks(first: 10) {
    edges {
      node {
        id
        title
      }
      cursor
    }
    pageInfo {
      hasNextPage
      hasPreviousPage
      startCursor
      endCursor
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

You can make a Query or field correspond to Relay-style by simply calling the relay method as implemented above. This is because the following processing is implicitly performed in the relay method.

  • Generate GraphQL object types required for Relay-style
  • Set the necessary args (first, last, before, and after) for Relay-style
  • Format values retrieved from DB into Relay-style format
  • Generate a cursor for each node
  • Calculate PageInfo such as hasNextPage and hasPreviousPage

You can also customize the details. By default, tasks are sorted in ascending order by id, but you can change the order using the relayOrderBy method.

 const tasksQuery = pg.query({
   name: 'tasks',
   field: (b) =>
     b
       .object(() => objects.Task)
       .relay()
+      .relayOrderBy({ updatedAt: 'desc' })
       .resolve(({ prismaArgs }) => prisma.task.findMany(prismaArgs))
 })
Enter fullscreen mode Exit fullscreen mode

Also, by calling the relay method, the first, last, before, and after args are set, but the relayArgs method can be used to set default values or validations for those.

 const tasksQuery = pg.query({
   name: 'tasks',
   field: (b) =>
     b
       .object(() => objects.Task)
       .relay()
       .relayOrderBy({ updatedAt: 'desc' })
+      .relayArgs((f) => ({
+        ...f,
+        first: f.first.default(10).validation((schema) => schema.max(100)),
+        last: last: f.last.validation((schema) => schema.max(100)),
+      }))
       .resolve(({ prismaArgs }) => prisma.task.findMany(prismaArgs))
 })
Enter fullscreen mode Exit fullscreen mode

In addition, the relayTotalCount method can be used to add a field to retrieve the TotalCount.

 const tasksQuery = pg.query({
   name: 'tasks',
   field: (b) =>
     b
       .object(() => objects.Task)
       .relay()
       .relayOrderBy([{ updatedAt: 'desc' }, { id: 'desc' }])
       .relayArgs((f) => ({
         ...f,
         first: f.first.default(10).validation((schema) => schema.max(100)),
         last: last: f.last.validation((schema) => schema.max(100)),
       }))
+      .relayTotalCount(() => prisma.task.count())
       .resolve(({ prismaArgs }) => prisma.task.findMany(prismaArgs))
 })
Enter fullscreen mode Exit fullscreen mode

After restarting the server, you should be able to get the totalCount as follows. You will also see that the default values and validations for first and last are in effect.

query {
  tasks {
    totalCount
    edges {
      node {
        id
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Context setting and Authorization

Up to this point, we have not mentioned any authorization controls. Revisiting the API requirements listed at the beginning of the tutorial, it appears that the following modifications are necessary for queries we have implemented so far.

  • Since tasksQuery returns all tasks, it should return only the calling user's own tasks
  • Only the administrator user can get user list, so usersQuery should be called only by the administrator user

To implement authorization control, it is essential to understand a concept called Context in GraphQL. The Context mainly stores information about the caller of the API. For example, the ID of the user who made the execution, the IP address of the caller, and so on.

We need to make sure that a context is generated with each request, which is the responsibility of the GraphQL Server (GraphQL Yoga or Apollo Server), not PlanetGraphQL (GraphQL Schema Builder). Let's add a process to generate a context for each request in the createServer method of GraphQL Yoga.

 const server = createServer({
   schema: pg.build([usersQuery, tasksQuery]),
   maskedErrors: false,
+  context: ({ req }) => ({
+    userId: Number(req.headers['x-user-id'] ?? 0),
+    isAdmin: Boolean(req.headers['x-is-admin'] ?? false),
+  })
 })
Enter fullscreen mode Exit fullscreen mode

Now the x-user-id and x-is-admin values in the request header will be set in the context and will be available in PlanetGraphQL. Since this is a sample, if there is no value in the header, a fixed value will be set.

Next, to make it easier to handle context in PlanetGraphQL, we pass the context type information to PlanetGraphQL. Modify index.ts as follows.

+ type TContext = {
+   userId: number
+   isAdmin: boolean
+ }

- const pg = getPGBuilder<{ Prisma: PrismaTypes }>()
+ const pg = getPGBuilder<{ Context: TContext, Prisma: PrismaTypes }>()
Enter fullscreen mode Exit fullscreen mode

We are now ready for Context. Let's limit the tasks that can be retrieved by tasksQuery to the caller's own tasks. Modify tasksQuery as follows:

 const tasksQuery = pg.query({
   name: 'tasks',
   field: (b) =>
     b
       .object(() => objects.Task)
       .relay()
       .relayOrderBy([{ updatedAt: 'desc' }, { id: 'desc' }])
       .relayArgs((f) => ({
         ...f,
         first: f.first.default(10).validation((schema) => schema.max(100)),
         last: f.last.default(10).validation((schema) => schema.max(100)),
       }))
-      .relayTotalCount(() => prisma.task.count())
+      .relayTotalCount(({ context }) => prisma.task.count({
+        where: { userId: context.userId },
+      }))
-      .resolve(({ prismaArgs }) => prisma.task.findMany(prismaArgs))
+      .resolve(({ context, prismaArgs }) => prisma.task.findMany({
+        ...prismaArgs,
+        where: { userId: context.userId },
+      }))
 })
Enter fullscreen mode Exit fullscreen mode

We used userId in the context to limit the data to be retrieved only to tasks associated with the calling user.

Next, let's modify usersQuery so that it can be called only by the administrator user. Modify usersQuery as follows:

 const usersQuery = pg.query({
   name: 'users',
   field: (b) =>
     b
       .object(() => objects.User)
       .list()
+      .auth(({ context }) => context.isAdmin, { strict: true })
       .prismaArgs(() => args.findManyUser.edit((f) => ({
 })
Enter fullscreen mode Exit fullscreen mode

You can control permissions on a field-by-field basis by returning a boolean of the authorization status in the auth method. Restart the server and then call usersQuery. An error should be returned unless you add { "x-is-admin": true } to the request header.

Finally, remove the { strict: true } to better understand the behavior of the auth method.

 const usersQuery = pg.query({
   name: 'users',
   field: (b) =>
     b
       .object(() => objects.User)
       .list()
-      .auth(({ context }) => context.isAdmin, { strict: true })
+      .auth(({ context }) => context.isAdmin)
       .prismaArgs(() => args.findManyUser.edit((f) => ({
 })
Enter fullscreen mode Exit fullscreen mode

Restart the server and this time an empty array should be returned instead of an error. The default behavior of the auth method is to return a response if there are values that can be returned without authorization. Specifically, the behavior is as follows:

  • If the return value is List type, return an empty array if there is no authorization
  • If the return value is nullable, return null if there is no authorization
  • otherwise an error occurs

Mutation implementation and copy

Finally, let's implement mutations. The first is a mutation that creates a user.
Please add the following to index.ts.

const taskEnum = pg.enum({
  name: 'TaskEnum',
  values: ['new', 'in_progress', 'done']
})

const createTaskInput = pg.input({
  name: 'CreateTaskInput',
  fields: (b) => ({
    title: b.string().validation((schema) => schema.max(100)),
    content: b.string().nullable(),
    status: b.enum(taskEnum),
    dueAt: b.dateTime(),
  })
}).validation((value) => value.title.length > 0 || value.status !== 'new')

const createTaskMutation = pg.mutation({
  name: 'createTask',
  field: (b) =>
    b
      .object(() => task)
      .args((b) => ({
        input: b.input(() => createTaskInput)
      }))
      .resolve(({ context, args }) => prisma.task.create({
        data: {
          ...args.input,
          userId: context.userId
        }
      }))
})
Enter fullscreen mode Exit fullscreen mode

Don't forget to add the newly defined createTaskMutation to the arguments of the pg.build method.

 const server = createServer({
-  schema: pg.build([usersQuery, tasksQuery]),
+  schema: pg.build([usersQuery, tasksQuery, createTaskMutation]),
   maskedErrors: false,
 })
Enter fullscreen mode Exit fullscreen mode

Here, we use pg.input to define the Input type of GraphQL without using PGArgBuilder. We also use pg.enum to define the Enum type of GraphQL so that we can specify the task status with this enum.

Let's call createTask with the following query. A new task associated with the calling user should be created and returned.

mutation {
  createTask(input: {
    title: "some title",
    content: null,
    dueAt: "2030-01-01T12:00:00Z",
    status: new
  }) {
    id
    title
    content
    dueAt
    status
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's also create a Mutation for updating the task. Add the following to index.ts.

const updateTaskInput = createTaskInput.copy({
  name: 'UpdateTaskInput',
  fields: (f, b) => ({
    ...f,
    id: b.int(),
  })
})

const updateTaskMutation = pg.mutation({
  name: 'updateTask',
  field: (b) =>
    b
      .object(() => task)
      .args((b) => ({
        input: b.input(() => updateTaskInput)
      })) 
      .resolve(async ({ context, args}) => {
        await prisma.task.findFirstOrThrow({
          where: {
            id: args.input.id,
            userId: context.userId,
          }
        })
        return prisma.task.update({
          where: {
            id: args.input.id,
          },
          data: args.input
        })
      })
})
Enter fullscreen mode Exit fullscreen mode

Remember to add updateTaskMutation as an argument to the pg.build method.

 const server = createServer({
-  schema: pg.build([usersQuery, tasksQuery, createTaskMutation]),
+  schema: pg.build([usersQuery, tasksQuery, createTaskMutation, updateTaskMutation]),
   maskedErrors: false,
 })
Enter fullscreen mode Exit fullscreen mode

The copy method of CreateTaskInput is used to create an UpdateTaskInput for updating. Since the update requires the ID of the target task, we add an id field to receive the ID. Restart the server and make sure that updateTask can be called.

mutation {
  updateTask(input: {
    id: 0,
    title: "some updated title",
    content: null,
    dueAt: "2030-01-01T12:00:00Z",
    status: new
  }) {
    id
    title
    content
    dueAt
    status
  }
}
Enter fullscreen mode Exit fullscreen mode

File structure

So far, all implementations have been written in index.ts, but in actual applications, it is necessary to consider a more manageable file structure. Here we introduce a recommended file structure as PlanetGraphQL.

  • src/models/*.ts: Define each model
  • src/resolvers/*.ts: Define field resolvers for each model, and Query, Mutation, Subscription associated with each model
  • src/builder.ts: Initialize PlanetGraphQL
  • src/server.ts: Initialize and start the GraphQL server

Please check this repository for the actual source file after the file structure changes.

In conclusion

We hope that you found it simple to implement GraphQL APIs using PlanetGraphQL and Prisma.
PlanetGraphQL is a young project. If you notice anything that could be improved, please submit an issue on Github! Have a nice GraphQL life!

Top comments (0)