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:

  1. api/types/AuthInput - allows users to enter their credentials
  2. api/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:

  1. Given an email address, check if a user already exists
  2. If not, create a new user with a hashed password value
  3. 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.

link References

format_list_bulleted
help_outline