[ PART 3 ] Creating a Twitter clone with GraphQL, Typescript and React ( User Registration )

ipscodingchallenge

ips-coding-challenge

Posted on January 6, 2021

[ PART 3 ] Creating a Twitter clone with GraphQL, Typescript and React ( User Registration )

Github repo

Hi everyone! Let's continue the project ;). I have to say that learning a new tech while writing those articles is harder than I first thought and takes a lot more time :D.

Register a user

Before starting, I add 2 fields that I forgot last time in the users table ;).

src/db/migrations/add_fields_to_users_table.ts

import * as Knex from 'knex'

export async function up(knex: Knex): Promise<void> {
  return knex.schema.alterTable('users', (t) => {
    t.string('display_name').notNullable()
    t.string('avatar')
  })
}

export async function down(knex: Knex): Promise<void> {
  return knex.schema.alterTable('users', (t) => {
    t.dropColumn('display_name')
    t.dropColumn('avatar')
  })
}

Enter fullscreen mode Exit fullscreen mode

The username will be used as "slug" hence the fact that it is unique and not the display_name.

src/entities/User.ts

@Field()
display_name: string

@Field()
avatar?: string
Enter fullscreen mode Exit fullscreen mode

I will use a Token JWT based authorization. When a user logs in or registers, I will generate a JWT Token that I will send to the client. This token will then be passed to each request via an Authorization header and can then be checked to retrieve the logged-in user.

Let's install two new libraries ;)

yarn add jsonwebtoken argon2
Enter fullscreen mode Exit fullscreen mode

Ok let's go to the AuthResolver to create our register mutation

src/resolvers/AuthResolver.ts

@Mutation(() => AuthResponse)
  async register(@Arg('input') input: RegisterPayload, @Ctx() ctx: MyContext) {
    const { db } = ctx

    const hash = await argon2.hash(input.password)

    const [user] = await db('users')
      .insert({
        ...input,
        password: hash,
      })
      .returning('*')

    const token = generateToken(user)

    return { token, user }
  }
Enter fullscreen mode Exit fullscreen mode

As you can see, I also created a AuthResponse and RegisterPayload class.

@ObjectType()
class AuthResponse {
  @Field()
  token: string

  @Field(() => User)
  user: User
}
Enter fullscreen mode Exit fullscreen mode

And it is in the RegisterPayload class that I will put the validation rules (via the class-validator library).

src/dto/RegisterPayload.ts

import { IsEmail, Matches, MinLength } from 'class-validator'
import { Field, InputType } from 'type-graphql'

@InputType()
class RegisterPayload {
  @Field()
  @Matches(/^[a-zA-Z0-9_]{2,30}$/, {
    message:
      'The username should only contains alphanumeric characters and should have a length between 2 to 30',
  })
  username: string

  @Field()
  @MinLength(2)
  display_name: string

  @Field()
  @IsEmail()
  email: string

  @Field()
  @MinLength(6)
  password: string
}

export default RegisterPayload

Enter fullscreen mode Exit fullscreen mode

Nothing special here. For the moment, the uniqueness of the email and username is only managed via the database. We'll see later how to create a custom Validation ;).

Otherwise if I go back to my mutation:

@Mutation(() => AuthResponse)
  async register(@Arg('input') input: RegisterPayload, @Ctx() ctx: MyContext) {
    const { db } = ctx

    const hash = await argon2.hash(input.password)

    const [user] = await db('users')
      .insert({
        ...input,
        password: hash,
      })
      .returning('*')

    const token = generateToken(user)

    return { token, user }
  }
Enter fullscreen mode Exit fullscreen mode
  • I first get knex via context.
  • I hash the password via the argon2 library.
  • I insert my user
  • I generate a JWT token

As for the generateToken method, here it is

src/utils/utils.ts

export const generateToken = (user: User) => {
  const token = jwt.sign(
    {
      data: {
        id: user.id,
        username: user.username,
        display_name: user.display_name,
      },
    },
    JWT_SECRET as string,
    { expiresIn: '7d' } // 7 days
  )
  return token
}
Enter fullscreen mode Exit fullscreen mode

