[ PART 3 ] Creating a Twitter clone with GraphQL, Typescript and React ( User Registration )
ips-coding-challenge
Posted on January 6, 2021
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')
})
}
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
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
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 }
}
As you can see, I also created a AuthResponse and RegisterPayload class.
@ObjectType()
class AuthResponse {
@Field()
token: string
@Field(() => User)
user: User
}
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
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 }
}
- 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
}
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
If I test my request with the GraphQL playground, I get this
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')
})
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,
})
}
}
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 ;).
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()
})
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 ;)
Posted on January 6, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.