npm i tsup tsx typescript -D
for our dev dependencies and thennpm i fastify
tsc --init
to generate atsconfig.json
changing our target toes2020
- After that we create a file for our serve and app
- We ensure our packages are using the same exact version creating a .npmrc with the flag
save-exact=true
- That done we can use bots like renovatebot which will keep checking our projects dependencies and open pull requests to update them, in case these updates don't break our application.
- We create a
.env
and.env.example
files adding the first to our.gitignore
- We install dotenv
npm i dotenv
which we use in conjunction with zodnpm i zod
to make our variables validation. - [[./src/env/index.ts]]:
- Here we validate our data using zod.
- In our schema we use a enum to check our
NODE_ENV
- For our port we coerce it into a number
- After that we parse it and check for errors accessing the key
.succes
.errors.format
present us with an error redable by our user.throw new Error
prevent our app from running if our envs are not present.- In case everythign is alright we export the data in
_env.data
import 'dotenv/config'; import { z } from 'zod'; const envSchema = z.object({ NODE_ENV: z.enum(['dev', 'test', 'production']).default('dev'), PORT: z.coerce.number().default(3333) }) const _env = envSchema.safeParse(process.env) if (_env.success === false) { console.error('❌ Invalid environment variables', _env.error.format) throw new Error('Invalid environment varibles') } export const env = _env.data;
- We do
npm i eslint @rocketseat/eslint-config -D
- Create a
.eslintrc.json
and add{ "extends": [ "@rocketseat/eslint-config/node" ] }
- And then in tsconfig.json we add alias:
"baseUrl": "./", "paths": { "@/*": [ "./src/*" ] }, ```
- To use bitnami postgres image forthe first time
docker run --name api-solid-pg -e POSTGRESQL_USERNAME=docker -e POSTGRESQL_PASSWORD=docker -e POSTGRESQL_DATABAS=apisolid -p 5432:5432 bitnami/postgresql
- To run it again `docker start api-solid-pg``
- To stop
docker stop api-solid-pg
- And see list of images:
docker ps
- Docker compose is a tool we use to create the necessary containers to run our application.
- We do it by creating a file and specifying all containers and settings necessary for it to run.
- To run:
docker compose up -d
[-d is detached] - To stop:
docker compose stop
- To delete it:
docker compose down
- Starting with our controller on [./src/http/controllers/register.ts] is where we receive the data from the request, parse it,and send back the reply. It is also here our use-case and repository are declared and used, and whre our catch all is located to treat the error and send it back to the user.
- Is where our data storage is located, we abstract it with [./src/repositories/users-repository.ts] where the interface, our contract is declared with the methods and parameters needed.
import { Prisma, User } from '@prisma/client' export interface UsersRepository { create(data: Prisma.UserCreateInput): Promise<User> findByEmail(email: string): Promise<User | null> }
- It is implemented in [./src/repositories/prisma/prisma-users-repository.ts]. Here we create a class that implements our itnerface and makes the data transcations using prisma.
import { Prisma, User } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { UsersRepository } from '../users-repository'
export class PrismaUsersRepository implements UsersRepository {
async create({ name, email, password_hash }: Prisma.UserCreateInput) {
const user = await prisma.user.create({
data: {
name,
email,
password_hash,
},
})
return user
}
async findByEmail(email: string) {
const user = await prisma.user.findUnique({
where: {
email,
},
})
return user
}
}
- The use-case is where our business logic is created. On thte constructor the user repository is received as a parameter. After we do the necessary transformations and further validation of our data we pass it to our reository to store it.
- By declaring our
usersRepository
as property of our constructor with a modifier as a prefix ts automatically delclares it in our class so we can call it withthis.usersRepository
import { hash } from 'bcryptjs' import { RegisterBodyType } from '@/http/controllers/register' import { AppError } from '@/shared/errors/AppErrors' import { UsersRepository } from '@/repositories/users-repository' export class RegisterUseCase { constructor(private usersRepository: UsersRepository) {} async execute({ name, email, password }: RegisterBodyType) { const password_hash = await hash(password, 6) const userWithSameEmail = await this.usersRepository.findByEmail(email) if (userWithSameEmail) { throw new AppError('Email already exists', 409) } await this.usersRepository.create({ name, email, password_hash, }) } }
- By declaring our
- To treat erros globally in our application we declare in [./src/app.ts] a
app.setErrorHandler
which will receives all erros that get thrown at the last(first) level of our application and treat it. Here we treat our zod errors and unknown origin errors. We also leave a space where we can add an external tool to monitor errors like Sentry, Datalog and NewRelicapp.setErrorHandler((err, _, reply) => { if (err instanceof ZodError) { return reply .status(400) .send({ message: 'Validation erro.', issues: err.format() }) } if (env.NODE_ENV !== 'production') { console.error(err) } else { // Here we add an external tool like Datalog/NewRelic/Sentry } return reply.status(500).send({ message: 'Internal server error.' }) })
- For testing we add a few dependencies:
pnpm i vitest vite-tsconfig-paths -D pnpm i -D @vitest/ui
- Create a vite.config.ts:
import { defineConfig } from 'vitest/config' import tsconfigPaths from 'vite-tsconfig-paths' export default defineConfig({ plugins: [tsconfigPaths()], test: { environmentMatchGlobs: [['src/http/controllers/**', 'prisma']], }, })
- And create the necessary scripts to run our tests:
"scripts": {
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
},
- Here is an example of a use case we made [./src/use-cases/check-in.ts]:
- Our use case have an interface for its request and response.
- In our constructor we invoke both check in and gym repository to do our operations
- Inside our function we first convert our Prisma values from decimal to number and throw it to our service.
import { CheckInsRepository } from '@/repositories/check-ins-repository' import { GymsRepository } from '@/repositories/gyms-repository' import { AppError } from '@/shared/errors/AppErrors' import { getDistanceBetweenCoordinates } from '@/utils/get-distance-between-coordinates' import { CheckIn } from '@prisma/client' interface CheckInUseCaseRequest { userId: string gymId: string userLatitude: number userLongitude: number } interface CheckInUseCaseResponse { checkIn: CheckIn } export class CheckInUseCase { constructor( private checkInsRepository: CheckInsRepository, private gymsRepository: GymsRepository, ) {} async execute({ userId, gymId, userLatitude, userLongitude, }: CheckInUseCaseRequest): Promise<CheckInUseCaseResponse> { const gym = await this.gymsRepository.findById(gymId) if (!gym) { throw new AppError('Resource not found', 400) } const distance = getDistanceBetweenCoordinates( { latitude: userLatitude, longitude: userLongitude }, { latitude: gym.latitude.toNumber(), longitude: gym.longitude.toNumber(), }, ) const MAX_DISTANCE_IN_KILOMETERS = 0.1 if (distance > MAX_DISTANCE_IN_KILOMETERS) { throw new AppError('Max distance surpassed.') } const checkInOnSameDay = await this.checkInsRepository.findByUserIdOnDate( userId, new Date(), ) if (checkInOnSameDay) { throw new AppError('Max number of check-ins reached.') } const checkIn = await this.checkInsRepository.create({ user_id: userId, gym_id: gymId, }) return { checkIn, } } }
- One of the in-memory repositories we created to simulate our databse operations [./src/repositories/in-memory/in-memory-check-ins-repository.ts]:
- In
findByUserIdOnDate
we usedayjs
to get our datestartOf
andendOf
and thenisAfter
andisBefore
to check if its on the same day.
import type { CheckIn, Prisma } from '@prisma/client' import { CheckInsRepository } from '../check-ins-repository' import { randomUUID } from 'crypto' import dayjs from 'dayjs' export class InMemoryCheckInsRepository implements CheckInsRepository { public items: CheckIn[] = [] async findById(id: string): Promise<CheckIn | null> { const checkIn = this.items.find((item) => item.id === id) if (!checkIn) { return null } return checkIn } async findByUserIdOnDate(userId: string, date: Date) { const startOfTheDay = dayjs(date).startOf('date') const endOfTheDay = dayjs(date).endOf('date') const checkInOnSameDate = this.items.find((checkIn) => { const checkInDate = dayjs(checkIn.created_at) const isOnSameDate = checkInDate.isAfter(startOfTheDay) && checkInDate.isBefore(endOfTheDay) return checkIn.user_id === userId && isOnSameDate }) if (!checkInOnSameDate) { return null } return checkInOnSameDate } async findManyByUserId(userId: string, page: number): Promise<CheckIn[]> { return this.items .filter((item) => item.id !== userId) .slice((page - 1) * 20, page * 20) } async countByUserId(userId: string): Promise<number> { return this.items.filter((item) => item.id !== userId).length } async create({ gym_id, user_id, validated_at, }: Prisma.CheckInUncheckedCreateInput) { const checkIn: CheckIn = { id: randomUUID(), gym_id, user_id, created_at: new Date(), validated_at: validated_at ? new Date(validated_at) : null, } this.items.push(checkIn) return checkIn } async save(checkIn: CheckIn): Promise<CheckIn> { const checkInIndex = this.items.findIndex((item) => item.id === checkIn.id) if (checkInIndex >= 0) { this.items[checkInIndex] = checkIn } return checkIn } }
- In
- Here is one of the most example tests we made [./src/use-cases/check-in.spec.ts]:
- Here we create a gym before each test, since a check-in must necessarily have a corresonding gym from which we compare the distance between it and user.
- We use
vi.setSystemTime
to manipulate the date - Another function we can use from vitest is
vi.advanceTimersByTime(twentyOneMinutsInMs)
which advances time by a determined amount of ms. - One other good example to look is [./src/use-cases/fetch-users-check-ins-history.spec.ts] where multiple exects are used to validate our usecase
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { InMemoryCheckInsRepository } from '@/repositories/in-memory/in-memory-check-ins-repository' import { CheckInUseCase } from './check-in' import { AppError } from '@/shared/errors/AppErrors' import { InMemoryGymsRepository } from '@/repositories/in-memory/in-memory-gyms-repository' let checkInsRepository: InMemoryCheckInsRepository let gymRepository: InMemoryGymsRepository let sut: CheckInUseCase describe('Check-in', () => { beforeEach(async () => { checkInsRepository = new InMemoryCheckInsRepository() gymRepository = new InMemoryGymsRepository() sut = new CheckInUseCase(checkInsRepository, gymRepository) gymRepository.create({ id: 'gym-id', phone: '11931223213', title: 'Academia Teste', latitude: -23.6336868, longitude: -46.7862208, description: 'Só para os testadores de peso', created_at: new Date(), }) vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) it('should be able to create check in', async () => { vi.setSystemTime(new Date(2022, 0, 20, 8, 0, 0, 0)) const { checkIn } = await sut.execute({ userId: 'user-id', gymId: 'gym-id', userLatitude: -23.6336868, userLongitude: -46.7862208, }) expect(checkIn.id).toEqual(expect.any(String)) }) it('should not be able to check in twice in the same date for same user', async () => { vi.setSystemTime(new Date(2022, 0, 20, 8, 0, 0, 0)) await sut.execute({ userId: 'user-id', gymId: 'gym-id', userLatitude: -23.6336868, userLongitude: -46.7862208, }) await expect(async () => sut.execute({ userId: 'user-id', gymId: 'gym-id', userLatitude: -23.6336868, userLongitude: -46.7862208, }), ).rejects.toBeInstanceOf(AppError) }) it('should be able to check in twice in different dates for same user', async () => { vi.setSystemTime(new Date(2022, 0, 20, 8, 0, 0, 0)) await sut.execute({ userId: 'user-id', gymId: 'gym-id', userLatitude: -23.6336868, userLongitude: -46.7862208, }) vi.setSystemTime(new Date(2022, 0, 21, 8, 0, 0, 0)) const { checkIn } = await sut.execute({ userId: 'user-id', gymId: 'gym-id', userLatitude: -23.6336868, userLongitude: -46.7862208, }) expect(checkIn.id).toEqual(expect.any(String)) }) it('should note be able to check in on distant gym', async () => { vi.setSystemTime(new Date(2022, 0, 20, 8, 0, 0, 0)) await expect(async () => { await sut.execute({ userId: 'user-id', gymId: 'gym-id', userLatitude: -23.6524369, userLongitude: -46.8061568, }) }).rejects.toBeInstanceOf(AppError) }) }) // red -> error no teste // green -> codar minimo possivel para o teste passar // refactor -> refatoro o codigo
- A service we create to calculate the distance between coordinates, would be interesting to undestand it better later [./src/utils/get-distance-between-coordinates.ts]:
export interface Coordinate { latitude: number longitude: number } export function getDistanceBetweenCoordinates( from: Coordinate, to: Coordinate, ) { const fromRadian = (Math.PI * from.latitude) / 180 const toRadian = (Math.PI * to.latitude) / 180 const theta = from.longitude - to.longitude const radTheta = (Math.PI * theta) / 180 let dist = Math.sin(fromRadian) * Math.sin(toRadian) + Math.cos(fromRadian) * Math.cos(toRadian) * Math.cos(radTheta) if (dist > 1) { dist = 1 } dist = Math.acos(dist) dist = (dist * 180) / Math.PI dist = dist * 60 * 1.1515 dist = dist * 1.609344 return dist }
- JSON web tokens are unique, non modifiable stateless tokens created by our backend from a keyword. It is separated in header.payload.verify_signature.
- Header contains the algorithm we used to generate our token
- Payload contains the data of our user his id being named as sub
- Our signature is what protects the data in our payload fro mbeing modified.
- JWT is a form of authentication which works bettwer when used with HTTP routes, for that reason it will be contained in our http layer.
- An example of a controller in [./src/http/controllers/gyms/create.ts]. We validate the data received through a schema, execute our useCase and return a status.
import { FastifyRequest, FastifyReply } from 'fastify' import { z } from 'zod' import { makeCreateGymUseCase } from '@/use-cases/factories/make-create-gym-use-case' const createGymBodySchema = z.object({ title: z.string(), description: z.string().nullable(), phone: z.string().nullable(), latitude: z.number().refine( (value) => Math.abs(value) <= 90, // Math.abs(absolute) transforms our value into a positive one ), longitude: z.number().refine((value) => Math.abs(value) <= 180), }) export type RegisterBodyType = z.infer<typeof createGymBodySchema> export async function create(request: FastifyRequest, reply: FastifyReply) { const { title, description, phone, latitude, longitude } = createGymBodySchema.parse(request.body) const createGymUseCase = makeCreateGymUseCase() await createGymUseCase.execute({ title, description, phone, latitude, longitude, }) return reply.status(201).send() }
- In [./src/http/controllers/check-ins/routes.ts]
- Here is an example of routing. When we're modyfing a specific checkIn or gym, we code it as a params.
- Notice that even tho a route is in the check-in route it is still accessed through the
/gyms
since a check-in or validation is done to a gym.
export async function checkInsRoutes(app: FastifyInstance) { app.addHook('onRequest', verifyJWT) app.get('/check-ins/history', history) app.get('/check-ins/metrics', metrics) app.post('/gyms/:gymId/check-ins', create) app.patch('/gyms/:checkInId/validate', create) }
- To create a test environment we need to follow a few steps:
- Create [./src/prisma/prisma-users-repository] folder
- npm start it
- Add to [./src/vite.config.ts]:
test: { environmentMatchGlobs: [['src/http/controllers/**', 'prisma']], },
- Create a link between our prima-users-repository and project running
npm link
on [./prisma/vitest-environment-prisma/] andnpm link vitest-environment-prism
on [./] - To avoid the need of having to repeat the linking process on every environemnt we use we create the following scripts on [./package.json] (we also need to install npm-run-all for it to run on non-unix complianat systems) for that:
- On npm we can add pre and post to run commands before and after our command is runt.
{ "test:create-prisma-environment": "npm link ./prisma/vitest-environment-prisma", "test:install-prisma-environment": "npm link vitest-environment-prisma", "build": "tsup src --out-dir build", "test": "vitest run --dir src/use-cases", "test:watch": "vitest --dir src/use-cases", "pretest:e2e": "run-s test:create-prisma-environment test:install-prisma-environment", "test:e2e": "vitest run --dir src/http", }
- In [./src/http/controllers/check-ins/validate.spec.ts] we have complete example of a e2e validation
- We use supertest to call our app and call our endpoints, before each and after every test we ready and close it.
- To create our user and get our token we created a function that can be called for the various tests wwhere it is needed.
- For operations besides our main one, for the memoment we call directly prisma to create our entries.
- After that's done we call the route we want
- Here we use
patch
but we could use any other http method aswell .send
for a json body but for query params we can usequery
.set
to add our bearer token to our header with the name authorization
- Here we use
import request from 'supertest' import { app } from '@/app' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { createAndAuthenticateUser } from '@/utils/test/create-and-authenticate-user' import { createAndGetGym } from '@/utils/test/create-and-get-gym' import { prisma } from '@/lib/prisma' describe('Validate check-in (e2e)', () => { beforeAll(async () => { await app.ready() }) afterAll(async () => { await app.close() }) it('should be able to validate check-in', async () => { const { token } = await createAndAuthenticateUser(app) const user = await prisma.user.findFirstOrThrow() const gym = await prisma.gym.create({ data: { title: 'Powerlifter Gym', latitude: -23.6336868, longitude: -46.7862208, }, }) const checkIn = await prisma.checkIn.create({ data: { gym_id: gym.id, user_id: user.id, }, }) const response = await request(app.server) .patch(`/gyms/${checkIn.id}/validate`) .set('Authorization', `Bearer ${token}`) .send({ latitude: -23.6336868, longitude: -46.7862208, }) expect(response.statusCode).toEqual(200) }) })
- Function to register and create authentication on [./src/utils/test/create-and-authenticate-user.ts]
import { FastifyInstance } from 'fastify' import request from 'supertest' export async function createAndAuthenticateUser(app: FastifyInstance) { await request(app.server).post('/users').send({ name: 'João Cabeção', email: 'joao.cabecao@email.com', password: 'password', }) const authResponse = await request(app.server).post('/sessions').send({ email: 'joao.cabecao@email.com', password: 'password', }) const { token } = authResponse.body return { token } }