DEV Community

Cover image for How to Build a Type-safe GraphQL API using Pothos and Kysely
Francisco Mendes
Francisco Mendes

Posted on

How to Build a Type-safe GraphQL API using Pothos and Kysely

Introduction

There are two popular ways to create a GraphQL api, schema-first and code-first. If you prefer to use the schema-first approach in a TypeScript project, we can always use codegen from the api schema. However, there are a thousand and one ways to create an api using the code-first approach.

And to make the choice even more complicated, in the JavaScript community we have a lot of libraries when compared to other communities. But a pattern I've seen in the TypeScript community lately is the popularization of automatic data type inference because it brings a better development experience.

What will we build today?

In today's article we are going to create a GraphQL api using the Koa framework together with the GraphQL Yoga library and Pothos. In addition, we will use Kysely, which is a query builder entirely written in TypeScript.

Getting Started

As a first step, create a project directory and navigate into it:

mkdir gql-ts-api
cd gql-ts-api
Enter fullscreen mode Exit fullscreen mode

Next, initialize a TypeScript project:

npm init -y
npm install typescript @types/node --save-dev
Enter fullscreen mode Exit fullscreen mode

Next, create a tsconfig.json file and add the following configuration to it:

{
  "compilerOptions": {
    "target": "ESNext",
    "lib": ["ESNext"],
    "module": "ESNext",
    "rootDir": "src",
    "moduleResolution": "node",
    "baseUrl": ".", 
    "types": ["node"],
    "resolveJsonModule": true,
    "allowJs": true,
    "outDir": "dist",
    "removeComments": true,
    "isolatedModules": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
Enter fullscreen mode Exit fullscreen mode

Now we can install the necessary dependencies:

npm install koa graphql @graphql-yoga/node @pothos/core @pothos/plugin-simple-objects kysely better-sqlite3 --save
npm install @types/koa @types/better-sqlite3 ts-standard --save-dev
Enter fullscreen mode Exit fullscreen mode

Next, in package.json add the following properties:

{
  "ts-standard": {
    "noDefaultIgnore": false,
    "ignore": [
      "dist"
    ],
    "project": "./tsconfig.json",
    "report": "stylish"
  }
}
Enter fullscreen mode Exit fullscreen mode

Whenever you want to lint the project, just run the following command:

npx ts-standard --fix
Enter fullscreen mode Exit fullscreen mode

Create the Database Schema

With our project set up we can start by defining the schema of our finger base and for that we are going to create a table called dog that will contain columns such as name and breed. This way:

// @/src/db/index.ts
import {
  Kysely,
  SqliteDialect,
  Generated
} from 'kysely'
import SQLite from 'better-sqlite3'

interface DogTable {
  id: Generated<number>
  name: string
  breed: string
}

interface Database {
  dog: DogTable
}

export const db = new Kysely<Database>({
  dialect: new SqliteDialect({
    database: new SQLite('dev.db')
  })
})
Enter fullscreen mode Exit fullscreen mode

In today's article I'm not going to perform migrations with Kysely so I recommend that you create a table called dog along with the properties that were defined earlier.

Creating a Schema Builder

The SchemaBuilder class is used to create types that will be stiched into a GraphQL schema. And the types that are defined in it are inferred in the resolvers, such as the context, in addition to this it also allows us to register the plugins we need.

// @/src/builder.ts
import SchemaBuilder from '@pothos/core'
import SimpleObjectsPlugin from '@pothos/plugin-simple-objects'

import { db } from './db'

interface Root<T> {
  Context: T
}

export interface Context {
  db: typeof db
}

const builder = new SchemaBuilder<Root<Context>>({
  plugins: [SimpleObjectsPlugin]
})

builder.queryType({})
builder.mutationType({})

export { builder }
Enter fullscreen mode Exit fullscreen mode

Defining some types

We can now define some types in the schema of our GraphQL api, first we will create an object that will represent some information about the data that will be returned (which in the case of this article is shared between several resolvers):

// @/src/schema/typeDefs.ts
import { builder } from '../builder'

export const DogObjectType = builder.simpleObject('CreateDogResponse', {
  fields: (t) => ({
    id: t.id(),
    name: t.string(),
    breed: t.string()
  })
})

export const DogObjectInput = builder.inputType('DogObjectInput', {
  fields: (t) => ({
    name: t.string({ required: true }),
    breed: t.string({ required: true }),
    id: t.int()
  })
})
Enter fullscreen mode Exit fullscreen mode

In the code above we defined objects without having defined any data types, but we still have type-safety.

Defining some fields

The next step is to add some fields to our schema, such as queries and mutations. In each of the resolvers we will get the object from the database through the context and we will perform the necessary operations.

We will also use the types that were created to define the returns of each of the resolvers as well as the arguments.

// @/src/schema/resolvers.ts
import { builder } from '../builder'

import { DogObjectType, DogObjectInput } from './typeDefs'

builder.queryField('getDogs', (t) =>
  t.field({
    type: [DogObjectType],
    resolve: async (root, args, ctx) => {
      return await ctx.db.selectFrom('dog').selectAll().execute()
    }
  })
)

builder.queryField('getDog', (t) =>
  t.field({
    type: DogObjectType,
    args: {
      id: t.arg.int({ required: true })
    },
    resolve: async (root, args, ctx) => {
      return await ctx.db.selectFrom('dog').selectAll().where('id', '=', args.id).executeTakeFirstOrThrow()
    }
  })
)

builder.mutationField('createDog', (t) =>
  t.field({
    type: DogObjectType,
    args: {
      input: t.arg({
        type: DogObjectInput,
        required: true
      })
    },
    resolve: async (root, args, ctx) => {
      return await ctx.db.insertInto('dog').values({
        name: args.input.name,
        breed: args.input.breed
      }).returningAll().executeTakeFirstOrThrow()
    }
  })
)

builder.mutationField('updateDog', (t) =>
  t.field({
    type: DogObjectType,
    args: {
      input: t.arg({
        type: DogObjectInput,
        required: true
      })
    },
    resolve: async (root, args, ctx) => {
      const data = {
        id: args.input.id as number,
        name: args.input.name,
        breed: args.input.breed
      }
      return await ctx.db.insertInto('dog').values(data)
        .onConflict((oc) => oc.column('id').doUpdateSet(data))
        .returningAll().executeTakeFirstOrThrow()
    }
  })
)

builder.mutationField('removeDog', (t) =>
  t.field({
    type: DogObjectType,
    args: {
      id: t.arg.int({ required: true })
    },
    resolve: async (root, args, ctx) => {
      return await ctx.db.deleteFrom('dog').where('id', '=', args.id).returningAll().executeTakeFirstOrThrow()
    }
  })
)
Enter fullscreen mode Exit fullscreen mode

We already have a lot of things related to the GraphQL schema defined, however we still need to compile our code-first schema into something that our GraphQL server can interpret.

// @/src/schema/index.ts
import path from 'path'
import fs from 'fs'

import { printSchema, lexicographicSortSchema } from 'graphql'

import { builder } from '../builder'

import './resolvers'

export const schema = builder.toSchema({})

const schemaAsString = printSchema(lexicographicSortSchema(schema))
fs.writeFileSync(path.join(process.cwd(), './src/schema/schema.gql'), schemaAsString)
Enter fullscreen mode Exit fullscreen mode

Creating the GraphQL Server

Last but not least, we just need to create the api entry file that will contain the GraphQL server configuration, to which we will add the schema that was created by us and we will add the Kysely instance to the context of our api.

// @/src/main.ts
import Koa from 'koa'
import { createServer } from '@graphql-yoga/node'

import { schema } from './schema'
import { Context } from './builder'
import { db } from './db'

const app = new Koa()

const graphQLServer = createServer<Koa.ParameterizedContext>({
  schema,
  context: (): Context => ({ db })
})

app.use(async (ctx) => {
  const response = await graphQLServer.handleIncomingMessage(ctx.req, ctx)
  ctx.status = response.status
  response.headers.forEach((value, key) => {
    ctx.append(key, value)
  })
  ctx.body = response.body
})

app.listen(4000, () => {
  console.log('Running a GraphQL API server at http://localhost:4000/graphql')
})
Enter fullscreen mode Exit fullscreen mode

Conclusion

As usual, I hope you enjoyed the article and that it helped you with an existing project or simply wanted to try it out.

If you found a mistake in the article, please let me know in the comments so I can correct it. Before finishing, if you want to access the source code of this article, I leave here the link to the github repository.

Top comments (0)