typescript fastify prisma

Backend REST de Fastify Prisma

Plantilla de TypeScript para API REST de Fastify con autenticación JWT y Prisma.

Daniel Gustaw

Daniel Gustaw

7 min read

Backend REST de Fastify Prisma

Fastify Prisma REST backend

Estoy creando muchos proyectos de Fastify y en este artículo he reunido las partes más comunes de ellos.

Configuración del proyecto:

pnpm init
pnpm --package=typescript dlx tsc --init
pnpm add -D typescript @types/node

opcionalmente agregar

{
  "pnpm": {
    "neverBuiltDependencies": []
  }
}

a package.json para evitar que pnpm pida aprobación para cada compilación.

Validación de entorno

No queremos permitir la ejecución del proyecto si faltan variables de entorno. Gracias a zod podemos validar si .env contiene todos los valores requeridos.

mkdir -p src && touch src/config.ts
pnpm add zod

agregar archivo src/config.ts

import { z } from 'zod';

export const serverVariables = z.object({
    DATABASE_URL: z.string(),
    JWT_SECRET: z.string(),

    NODE_ENV: z
        .enum(['development', 'production', 'test'])
        .default('development'),

    PORT: z.coerce.number().int().default(4747),
});

export const config = serverVariables.parse(process.env);

Conexión de Prisma

La mayoría de los proyectos necesita una base de datos.

pnpm add prisma @prisma/client
npx prisma init

entonces acuerde para las compilaciones.

pnpm approve-builds

Sincroniza el esquema de prisma si existe

pnpm dlx prisma db pull
touch src/db.ts

Agregar Fastify

Descomenta la línea "resolveJsonModule": true, en tsconfig.json.

Instala paquetes

pnpm add fastify @fastify/cors @fastify/sensible jsonwebtoken
pnpm add -D @types/jsonwebtoken tsx

Añadir archivo src/fastify.ts

import fastify, {
    FastifyInstance, FastifyPluginOptions,
    FastifyReply,
    FastifyRequest, RouteShorthandOptions,
} from 'fastify';
import {config} from "./config";
import cors from '@fastify/cors';
import fastifySensible from '@fastify/sensible';
import {FastifyRouteConfig} from "fastify/types/route";
import jwt from 'jsonwebtoken';
import pJson from '../package.json';

interface TokenPayload {
    id: number,
    iat: number,
    exp: number
}

interface UserProjection {
    id: number
}

declare module 'fastify' {
    interface FastifyRequest {
        user: UserProjection | null;
    }
}

export function verifyToken(token: string):  UserProjection {
    const payload = jwt.verify(token, config.JWT_SECRET) as TokenPayload;

    return {
        id: payload.id,
    }
}

function isProtected(config: FastifyRouteConfig): boolean {
    return (
        Boolean('isProtected' in config && config.isProtected)
    );
}

function getErrorMessage(error: unknown): string {
    if(error instanceof Error) {
        return error.message;
    }
    return 'Unknown error';
}

function version(_req: FastifyRequest, res: FastifyReply): void {
    res.code(200).send({
        name: pJson.name,
        version: pJson.version,
    });
};


const PROTECTED: RouteShorthandOptions = { config: { isProtected: true } };
const PUBLIC: RouteShorthandOptions = { config: { isProtected: false } };

function router(
    server: FastifyInstance,
    _options: FastifyPluginOptions,
    next: () => void,
) {
    server.get('/', PUBLIC, version);

    next();
}

export function getFastifyInstance(): FastifyInstance {
    const app = fastify({
        logger: config.NODE_ENV === 'development',
        bodyLimit: 100 * 1048576,
    });

    app.register(cors, {});
    app.register(fastifySensible);

    app.addHook(
        'onRequest',
        async (
            request: FastifyRequest<{ Headers: { authorization?: string } }>,
            reply: FastifyReply,
        ) => {
            // If the route is not private we ignore this hook
            if (isProtected(request.routeOptions.config)) {
                const authHeader = request.headers.authorization;
                if (typeof authHeader !== 'string') {
                    reply.unauthorized('No Authorization header');
                    return;
                }
                const token: string = String(authHeader)
                    .replace(/^Bearer\s+/, '')
                    .trim();
                if (!token) {
                    reply.unauthorized('Token is empty');
                    return;
                }

                try {
                    request.user = verifyToken(token);
                } catch (error) {
                    return reply.unauthorized(getErrorMessage(error));
                }
            }
        },
    );

    app.register(router);

    return app;
}

Esta es una configuración minimalista con validación de usuarios. Podemos importarlo en src/index.ts

import {getFastifyInstance} from './fastify';
import { config } from './config';

const app = getFastifyInstance();

app.listen(
    { port: config.PORT, host: '0.0.0.0' },
    (err: Error | null, host: string) => {
        if (err) {
            throw err;
        }
        console.info(`server listening on ${host}`);
    },
);

agregar script dev a package.json

{
  "scripts": {
    "dev": "infisical run --env=dev --path=/apps/fastify -- tsx watch ./src/index.ts",
  }
}

Prueba por

pnpm dev

y

http localhost:4747

Construcción

pnpm add -D tsup

agregar scripts

{
    "scripts": {
        "build": "tsup src/index.ts",
        "serve:prod": "infisical run --domain https://infisical.preciselab.io --projectId acb5ccfb-c211-4461-a06d-8caa248beea1 --env=prod --path=/apps/fastify -- node dist/index.js"
    }
}

