DEV Community

loading...

How to create a GraphQL API with Prisma and Nexus

studio_hungry profile image Richard Haines ・15 min read

As part of my learning everything there is to know about Prisma I've decided to write some tutorials for you as much for me. There may be some knowledge gaps and I may not have a great grasp on some things yet, so if you find an error or something that I have explained that is fundamentally wrong, feel free to message me on Twitter @studio_hungry and we can duke it out!

What you will build

You are going to build a GraphQL api backed by a postgres database. This api will hold data for showing different competencies within companies. For example, each company will have roles within their organization (Software Engineer, Cloud Specialist) which require certain skills (Scala, React).

Once complete you will have learnt how to setup your own GraphQL api, how to use Prisma as an ORM layer and how to use Nexus as a means of producing code first GraphQL schemas.

Prerequisites

This tutorial assumes basic knowledge of databases, APIs and how to setup a project and install packages. The tutorial borrows heavily from the awesome docs for Prisma and Nexus.

What is Prisma and Nexus?

Prisma is an ORM (Object-Relational-Mapping) layer that sits on top of your database. It ships with a query builder which allows you to write type safe queries as JavaScript (or TypeScript) objects. It's built to work with TypeScript, has it's own vscode extension and offers a lovely abstraction over SQL databases. For a full (and in depth) rundown of what Prisma is you can check out their docs page, Why Prisma?.

Nexus is a declarative, code first approach to writing GraphQL schemas. It's created by a team at Prisma and works seamlessly with it. Nexus allows you to house your schema and resolver logic in one place and write it with one common language such as JavaScript or TypeScript. You'll learn more about this approach during the tutorial. If you wish to read further you can checkout their excellent docs page Why Nexus?.

Setup the project

Create a new directory in your project location of choice and begin by installing the required packages.

This tutorial will use NPM to run the application, you can use Yarn if you wish.

npm init -y
npm i --save-dev prisma ts-node-dev typescript
npm i @prisma/client apollo-server graphql nexus
Enter fullscreen mode Exit fullscreen mode

The article uses the following versions:

  "devDependencies": {
    "prisma": "^2.17.0",
    "ts-node-dev": "^1.1.1",
    "typescript": "^4.1.5"
  },
  "dependencies": {
    "@prisma/client": "^2.17.0",
    "apollo-server": "^2.21.0",
    "graphql": "^15.5.0",
    "nexus": "^1.0.0"
  }
Enter fullscreen mode Exit fullscreen mode

Next create a tsconfig.json file at your projects root and add the following boilerplate:

