Contracts
on this page
Contracts
are the crux of ts-rest. They are essentially schemas for your API endpoints that enable amazing type safety(and intellisense by extension), and data validation. Before we make our first contract for our User, lets first design a specification of each of our endpoints.
User Endpoints
Register a user
- Lets keep it at a simple
/register
path. - Since this will be a mutation(creation of an entity), this should have a
POST
method. - The request body should have an
email
, aname
, and apassword
as required by our data model. - If the user is successfully registered, return a response status of
201
, and the responsebody
should include the user data along with theid
that the user was registered in our database. - If a user by the specified email already exists, then return a response status of
400
, with an error message in thebody
.
Now that we have acquired the full understanding of our endpoint, we can go ahead and write this same information as a ts-rest contract
.
import { initContract } from '@ts-rest/core';
import { z } from 'zod';
const contract = initContract();
const UserContract = contract.router({
register: {
method : 'POST',
path: '/register',
responses:{
400 : contract.type<{ message: "User already exists" }>(),
201: z.object({
id : z.number(),
name : z.string(),
email : z.string().email(),
})
},
body: z.object({
name : z.string(),
email : z.string().email(),
password : z.string()
}),
summary : 'Register a user'
}
});
User Login
Since we have understood what contracts are, we can go ahead and add the login route to our contract as well.
const UserContract = contract.router({
register : {/* implementation above*/},
login : {
method : 'POST',
path : '/login',
responses:{
200 : z.object({
id : z.number(),
name : z.string(),
email : z.string().email(),
token : z.string()
}),
400 : contract.type<{ message : "Username or password incorrect"}>(),
},
body: z.object({
email : z.string().email(),
password : z.string()
}),
summary : 'login a user'
}
});
In this route, we have specified that the request body
must have the email
, and password
of the user. If the user does not exist, or the password is incorrect, we return a 400
, with the appropriate message. If the email, and password are valid, then we return a 200
along with the user details.
About Authentication
One interesting detail is the token
field in the response body. This token field is part of the basic bearer authentication
scheme that we will implement later. Basically this token is a string that contains the user id
in an encrypted form. Our other endpoints that we will implement later on will require this token so we can not only protect our routes from unwarranted accesses, but also identify which user has hit the endpoint.
Blog Posts Endpoints
Now that we have defined our user contract, we can move onto creating a contract for our posts.
Creating a post
The request body
must have a title, and a content for the post. The response body
will have all the post details including its id
.
import { initContract } from '@ts-rest/core';
import { z } from 'zod';
const contract = initContract();
const PostContract = contract.router({
createPost: {
method: 'POST',
path: '/posts',
responses: {
201: z.object({
id: z.number().int(),
title: z.string(),
content: z.string(),
creationDate: z.coerce.date(),
authorId: z.number().int(),
})
},
body: z.object({
title : z.string(),
content : z.string()
}),
summary: 'Create a post',
headers: z.object({
authorization : z.string()
})
}
});
If you noticed, the a post requires an authorId
, and our request body
does not include it. This is where the authorization header comes into place. The authorization headerβs value can be deserialized to get the id
of the user that makes the call, and we can essentially consider them the author of that post. We will discuss this in more detail when we actually implement the authentication middleware
.
Fetching a post
Since this is a simple fetch query, and not a mutation, we can use a GET
method. We can also make use of path parameters
to pass the id
of the post that we want to fetch. If the request is successful, the response will have 200
status. The response body will include the post data if found otherwise it will be null
.
const PostContract = contract.router({
createPost: {/*Implemented above*/},
getPost: {
method: 'GET',
path: `/posts/:id`,
pathParams : z.object({ id : z.string().transform(Number) }),
responses: {
200: PostSchema.nullable(),
},
summary: 'Get a post by id',
headers : z.object({
authorization : z.string()
})
},
});
Authenticating the entire contract
Since we wanted to authenticate this endpoint as well, we have included the authorization header again. Instead of repeating ourselves, we can remove the individual header definitions, and define it once on the contract as a whole. This means that any endpoints within this contract must be authenticated.
const PostContract = contract.router({
createPost: {/*Implemented above*/},
getPost: {/*Implemented above*/},
}, {
baseHeaders : z.object({
authorization : z.string()
})
});
We have successfully implemented all endpoints in our two contracts. Now we will go ahead and implement the actual logic also called the controllers
.