Prueba E2E

pnpm add -D vitest

agregar archivo test/version.e2e.spec.ts

import {it, expect} from "vitest";

import {getFastifyInstance} from "../src/fastify";

it('should return version', async () => {
    const app = getFastifyInstance();

    const response = await app.inject({
        method: 'GET',
        url: '/',
    });

    expect(response.statusCode).toBe(200);
    expect(response.json()).toEqual({
        name: 'fastify',
        version: '1.0.0',
    });
})

y guion

{
    "scripts": {
        "test": "infisical run --env=dev --path=/apps/fastify -- vitest run",
    }
}

llamar

pnpm test

Probar ruta protegida

crear ruta

mkdir -p src/controller/auth
touch src/controller/auth/me.ts

agregar archivo src/controller/auth/me.ts

import {FastifyRequest} from "fastify";
import {UserProjection} from "../../types/auth";

export function me(req: FastifyRequest): UserProjection | null {
    return req.user;
}

donde src/types/auth.ts contiene tipos excluidos de fastify.ts

export interface TokenPayload {
    id: number,
    iat: number,
    exp: number
}

export interface UserProjection {
    id: number
}

También podemos excluir router.ts de fastify.ts a un archivo separado.

import {FastifyInstance, FastifyPluginOptions, RouteShorthandOptions} from "fastify";
import {version} from "./controllers/app/version";
import {me} from "./controllers/auth/me";

const PROTECTED: RouteShorthandOptions = { config: { isProtected: true } };
const PUBLIC: RouteShorthandOptions = { config: { isProtected: false } };

export function router(
    server: FastifyInstance,
    _options: FastifyPluginOptions,
    next: () => void,
) {
    server.get('/', PUBLIC, version);
    server.get('/me', PROTECTED, me);

    next();
}

y mueve version a controllers/app/version.ts

import {FastifyReply, FastifyRequest} from "fastify";
import pJson from "../../../package.json";

export function version(_req: FastifyRequest, res: FastifyReply): void {
    res.code(200).send({
        name: pJson.name,
        version: pJson.version,
    });
}

Ahora podemos probarlo en el archivo test/auth.e2e.spec.ts

import {it, expect} from "vitest";

import {getFastifyInstance} from "../src/fastify";
import jwt from "jsonwebtoken";
import {config} from "../src/config";

it('should return me', async () => {
    const app = getFastifyInstance();
    const token = jwt.sign({
        "id": 6,
        "iat": 1739986214,
        "exp": (Date.now() / 1000) + 3600 // 1 hour from now
    }, config.JWT_SECRET);

    const response = await app.inject({
        method: 'GET',
        url: '/me',
        headers: {
            authorization: `Bearer ${token}`
        }
    });

    expect(response.statusCode).toBe(200);
    expect(response.json()).toEqual({
        id: 6,
    });
})

Implementación

Dockerfile

FROM node:22-alpine AS base

ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare [email protected] --activate

WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install

FROM base AS build

COPY . .

RUN pnpm dlx prisma generate
RUN pnpm build

RUN apk add --no-cache bash curl && curl -1sLf \
'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.alpine.sh' | bash \
&& apk add infisical

CMD pnpm serve:prod

Docker compose: docker-compose.yml

services:
  app:
    image: registry.digitalocean.com/main/up-fastify
    ports:
      - '4747:4747'
    environment:
      - INFISICAL_TOKEN

Archivo de hosts

[local]
127.0.0.1 env=prod ansible_python_interpreter=/usr/bin/python3

[api]
142.132.182.19 ansible_user=root env=prod ansible_python_interpreter=/usr/bin/python3

Ansible playbook deploy.yml

---
- name: Build Backend
  hosts: local
  connection: local
  vars:
    image_url: registry.digitalocean.com/main/api-domain-com
  tasks:
    - name: Build Image
      ansible.builtin.shell: >
        DOCKER_BUILDKIT=1 docker build -t {{image_url}} .
    - name: Push Image
      ansible.builtin.shell: >
        docker push {{image_url}}
- name: Deploy Backend
  hosts: api
  vars:
    path: /root/api.domain.com
  tasks:
    - name: Creates Api directory
      file:
        path: '{{ path }}'
        state: directory
    - name: Copy Docker Compose
      copy:
        src: ./docker-compose.yml
        dest: '{{ path }}/docker-compose.yml'
    - name: Pull Image
      shell:
        cmd: docker compose pull
        chdir: '{{ path }}'
    - name: Restart Image
      shell:
        cmd: docker compose up -d --remove-orphans
        chdir: '{{ path }}'

Ve a control de acceso de Infisical, selecciona el token de servicio y crea uno nuevo con permisos de lectura seleccionando el alcance /** y el tiempo de expiración nunca.

Finalmente, agrega el comando de despliegue al Makefile.

deploy:
	ansible-playbook -i "hosts.${ENV}" deploy.yml

Luego, teniendo hosts.prod y hosts.stag, puedes desplegar ejecutando ENV=prod make deploy o ENV=stag make deploy.

Para vincular el despliegue con el dominio en el servidor, añade al archivo /etc/caddy/Caddyfile las líneas

api.domain.com {
 reverse_proxy localhost:4747
}

y recargar por

systemctl reload caddy

Other articles

You can find interesting also.