{
    "compilerOptions": {
        "target": "ES2018",
        "module": "commonjs",
        "lib": ["esnext"],
        "strict": true,
        "rootDir": ".",
        "outDir": "dist",
        "sourceMap": true,
        "esModuleInterop": true
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally add some build scripts to the package.json

"dev": "ts-node-dev --transpile-only --no-notify api/app.ts",
"build": "tsc"
Enter fullscreen mode Exit fullscreen mode

Now that everything is installed you'll need to create some files and folders. All the code will live in an api folder at the projects root. The structure will look like this:

  • api
    • graphql
    • index.ts
    • app.ts
    • context.ts
    • db.ts
    • schema.ts
    • server.ts

Setup the database

This tutorial uses Postgres as it's database. If you don't already have an instance of Postgres installed on your machine you can grab it from the download page on their site. The default postgres installation comes with the user postgres. This tutorial will assume that user from here onwards, though you can swap that out for your own user if you wish. This tutorial will also use psql for interacting with the database at certain times. psql is a tool for writing SQL queries from the command line, it comes with the installation of Postgres.

Once you have it installed login and create a new database.

// Login
psql -U postgres;

// Create database
CREATE DATABASE companyCompetenceDatabase;

// Confirm database creation
\l;
Enter fullscreen mode Exit fullscreen mode

Adding the Prisma schema data models

To setup Prisma you must first invoke the CLI by running npx prisma then npx primsa init. A new folder will be created at the projects root called prisma which will house a schema.prisma file. A .env file will also be created and pre-populated with a placeholder connection string.

The DATABASE_URL is the connection string that will be used to hook up to your Postgres database. In the .env file change the placeholder to the following, replacing the password for your own, the user for your own if you have a different one and the database name if you choose a different name.

DATABASE_URL="postgresql://postgres:test1234@localhost:5432/companycompetencedatabase?schema=public"
Enter fullscreen mode Exit fullscreen mode

Note that although you gave the name of the database in camelCase when creating it, Postgres converts that to all lower case once the database is created. I have left the creation string as camelCase for readability.

Inside schema.prisma add the following models below the client generator.


// 1-n with trade and role
// company can have many roles
model Company {
  id            Int      @id @default(autoincrement())
  name          String?
  contactPerson String?
  bio           String?
  email         String?
  website       String?
  roles         Role[]
}

// 1-n with skill, n-1 with company
// a role can have many skills
// a company can have many roles
model Role {
  id        Int     @id @default(autoincrement())
  name      String?
  skills    Skill[]
  company   Company? @relation(fields: [companyId], references: [id]) // not in db, used by Prisma to set relation
  companyId Int? // the foreign key, this is in the db
}

// 1-n with role
model Skill {
  id     Int    @id @default(autoincrement())
  name   String?
  role   Role?   @relation(fields: [roleId], references: [id])
  roleId Int?
}
Enter fullscreen mode Exit fullscreen mode

Let's dive into what all this means. The Prisma data modal is a schema that maps to your database schema. In it you define the shape of your data and any relationships that may exist between tables. If you have never worked with SQL databases some of fields may seem confusing. Let's break them down.

In SQL land relationships between tables are defined by foreign keys. These are ids that map to other tables. In the example above you can see that the Role model has a field called companyId, this is a foreign key that will be added to the database and represents the link to the primary key (the id field) in the Company model. In the Company model you can see an array of roles. This represents a collection or list of roles, the many in the 1-many relationship that the Company (1) and Roles (many) can have. The ? next to many of the field denotes that they are nullable.

A more explicit example of a one-many relationship can be viewed below:


model One  {
  id        Int @id @default(autoincrement()) // Primary key
  hasMany   Many[]
}

model Many  {
  id                Int @id @default(autoincrement()) // Primary key
  oneRelationField  One @relation(field: [foreignKey], references: [id]) // Not in db, used by Prisma to describe relation
  foreignKey        Int // The foreign key, this is in the db
}

Enter fullscreen mode Exit fullscreen mode

The foreignKey references the id on the One model, this tells the database that the primary key from One is used as the foreignKey on Many. The foreignKey is designed to allow multiple instances of the value from which it references. Only the foreign key is used in the actual database table, the oneRelationField is used by Prisma when creating relations.


Now that you have created your data model you can run a migration and map the models to the database schema. The following command creates a new SQL migration file named init and runs the migration against the database. This creates the tables in the format described by the models.

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

Using the Nexus objectType

The traditional way to setup a GraphQL schema is to build a schema using the GraphQL Schema Language which is written as a template string. A resolver is also created to resolve the endpoint and return whatever data is requested. In Apollo this is done by creating a typeDefs file and declaring the schema types and queries. Inside the typeDefs file you would create the the GraphQL types (which look quite similar to the Prisma Schema Language (PSL)) and queries inside an exported template string on a GraphQL tag, an example how this might look can be seen below:


exports.typeDefs = gql`
type Query {
    allCharacters: [Character]!
    characterById(_id: ID!): Character
    characterByName(name: String!): Character
}

type Character {
    _id: ID
    name: String!
    house: String
    patronus: String
    bloodStatus: String
    role: String
    school: String
    deathEater: Boolean
    dumbledoresArmy: Boolean
    orderOfThePheonix: Boolean
    ministryOfMagic: Boolean
    alias: String
    wand: String
    boggart: String
    animagus: String
}
`
Enter fullscreen mode Exit fullscreen mode

This file would then be added to every time you wanted to introduce a new data type or query definition, this works so long as the number of data models is small but is not very modular and wouldn't be easy to scale if you were to introduce multiple new data types and queries. Another way to accomplish defining your GraphQL types and schema is to use Nexus and to take a code first approach by giving each type its own file and using the objectType function.

Inside api/graphql create a new file called company.ts and add the following:

import { objectType } from 'nexus';

export const Company = objectType({
    name: 'Company',
    definition(t) {
        t.nonNull.int('id');
        t.string('name');
        t.string('contactPerson');
        t.string('bio');
        t.string('email');
        t.string('website');
        t.int('roleId');
        t.nonNull.list.nonNull.field('roles', {
            type: 'Role',
            resolve: (parent, _, ctx) => {
                return ctx.db.company
                    .findUnique({
                        where: { id: parent.id },
                    })
                    .roles();
            },
        });
    },
});
Enter fullscreen mode Exit fullscreen mode

The objectType function is used to fetch the fields from your schema. It's combined with a resolver and describes the schema models in code. The definition method allows you to define the fields of your object type as well as the relationships.

Let's dissect the relations field to make sure we understand what is happening here.

t.nonNull.list.nonNull.field('roles', {
    type: 'Role',
    resolve: (parent, _, ctx) => {
        return ctx.db.company
            .findUnique({
                where: { id: parent.id },
            })
            .roles();
    },
});
Enter fullscreen mode Exit fullscreen mode

The field is not nullable, that is, it can't be null t.nonNull, it's an array (a list) of values which can also not be null, t.nonNull.list.nonNull. In the field you have given it a name that corresponds to the name used in the schema and set it's model type, Role, you then resolve the relationship. The first argument in the resolver is the parent or root node, this is used to pass information from the parent to the child resolvers. The second is the arguments passed to the resolver and the third the schema context, which we will shortly be covering. The Prisma client (via the context) is used to query for the model and find the record where the id matches. So in this case the query will resolve to find zero or one Company that matches the filter.

The full contents of api/GraphQL

// company.ts
export const Company = objectType({
    name: 'Company',
    definition(t) {
        t.nonNull.int('id');
        t.string('name');
        t.string('contactPerson');
        t.string('bio');
        t.string('email');
        t.string('website');
        t.int('roleId');
        t.nonNull.list.nonNull.field('roles', {
            type: 'Role',
            resolve: (parent, _, ctx) => {
                return ctx.db.company
                    .findUnique({
                        where: { id: parent.id },
                    })
                    .roles();
            },
        });
    },
});

// role.ts
export const Role = objectType({
    name: 'Role',
    definition(t) {
        t.nonNull.int('id');
        t.string('name');
        t.field('company', {
            type: 'Company',
            resolve: (parent, _, ctx) => {
                return ctx.db.role
                    .findUnique({
                        where: { id: parent.id || undefined },
                    })
                    .company();
            },
        });
        t.list.field('skills', {
            type: 'Skill',
            resolve: (parent, _, ctx) => {
                return ctx.db.role
                    .findUnique({
                        where: { id: parent.id || undefined },
                    })
                    .skills();
            },
        });
    },
});

// skill.ts
export const Skill = objectType({
    name: 'Skill',
    definition(t) {
        t.nonNull.int('id');
        t.string('name');
        t.field('role', {
            type: 'Role',
            resolve: (parent, _, ctx) => {
                return ctx.db.skill
                    .findUnique({
                        where: { id: parent.id || undefined },
                    })
                    .role();
            },
        });
    },
});
Enter fullscreen mode Exit fullscreen mode

Finally, export the types from the api/GraphQL/index.ts file:

export * from './company';
export * from './role';
export * from './skill';
Enter fullscreen mode Exit fullscreen mode

Creating the server

Begin by opening api/schema.ts. This will be where you make a schema with Nexus. You will pass in the defined types from the api/GraphQL/index.ts file and set the output paths. These paths will be where Nexus writes the generated TypeScript definitions that are derived from the Prisma schema. It will also output a GraphQL schema, this file should not be edited directly.

schema.ts

import { makeSchema } from 'nexus';
import { join } from 'path';
// It is considered best practice to pass your types directly from a "star import" like we've done above. Under the hood, Nexus will unwrap the types. This prevents from constantly having to manually export & import every single type of your API.
import * as types from './graphql';

export const schema = makeSchema({
    // GraphQL types that will be used to construct your GraphQL schema.
    types,
    outputs: {
        // Output path to where nexus should write the generated TypeScript definition types derived from your schema. This is mandatory to benefit from Nexus' type-safety.
        typegen: join(__dirname, '..', 'nexus-typegen.ts'),
        // Output path to where nexus should write the SDL (schema definition language) version of your GraphQL schema.
        schema: join(__dirname, '..', 'schema.GraphQL'),
    },
    contextType: {
        // Path to the module where the context type is exported
        module: join(__dirname, './context.ts'),
        // Name of the export in that module
        export: 'Context',
    },
});
Enter fullscreen mode Exit fullscreen mode

Open the server.ts file and add the following:

import { ApolloServer } from 'apollo-server';
import { schema } from './schema';
import { context } from './context';

export const server = new ApolloServer({ schema, context });
Enter fullscreen mode Exit fullscreen mode

Here you are instantiating the ApolloServer instance and exporting it while passing in the schema and context. Next you will create the context.

Finally open app.ts and add the following, which will start the server when the dev script is run.

import {server} from './server';

server.listen().then(({url}) => {
    console.log(`Server ready at ${url}`)
})
Enter fullscreen mode Exit fullscreen mode

The GraphQL context

The GraphQL context is an object which is shared across all the resolvers, the GraphQL server creates and destroys a new instance between each request. To create a context open the api/db.ts file.

import { PrismaClient } from '@prisma/client';

export const db = new PrismaClient();
Enter fullscreen mode Exit fullscreen mode

Then the api/context.ts file

import { db } from './db';
import { PrismaClient } from '@prisma/client';

export interface Context {
    db: PrismaClient;
}

export const context = {
    db,
};
Enter fullscreen mode Exit fullscreen mode

Now the context has been created and passed to the GraphQL server. The context can be used to store and/or check information too. One such use case might be an authenticated user, are they authorized to do a certain mutation? In any case, your context is a simple object which will suffice for now.

Writing queries and mutations

The API isn't much good if you can't make any requests against it. Begin by opening back up the companies.ts file. Under the objectType creation of the Company type, add the new Query and Mutation types.

export const Company = objectType({
    name: 'Company',
    definition(t) {
        t.nonNull.int('id');
        t.string('name');
        t.string('contactPerson');
        t.string('bio');
        t.string('email');
        t.string('website');
        t.int('roleId');
        t.nonNull.list.nonNull.field('roles', {
            type: 'Role',
            resolve: (parent, _, ctx) => {
                return ctx.db.company
                    .findUnique({
                        where: { id: parent.id },
                    })
                    .roles();
            },
        });
    },
});

export const CompanyQuery = extendType({
    type: 'Query',
    definition(t) {
        // get all companies
        t.list.field('companies', {
            type: 'Company',
            resolve(_root, _args, ctx) {
                return ctx.db.company.findMany();
            },
        });
        // get company by id
        t.field('company', {
            type: 'Company',
            args: {
                id: nonNull(intArg()),
            },
            resolve(_root, args, ctx) {
                return ctx.db.company.findUnique({
                    where: { id: args.id },
                });
            },
        });
        t.list.field('roles', {
            type: 'Role',
            resolve(_root, _args, ctx) {
                return ctx.db.role.findMany();
            },
        });
    },
});

export const CompanyMutation = extendType({
    type: 'Mutation',
    definition(t) {
        // create a new company
        t.nonNull.field('createCompany', {
            type: 'Company',
            args: {
                id: intArg(),
                name: nonNull(stringArg()),
                contactPerson: nonNull(stringArg()),
                bio: nonNull(stringArg()),
                email: nonNull(stringArg()),
                website: nonNull(stringArg()),
                roleId: intArg(),
                roles: arg({
                    type: list('RoleInputType'),
                }),
            },
            resolve(_root, args, ctx) {
                return ctx.db.company.create({
                    data: {
                        name: args.name,
                        contactPerson: args.contactPerson,
                        bio: args.bio,
                        email: args.email,
                        website: args.website,
                        roles: {
                            connect: [{ id: args.roleId || undefined }],
                        },
                    },
                });
            },
        });
        // update a company by id
        t.field('updateCompany', {
            type: 'Company',
            args: {
                id: nonNull(intArg()),
                name: stringArg(),
                contactPerson: stringArg(),
                bio: stringArg(),
                email: stringArg(),
                website: stringArg(),
                roleId: intArg(),
                roles: arg({
                    type: list('RoleInputType'),
                }),
            },
            resolve(_root, args, ctx) {
                return ctx.db.company.update({
                    where: { id: args.id },
                    data: {
                        name: args.name,
                        contactPerson: args.contactPerson,
                        bio: args.bio,
                        email: args.email,
                        website: args.website,
                        roles: {
                            connect: [{ id: args.roleId || undefined }],
                        },
                    },
                });
            },
        });
        // delete a company by id
        t.field('deleteCompany', {
            type: 'Company',
            args: {
                id: nonNull(intArg()),
            },
            resolve(_root, args, ctx) {
                return ctx.db.company.delete({
                    where: { id: args.id },
                });
            },
        });
    },
});
Enter fullscreen mode Exit fullscreen mode

Let's dissect that massive block of code. Starting with the Query block, the queries available to you in the playground are defined using much of the same syntax as when the object itself was defined with fields and resolvers.

// get all companies
t.list.field('companies', {
    type: 'Company',
    resolve(_root, _args, ctx) {
        return ctx.db.company.findMany();
    },
});
Enter fullscreen mode Exit fullscreen mode

The query to get all of the companies uses the GraphQL context to run a prisma query and the method findMany() to return all of the companies in the database. The findMany() method can take optional parameters which are filters on the query, for example to return only the first 10 company in the database you could pass an object with the key take and the value 10 like so:

// get all companies
t.list.field('companies', {
    type: 'Company',
    resolve(_root, _args, ctx) {
        return ctx.db.company.findMany({ take: 10 });
    },
});
Enter fullscreen mode Exit fullscreen mode

This value could be passed into the query as an argument too instead of being hardcoded. Let's look at passing arguments to queries.

To get a company by it's id you'll make use of the second paramter of the resolver function, args.

// get company by id
t.field('company', {
    type: 'Company',
    args: {
        id: nonNull(intArg()),
    },
    resolve(_root, args, ctx) {
        return ctx.db.company.findUnique({
            where: { id: args.id },
        });
    },
});
Enter fullscreen mode Exit fullscreen mode

Here a different method is used on the GraphQL Prisma context, findUnique(). A filter is passed which asks for only companies where it's id matches the id passed in as a parameter. Above the resolver the args types are defined. Here an id is defined as a nonNull integer, that is, a number value that cannot be null. Which makes sense as you wont run this query without actually passing a value to it. If you were to not add the nonNull function the arg would be nullable be default. (Though you can also import nullable from nexus and explicitly state that fact that it is nullable). The roles query works the same way as the get all companies query in that it returns all of the roles related to this company.

Moving onto the mutations and creating a new company in the database.

        // create a new company
        t.nonNull.field('createCompany', {
            type: 'Company',
            args: {
                id: intArg(),
                name: nonNull(stringArg()),
                contactPerson: nonNull(stringArg()),
                bio: nonNull(stringArg()),
                email: nonNull(stringArg()),
                website: nonNull(stringArg()),
                roleId: intArg(),
                roles: arg({
                    type: list('RoleInputType')
                })
            },
            resolve(_root, args, ctx) {
                return ctx.db.company.create({
                    data: {
                        name: args.name,
                        contactPerson: args.contactPerson,
                        bio: args.bio,
                        email: args.email,
                        website: args.website,
                        roles: {
                            connect: [
                                {id: args.roleId || undefined}, 
                            ]
                        }
                    }
                })
            }
        })
Enter fullscreen mode Exit fullscreen mode

Much of this code will now be familiar, the field type is given a logical name (which will be used to run the mutation) and a bunch of args are defined outlining what the resolver can expect and in what format the variables will be passed as. An interesting point to notice here is the roleId, which is the foreign key for the Role type, and the roles arg which uses a custom input type to tell the resolver what type to expect. As the Role isn't a primitive you have to declare a custom type which is used instead. As the plural name suggests, this argument is of type list. The RoleInputType is defined in the role.ts file and defines an id.

The resolver uses the create method on the Prisma context and defines a data object which maps the args passed through to the corresponding object keys. The roles arg uses the connect method and the foreign key to connect the new company to a previously defined role.

Running our queries and mutations in the Playground

In order to actually play about with the queries and mutations run the dev script from the projects root (npm run dev). This will open up a url at localhost:4000. The server has given you a GraphQL playground from which you can run your queries and mutations against the database in real time. This is an excellent way to check how your data behaves in the real world, outside of those console.logs.

Begin by creating a new skill.

mutation CreateSkill {
  createSkill(name: "GraphQL") {
    id
    name
  }
}
Enter fullscreen mode Exit fullscreen mode

Here you are running your createSkill mutation passing in the name of the skill top be created and returning the newly created skills id and name. Next create a role.

mutation CreateRole {
  createRole(name: "Backend Developer" skillId: 1) {
    id
    name
  }
}
Enter fullscreen mode Exit fullscreen mode

The createRole mutation accepts a name argument and an optional skillId, which is the foreign key to the skill you would like to link to the role. In this case you are creating a new Backend Developer role and assigning it the skill of GraphQL via the skills id.

Finally you can create a new company. By taking advantage of the foreign key (roleId) you can link not only a role but certain skills to the company. In this mutation your new company has a Backend Developer role which requires/uses a GraphQL skill.

mutation CreateCompany {
  createCompany(
    name: "My Super Cool Company"
    bio: "A super cool company"
    website: "mycomp.com"
    contactPerson: "Barry McBarry"
    email: "barry@mycomp.com"
    roleId: 1
  ) {
    name
    bio
    website
    contactPerson
    email
    roles {
      name
      skills {
        name
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now that your database has some data in it you can query it to see how that data looks when returned.

# get all companies and their roles and skills
query GetCompanies {
  companies {
    id
    name
    contactPerson
    bio
    email
    website
    roles {
      name
      skills {
        name
      }
    }
  }
}

#get all roles and their skills
query GetRoles {
  roles {
    id
    name
    skills {
      name
    }
  }
}

#get all skills
query GetSkills {
  skills {
    id
    name
  }
}

Enter fullscreen mode Exit fullscreen mode

Conclusion

As a predominantly frontend developer using Prisma and combining it with Nexus for the first time has been a relatively painless experience. Of course much of that is due to the excellent documentation which allows you to get up and running quickly and without fuss. Some areas where I did find myself rather confused was nested mutations and relations in the schema. People learn in different ways, I enjoy quite a lot of visuals with pictures forming a better understanding in my head as opposed to just blocks of text. Perhaps the docs could do with some more video instructions and interactive elements. That being said this was quite a text and code block heavy piece itself!

This was the first of many pieces about my journey learning Prisma and what it can do. This article only touched on some base concepts but I found it very helpful in getting a grasp of what is possible. My take away from this piece is that I need to dive deeper into relationships and how they are used as this seems to be a focal point of what you can do with Prisma.

Hit me up on Twitter @studio_hungry if you fancy a chinwag about any of this.

Discussion (0)

pic
Editor guide