Chapter 3: Typegoose
Strongly Typed Next.js
link Typegoose
By the end of this lesson, developers will be able to:
- Create authentication and stream resolvers
- Create password manager for authentication
Introduction
In choosing a database, developers should be aware of which frameworks are supported. In the following diagram, we can review the frameworks that led to using MongoDB and Mongoose.
Mongoose is a MongoDB object modeling tool designed to work in an asynchronous environment. Mongoose supports both promises and callbacks, and even Typescript.
Take Mongoose, add TypGraphQL and you get Typegoose. Typegoose is a wrapper library for easily writing MongoDB models with TypeScript. It allows us to easily apply Mongoose schemas and models in TypeScript.
Typegoose will create the correct schemas and model mappings for our database. In getting started, it is important to realize that the decision to use Typegoose is based on using MongoDB. Given another database driver, you may want to consider using an Object Relational Mapping like TypeORM.
link Authentication
Before creating the next resolver called AuthResolver
, we will need to create two things:
api/types/AuthInput
- allows users to enter their credentialsapi/types/UserResponse
- returns an authentication user response
link Auth Input
Create a new file, api/types/AuthInput.ts
and insert the following:
import { InputType, Field } from 'type-graphql';
@InputType()
export class AuthInput {
@Field()
email: string;
@Field()
password: string;
}
When writing GraphQL mutations, we should create an input type to handle sending the data values. In this case, new users are asked to enter their email and password.
UserResponse
Create a new file, api/types/UserResponse.ts
and insert the following:
import { ObjectType, Field } from 'type-graphql';
import { User } from '../entity/User';
@ObjectType()
export class UserResponse {
@Field(() => User, { nullable: true })
user?: User;
@Field(() => String, { nullable: true })
token?: string;
}
The user response returns a User
object and JWT string. Let's begin to integrate a password manager for JWT authentication.
link AuthResolver
Before creating the auth resolver, let's install our hashing library: bcryptjs
, which relies on Web Crypto API's getRandomValues
interface to obtain secure random numbers.
npm install bcryptjs
npm install -D @types/bcryptjs
Create a new file, api/resolvers/AuthResolver.ts
and insert the following:
import { Arg, Mutation, Resolver } from 'type-graphql';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { UserModel } from '../entity/User';
import { AuthInput } from '../types/AuthInput';
import { UserResponse } from '../types/UserResponse';
@Resolver()
export class AuthResolver {
@Mutation(() => UserResponse)
async register(
@Arg('input')
{ email, password }: AuthInput
): Promise<UserResponse> {
// 1. check for existing user email
const existingUser = await UserModel.findOne({ email });
if (existingUser) {
throw new Error('Email already in use');
}
// 2. create new user with hash password
const hashedPassword = await bcrypt.hash(password, 10);
const user = new UserModel({ email, password: hashedPassword });
await user.save();
// 3. store user id on the token payload
const payload = {
id: user.id,
};
const token = jwt.sign(
payload,
process.env.SESSION_SECRET || 'aslkdfjoiq12312'
);
return { user, token };
}
@Mutation(() => UserResponse)
async login(
@Arg('input') { email, password }: AuthInput
): Promise<UserResponse> {
const user = await UserModel.findOne({ email });
if (!user) {
throw new Error('Invalid login');
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
throw new Error('Invalid login');
}
// Store user id on the token payload
const payload = {
id: user.id,
};
const token = jwt.sign(
payload,
process.env.SESSION_SECRET || 'aslkdfjoiq12312'
);
return { user, token };
}
}
The AuthResolver
is primarily responsible for the following:
- Given an email address, check if a user already exists
- If not, create a new user with a hashed password value
- Finally, assign and return the new user's JSON Web Token
During testing we will see how the token object is passed to the server. For now, let's continue with creating the StreamResolver
.
link Stream Input
Before creating the third resolver StreamResolver
, let's declare an input type for creating streams: StreamInput
.
Create a new file, api/types/StreamInput.ts
and insert the following:
import { InputType, Field } from 'type-graphql';
import { ObjectId } from 'mongodb';
import { Stream } from '../entity/Stream';
@InputType()
export class StreamInput implements Partial<Stream> {
@Field({ nullable: true })
id?: ObjectId;
@Field()
title: string;
@Field({ nullable: true })
description?: string;
@Field()
url: string;
}
Similar to UserInput
, StreamInput
will accept some parameters to create the new model object. In this case, it accepts title
, description
and url
.
link StreamResolver
Create a new file, api/resolvers/StreamResolver.ts
and insert the following:
import {
Resolver,
Query,
Mutation,
FieldResolver,
Ctx,
Arg,
Root,
UseMiddleware,
} from 'type-graphql';
import { ObjectId } from 'mongodb';
import { MyContext } from '../types/MyContext';
import { User, UserModel } from '../entity/User';
import { Stream, StreamModel } from '../entity/Stream';
import { ObjectIdScalar } from '../schema/object-id.scalar';
import { StreamInput } from '../types/StreamInput';
import { isAuth } from '../middleware/isAuth';
@Resolver(() => Stream)
export class StreamResolver {
@Query(() => Stream, { nullable: true })
stream(@Arg('streamId', () => ObjectIdScalar) streamId: ObjectId) {
// 1. find a single stream
return StreamModel.findById(streamId);
}
@Query(() => [Stream])
@UseMiddleware(isAuth)
streams(@Ctx() ctx: MyContext) {
// 2. display all streams for the current user
return StreamModel.find({ author: ctx.res.locals.userId });
}
@Mutation(() => Stream)
@UseMiddleware(isAuth)
async addStream(
@Arg('input') streamInput: StreamInput,
@Ctx() ctx: MyContext
): Promise<Stream> {
// 3. create a new user's stream
const stream = new StreamModel({
...streamInput,
author: ctx.res.locals.userId,
} as Stream);
await stream.save();
return stream;
}
@Mutation(() => Stream)
@UseMiddleware(isAuth)
async editStream(
@Arg('input') streamInput: StreamInput,
@Ctx() ctx: MyContext
): Promise<Stream> {
const { id, title, description, url } = streamInput;
const stream = await StreamModel.findOneAndUpdate(
{ _id: id, author: ctx.res.locals.userId },
{
title,
description,
url,
},
{ runValidators: true, new: true }
);
if (!stream) {
throw new Error('Stream not found');
}
return stream;
}
@Mutation(() => Boolean)
@UseMiddleware(isAuth)
async deleteStream(
@Arg('streamId', () => ObjectIdScalar) streamId: ObjectId,
@Ctx() ctx: MyContext
): Promise<Boolean | undefined> {
const deleted = await StreamModel.findOneAndDelete({
_id: streamId,
author: ctx.res.locals.userId,
});
if (!deleted) {
throw new Error('Stream not found');
}
return true;
}
@FieldResolver()
async author(@Root() stream: Stream): Promise<User | null> {
return await UserModel.findById(stream.author);
}
}
With the above code, we can query for single and multiple streams. We can create new streams, given a user is logged in and has a valid session userId
.
The @FieldResolver
decorator assign streams to their respective authors.
Note: Without
TypegooseMiddleware
, the@FieldResolver
would not work, as it relies on converting Document objects to model objects.
With the resolvers ready, we are ready to complete the server implemetation.
Congratulations!
Today we wrote authentication and stream resolvers. In the next section, we implement the schema, session and server.