Controllers
on this page
We have made schemas for our endpoints that protect us from making any obvious logical mistakes, and invalid inputs but we have not written the actual business logic that will be executed against these endpoints. We will now define controllers
for each endpoint in each contract that encapsulate our logic.
Register Controller
Lets go ahead and define our first controller against the /register
endpoint.
import { initServer } from "@ts-rest/express";
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const server = initServer();
const UserRouter = server.router(UserContract, {
register : async ({ body : { name, email, password }}) => {
const userExists = await prisma.user.findFirst({ where : { email }});
if(userExists) return {
status : 400,
body : {
message : "User already exists"
}
}
const {password : _, ...user} = await prisma.user.create({ data : {
name,
email,
password : encrypt(password)
}});
return {
status : 201,
body : user,
}
}
}
There are a few things worth noticing here.
- The body parameter of the controller is completely typed so we will get type checking, auto complete on the properties
name
,email
, andpassword
. - We are not storing the passwords directly, but a hashed/encrypted version of it. This is a very basic, and essential security scheme.
- The return values in the case where the user already exists must match the one we defined in the contract(400 with error message), otherwise typescript will throw an error.
- The same is true for the successful registration scenario. The status code has to be
201
with the user details.
We should really appreciate how that by defining the contract beforehand, we have made it much harder for us to introduce any bugs to our codebase, as now we will have complete type-safety.
User Login Controller
The logic for the user controller is a bit longer, and it can be broken down as follows:
- The user provides their email and password.
- Check if that particular user email exists.
- Assuming that the user email exists, and using the same encryption method from before, compute a hash of the received password.
- Compare this hash with the one stored in the database.
- If it matches, then we will send them back an
authentication token
that contains an encrypted form of theiruser id
. This is part of our authentication scheme.
import jwt from "jsonwebtoken";
const UserRouter = server.router(UserContract, {
register : async ({ body }) => {/* Implemented above */},
/* continued */
login : async ({ body : { email, password } }) => {
const user = await prisma.user.findFirst({ where : { email }});
const IncorrectUserOrPasswordResponse = {
status : 400,
body : {
message : "Username or password incorrect"
}
} as const;
if(!user) return IncorrectUserOrPasswordResponse;
if(user.password !== encrypt(password)) return IncorrectUserOrPasswordResponse;
const secret = "This is the secret. keep it somewhere secure";
const token = jwt.sign({ id : user.id}, secret);
return {
status : 200,
body : {
name : user.name,
email : user.email,
id : user.id,
token
}
}
}
}
How JsonWebTokens works
We have used the library jsonwebtoken
to generate a token for us. The function jwt.sign
takes in two parameters that we are concerned with.
- A payload is encoded within the token.
- A secret which is also used to generate our token.
The secret should be protected, as it is there to ensure that only we can generate these tokens, and decrypt them. One common practice is to store these secrets in a .env
file server side.
The jwt.verify
function will accept a token, and the same secret. If the token is valid, then it will return the serialized payload within it. We can use this in our controllers. However if the token or the secret is invalid, it will throw an error. We can consider this as unauthorized access.
We will use the jwt.verify
function in a middleware
, and talk more about middlewares later on in this series.
CreatePost Controller
This controller is rather straightforward in both logic, and implementation.
const s = initServer();
const PostRouter = s.router(PostContract, {
createPost : async ({ body : { title, content }, req : { userId } }) => {
/* author is the person who makes the call to begin with */
const post = await prisma.post.create({ data : { title, content, authorId : userId } });
return {
status : 201,
body : post
}
}
});
One interesting aspect is the request object has a userId
which is being used as the authorId
for the post. If you are using typescript, this will throw an error since the request object usually does not have an attribute like that. You can explicitly type the express Request object as follows:
declare global {
namespace Express {
export interface Request {
userId: number;
}
}
Add this declaration anywhere in your code. This is only half of the problem though, as we have only typed the request to have this attribute but in reality it does not have this attribute right now. If you tried to access req.userId, it would be undefined on runtime. This is where our Middleware comes in which we will cover later. For now you can assume that the userId is indeed defined.
GetPost Controller
getPost : async ({ params : { id } }) => {
const post = await prisma.post.findUnique({ where: { id } });
return {
status : 200,
body : post ?? null
}
},
Notice how we have destructured the params
parameter as this is what we declared in the contract. The contract really makes development much easier, and safe with its rich auto complete.
Our implementation is almost complete and now we only need our authentication middleware.