Chapter 2: TypeGraphQL
Strongly Typed Next.js
link TypeGraphQL
By the end of this lesson, developers will be able to:
- Create a GraphQL API schema with TypeGraphQL
- Create a UserResolver for fetching user data
Introduction
Authentication is one of the most challenging tasks for developers just starting with GraphQL. There are a lot of technical considerations, including what ORM would be easy to set up, how to generate secure tokens and hash passwords, and even what HTTP library to use and how to use it.
In this section, we will start building a GraphQL API server. With MongoDB as our database, we learn how to incorporate TypeGraphQL and Typegoose. Let's begin with an overview of these libraries and their dependencies:
- TypeGraphQL: We will use TypeGraphQL to declare our models and their properties.
- Typegoose: Once the models are declared, we will use Typegoose to manage how MongoDB works in the database.
- jsonwebtoken: Once the user is logged in, each subsequent request will include the JWT, allowing the user to access routes, services, and resources that are permitted with that token.
jsonwebtoken
will be used to generate a JWT which will be used to authenticate users.
TypeGraphQL
Starting to incorporate Typescript and GraphQL, we can utilize existing libraries for creating strongly typed schemas. For a brief introduction to TypeGraphQL, be sure to visit the TypeGraphQL documentation.
TypeGraphQL solves many problems for us, like schema validation, authorization and dependency injection, which helps develop GraphQL APIs quickly and easily. TypeGraphQL also integrates with several third party libraries like Typegoose.
link Entities
In order to design a relational database, we need to understand the connections between our models. Let's define an Entity Relationship Diagram, or ERD for our models:
As seen above, each User
can create many Stream
entities. We will continue to define the backend models with TypeGraphQL and Typegoose.
link Installation
First, create a new directory at the root of your project and cd
into it:
mkdir api
cd api
Let's begin by installing each library and their peer dependencies.
npm init -y
TypeGraphQL
npm install typescript type-graphql graphql reflect-metadata
npm install --save-dev @types/node
Typegoose
npm install @typegoose/typegoose mongoose connect-mongo
npm install --save-dev @types/mongoose
Express and JWT
npm install express jsonwebtoken
npm install --save-dev @types/express @types/jsonwebtoken
TypeScript Config
To create a TypeScript configuration file, you can run the following command (similar to npm init -y
):
npx tsc --init
You will receive this output:
Output
message TS6071: Successfully created a tsconfig.json file.
Open your new api/tsconfig.json
file and you will see lots of different options, most of which are commented out.
Replace your existing api/tsconfig.json
file with the following contents:
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"lib": ["dom", "es6", "es2017", "esnext.asynciterable"],
"sourceMap": true,
"outDir": "./dist",
"moduleResolution": "node",
"removeComments": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"resolveJsonModule": true,
"baseUrl": "."
},
"exclude": ["node_modules", "./generated"],
"include": ["./**/*.ts"]
}
TypeScript has gained popularity over the last couple of years. In modern front-end frameworks, TypeScript has become a first-class citizen.
For more information on TypeScript and Visual Studio Code, visit digitalocean.com
link User Entity
Let's prepare our database schema with our first entity, called User
.
Create a new directory and file, api/entity
and api/entity/User.ts
and insert the following:
import { prop as Property, getModelForClass } from "@typegoose/typegoose";
import { ObjectId } from "mongodb";
import { Field, ObjectType } from "type-graphql";
@ObjectType()
export class User {
@Field()
readonly _id: ObjectId;
@Field()
@Property({ required: true })
email: string;
@Property({ required: true })
password: string;
}
export const UserModel = getModelForClass(User);
Each user has two accessible fields: _id
and email
. Passwords are readonly
for security purposes. We will demonstrate how to securely store passwords in an upcoming section.
Decorators
We just wrote our first decorators!
A Decorator is a special kind of declaration that can be attached to a class declaration, method, accessor, property, or parameter. Decorators use the form @expression
, where expression
must evaluate to a function that will be called at runtime with information about the decorated class.
For more information on decorators visit typescriptlang.org
Ref Type
Before going on to the Stream
entity, let's define a Ref
object type for our database. A Ref
is considered to be a manual reference. A manual references is where you save the ObjectId
field of one document in another document as a reference. Then your application can run a second query to return the related data. These references are simple and sufficient for most use cases.
Create a new directory and file, api/types
and api/types/Ref.ts
and insert the following:
import { ObjectId } from "mongodb";
export type Ref<T> = T | ObjectId;
Using manual references is the practice of including one document's ObjectId
field in another document. The application can then issue a second query to resolve the referenced fields as needed.
For nearly every case where you want to store a relationship between two documents, use manual references. The references are simple to create and your application can resolve references as needed. However, if you need to reference documents from multiple collections, consider using DBRefs.
For more information about Database References, visit MongoDB.org
link Stream Entity
Streams are considered to be embedded posts. We reference the User
entity with our Ref
type, and assign them as the stream's author
.
Create a new file, api/entity/Stream.ts
and insert the following:
import { prop as Property, getModelForClass } from "@typegoose/typegoose";
import { ObjectId } from "mongodb";
import { Field, ObjectType } from "type-graphql";
import { User } from "./User";
import { Ref } from "../types/Ref";
@ObjectType()
export class Stream {
@Field()
readonly _id: ObjectId;
@Field()
@Property({ required: true })
title: string;
@Field()
@Property({ required: true })
description: string;
@Field()
@Property({ required: true })
url: string;
@Field(() => User)
@Property({ ref: User, required: true })
author: Ref<User>;
}
export const StreamModel = getModelForClass(Stream);
With User
and Stream
entities defined we can go on to create a new ObjectID scalar.
link ObjectID
Before moving on to the resolvers, we will define an ObjectId
scalar for our schema. This scalar is specific to MongoDB, because an ObjectId
has a unique format, e.g. ObjectId("adaj130jfsdm10")
.
We will see a few examples of working with ObjectId
scalars using Typegoose.
Create a new directory and file, api/schema/
and api/schema/object-id.scalar.ts
and insert the following:
import { GraphQLScalarType, Kind } from "graphql";
import { ObjectId } from "mongodb";
export const ObjectIdScalar = new GraphQLScalarType({
name: "ObjectId",
description: "Mongo object id scalar type",
parseValue(value: string) {
return new ObjectId(value); // value from the client input variables
},
serialize(value: ObjectId) {
return value.toHexString(); // value sent to the client
},
parseLiteral(ast) {
if (ast.kind === Kind.STRING) {
return new ObjectId(ast.value); // value from the client query
}
return null;
},
});
The GraphQLScalarType
code handles parsing objects as strings, and serializing them as hex strings. This is useful for converting ObjectId
properties into string values.
In summary ObjectID("adaj130jfsdm10")
becomes a text string: adaj130jfsdm10
, and vice versa.
link Middleware
MyContext
Before getting started with the resolvers, we will declare a new type: MyContext
, which will be used to infer the current user's session.
Create a new file, api/types/MyContext.ts
and insert the following:
import { Request, Response } from 'express';
export interface MyContext {
req: Request;
res: Response;
}
We will begin to modify MyContext
with each session's userId
, once we create the authentication resolver.
In order to help create the authentication resolver, we will create a middleware that handles checking for the current user's userId
.
isAuth
Let's declare our first middleware, isAuth
(or "isAuthenticated") to make sure the current session contains a logged in user.
Create a new directory and file, api/middleware
and api/middleware/isAuth.ts
and insert the following:
import { MiddlewareFn } from 'type-graphql';
import { MyContext } from '../types/MyContext';
import jwt from 'jsonwebtoken';
const APP_SECRET = process.env.SESSION_SECRET || 'aslkdfjoiq12312';
export const isAuth: MiddlewareFn<MyContext> = async ({ context }, next) => {
const authorization = context.req.headers['authorization'];
try {
const token = authorization?.replace('Bearer ', '')!;
const user = jwt.verify(token, APP_SECRET) as any;
context.res.locals.userId = user.id;
return next();
} catch (err) {
throw new Error(err.message);
}
};
In the above code, we throw an error if the current session contains no userId
property. The isAuth
middleware will be applied to a few resolvers, as we will see in a few moments.
Similar to Express middleware, TypegraphQL allows us to write custom middlewares for each request. So we can add this custom logic before each incoming request.
For more information on
res.locals
, visit the Express.js documentation.
We have one more middleware for handling Typegoose documents, so let's go ahead and add it now.
Typegoose Middleware
Create a new file, api/middleware/typegoose.ts
and insert the following:
import { Model, Document } from 'mongoose';
import { getClassForDocument } from '@typegoose/typegoose';
import { MiddlewareFn } from 'type-graphql';
export const TypegooseMiddleware: MiddlewareFn = async (_, next) => {
const result = await next();
if (Array.isArray(result)) {
return result.map((item) =>
item instanceof Model ? convertDocument(item) : item
);
}
if (result instanceof Model) {
return convertDocument(result);
}
return result;
};
function convertDocument(doc: Document) {
const convertedDocument = doc.toObject();
const DocumentClass = getClassForDocument(doc)!;
Object.setPrototypeOf(convertedDocument, DocumentClass.prototype);
return convertedDocument;
}
In the above code, we convert MongoDB Documents into readable objects. Without this middleware, our Ref
types would not be able to reference other database objects.
link UserResolver
Without further ado, let's create our first resolver called UserResolver
. This resolver will handle any queries related to fetching user data.
Create a new directory and file, api/resolvers/
and api/resolvers/UserResolver.ts
and insert the following:
import { Resolver, Query, UseMiddleware, Arg, Ctx } from 'type-graphql';
import { ObjectId } from 'mongodb';
import { MyContext } from '../types/MyContext';
import { isAuth } from '../middleware/isAuth';
import { User, UserModel } from '../entity/User';
import { ObjectIdScalar } from '../schema/object-id.scalar';
@Resolver(() => User)
export class UserResolver {
@Query(() => User, { nullable: true })
async user(@Arg('userId', () => ObjectIdScalar) userId: ObjectId) {
return await UserModel.findById(userId);
}
@Query(() => User, { nullable: true })
@UseMiddleware(isAuth)
async currentUser(
@Ctx()
ctx: MyContext
): Promise<User | null> {
return await UserModel.findById(ctx.res.locals.userId);
}
}
You may notice the @UseMiddleware
decorator, which is used to integrate the isAuth
middleware. Using this resolver, we are able to fetch either individual users or the current logged in user.
Congratulations!
Today we wrote our first resolver. In the next section, we implement the authentication and stream resolvers.