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
Next, initialize a TypeScript project:
npm init -y
npm install typescript @types/node --save-dev
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"]
}
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
Next, in package.json
add the following properties:
{
"ts-standard": {
"noDefaultIgnore": false,
"ignore": [
"dist"
],
"project": "./tsconfig.json",
"report": "stylish"
}
}
Whenever you want to lint the project, just run the following command:
npx ts-standard --fix
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')
})
})
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 }
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()
})
})
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()
}
})
)
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)
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')
})
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)