Note that the JWT_SECRET variable comes from a config file that I added to facilitate the use of environment variables.

src/config/config.ts

import * as dotenv from 'dotenv'

dotenv.config({ path: `${__dirname}/../../.env.${process.env.NODE_ENV}` })

export const PORT = process.env.PORT
export const JWT_SECRET = process.env.JWT_SECRET

Enter fullscreen mode Exit fullscreen mode

If I test my request with the GraphQL playground, I get this
Register mutation

I also wrote some tests

import { gql } from 'apollo-server'
import knex from '../db/connection'
import { testClient } from './setup'
import { createUser } from './helpers'

const REGISTER = gql`
  mutation($input: RegisterPayload!) {
    register(input: $input) {
      token
      user {
        id
        username
        display_name
        email
        created_at
        updated_at
      }
    }
  }
`
beforeEach(async () => {
  await knex.migrate.rollback()
  await knex.migrate.latest()
})

afterEach(async () => {
  await knex.migrate.rollback()
})

test('it should register a user', async () => {
  const { mutate } = await testClient()

  const res = await mutate({
    mutation: REGISTER,
    variables: {
      input: {
        username: 'admin',
        display_name: 'Admin',
        email: 'admin@test.fr',
        password: 'password',
      },
    },
  })

  const { token, user } = res.data.register
  expect(token).not.toBeNull()
  expect(user.username).toEqual('admin')
})
Enter fullscreen mode Exit fullscreen mode

I'm only putting you on one test, but I've written others. You can see all this in the Repo Github.

Custom Unique Validation

At the moment we can't insert a user if the username or email already exists in the database but it's only managed by the database and we end up with an error that doesn't have the same format as the other validation errors. Let's fix this :D

src/validators/Unique.ts

import {
  registerDecorator,
  ValidationOptions,
  ValidatorConstraint,
  ValidatorConstraintInterface,
  ValidationArguments,
} from 'class-validator'

import db from '../db/connection'

@ValidatorConstraint({ async: true })
export class UniqueConstraint implements ValidatorConstraintInterface {
  async validate(value: any, args: ValidationArguments) {
    const table = args.constraints[0]

    if (!table) throw new Error('Table argument is missing')

    const [item] = await db(table).where(args.property, value)
    if (!item) return true
    return false
  }
}

export function Unique(table: string, validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      target: object.constructor,
      propertyName: propertyName,
      options: validationOptions,
      constraints: [table],
      validator: UniqueConstraint,
    })
  }
}

Enter fullscreen mode Exit fullscreen mode

I strictly followed the examples to create this validation constraint. I just added the possibility to enter the table in which to make the SQL query in order to make the constraint more generic.

Otherwise, it's quite simple to understand. The validate() method retrieves the name of the table and will search if the property already exists in the database. For this to work, the name of the property must obviously match the name of the column in the table ;).

Alt Text

The error is now formatted like other validation errors.

Here is the test to verify that a user cannot register if his email is already taken

src/tests/auth.test.ts

test('it should not register a user if the email already exists', async () => {
  await createUser('admin', 'admin@test.fr')

  const { mutate } = await testClient()

  const res = await mutate({
    mutation: REGISTER,
    variables: {
      input: {
        username: 'new',
        display_name: 'Admin',
        email: 'admin@test.fr',
        password: 'password',
      },
    },
  })

  expect(res.errors).not.toBeNull()

  const {
    extensions: {
      exception: { validationErrors },
    },
  }: any = res.errors![0]

  expect((validationErrors[0] as ValidationError).constraints).toEqual({
    UniqueConstraint: 'This email is already taken',
  })
  expect(res.data).toBeNull()
})
Enter fullscreen mode Exit fullscreen mode

I'm not a fan of the validation errors format. I haven't yet looked at how I can intercept errors to format them in a simpler way. TypeGraphQL allows us to use middlewares but I don't know if we can use them globally. If we have to pass the middleware for each mutation to validate, it's not going to be great :D.

I think the Register part is coming to an end ;). In the next part, we'll see how to connect a user.

Ciao and have a nice day or evening ;)

💖 💪 🙅 🚩
ipscodingchallenge
ips-coding-challenge

Posted on January 6, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related