Instalación
Para iniciar hay que instalar Node desde su página oficial, siempre intentando instalar la version LTS, que es la versión más estable. Hecho esto se puede comprobar si se instalo con el siguiente comando.
node -v
Esto devolverá la versión actual de Node.
Express + CORS
ExpressJS es un framework diseñado para crear aplicaciones con Node, siendo uno de los más importantes actualmente. Cors es un paquete que nos ayuda con el manejo de los CORS como su nombre lo indica. Para instalar el mismo debemos utilizar el siguiente comando.
npm i express cors express-async-handler
pnpm add express cors express-async-handler
express-async-handler
nos ayudará a manejar las respuestas asíncronas de las llamadas
Sumado a esto, al trabajar con [[TypeScript]] se deberán instalar sus tipos.
npm i -D @types/express @types/cors
pnpm add -D @types/express @types/cors
Configuración del entorno
Para comenzar a utilizar Node con Express y Typescript debemos configurar un entorno de pruebas. Para ello empezaremos creando una carpeta dedicada a nuestro entorno y utilizar el siguiente comando para iniciar el entorno.
npm init -y
pnpm init
Este comando creará nuestro archivo package.json
, este contiene toda la configuración y datos de nuestro proyecto, desde el nombre del autor hasta los paquetes que utilizaremos en el mismo.
Typescript
Para agregar [[TypeScript]] a nuestro proyecto debemos instalar y configurar el mismo. Lo primero que hay que hacer es instalar el paquete necesario con el siguiente comando.
npm i typescript
pnpm add typescript
Luego debemos agregar un script a nuestro package.json
.
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"tsc": "tsc",
"start": "tsc && node dist/index.js",
}
Y ejecutar el comando para agregar las configuraciones de TS a nuestro proyecto.
npm run tsc -- --init
pnpm tsc --init
Se creará el archivo tsconfig.json
, el cual podremos cambiar a gusto, quedando el mismo de la siguiente manera.
{
"compilerOptions": {
"target": "es2016",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true
}
}
Nodemon + ts-node
Uno de los paquetes más importantes para trabajar con Node es Nodemon
, el cual es un paquete que mantiene una vista constante del proyecto en el que se trabaja, refrescando cada vez que se hace un cambio, sumado a esto se puede instalar el paquete ts-node
, el cual trabaja en conjunto con nodemon. Para instalarlos se deberá utilizar el siguiente comando.
npm i nodemon ts-node -D
pnpm add nodemon ts-node -D
Luego deberemos crear el script para iniciar Nodemon en nuestro package.json
de la siguiente manera.
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"tsc": "tsc",
"start": "tsc && node dist/index.js",
"dev": "nodemon"
},
Por ultimo se deberá agregar un archivo nodemon.json
con sus respectivas configuraciones.
{
"watch": ["src"],
"ext": ".ts,.js",
"ignore": [],
"exec": "ts-node ./src/index.ts"
}
Coding
Luego de todas las configuraciones debemos crear el archivo src/index.ts
para poder crear nuestro servidor de la siguiente manera.
import express from "express"; // Importamos lo necesario
import cors from "cors";
const app = express(); // Creamos la app
// Server
const server = 5000;
// Middlewares
app.use(express.json()); // Aceptamos el JSON
app.use(cors());
app.use(express.urlencoded({ extended: true })); // Y los datos del body
// Routes
app.get("/", (_req, res) => { // Creamos la ruta con _ en el request
res.json({ message: "working" }); // Y creamos la respuesta
});
// Server Init
app.listen(server, () => {
console.log(`server running on port ${server}`
}) // Iniciamos el servidor
Routing
Una de las mejores formas de organizar el proyecto es con rutas separadas, para esto creamos el archivo para las rutas del usuario en la direccion src/routes/users/usersRoutes.ts
, el cual va a contener las rutas de la siguiente manera.
import { Router } from "express"; // Importamos el router
const usersRoutes = Router(); // Y lo inicializamos
usersRoutes.get("/users", (_req, res) => {
// Creamos la ruta
res.json({ message: "users page working" }); // Y la respuesta
});
export default usersRoutes; // Exportamos las rutas
Hecho esto podemos importarlas en nuestro index.ts
de la siguiente manera.
import express from 'express'
import cors from 'cors'
import usersRoutes from './routes/users/usersRoutes' // Importamos las rutas
const app = express()
// Server
const server = 5000
// Middlewares
app.use(express.json())
app.use(cors())
app.use(express.urlencoded({ extended: true }))
// Routes
// ===== / =====
app.get('/', (_req, res) => {
res.json({ message: 'working' })
})
// ===== /users =====
app.use('/users', usersRoutes) // Y las usamos con la base "/users"
// Server Init
app.listen(server, () => {
console.log(`server running on port ${server}`
})
Controladores
Las rutas utilizan controladores para funcionar, por lo que estos se pueden separar en otro componente, para estos debemos crear su respectivo archivo en la dirección src/controllers/users/usersControllers.ts
, quedando el mismo de la siguiente manera.
import { type Response } from "express"; // Importamos los tipos de Express
export const usersControllers = {
// Creamos la función principal
GET_AllUsers: (res: Response) => {
// Y cada una de sus respuestas
res.json({ message: "users page working" });
},
};
Hecho esto podemos reemplazar los mismos en la ruta de usuarios.
import { Router } from "express";
import { usersControllers } from "../../controllers/users/usersControllers"; // Importamos los controladores
const usersRoutes = Router();
usersRoutes.get("/users", usersControllers.GET_AllUsers); // Y los utilizamos
export default usersRoutes;
Datos desde Params
Es posible recibir datos del usuario por medio de la URL, gracias a los params
, estos son los datos que se escriben después de la base que utilizamos /notes/{params}
, pudiendo recibirlos de la siguiente manera.
GET_getOneNote: asyncHandler(async (req: Request, res: Response) : Promise<any> => { // Envolvemos la respuesta asíncrona
const noteID = req.params.id // Tomamos los params
if (noteID === undefined) { // Si no existe el id
return res
.status(400) // Devolvemos el error y el código de estado
.json({ message: "data must contain 'id' and 'title' and 'content" })
}
try {
/*
Search note by id and return note if found
*/
} catch (error) {
console.log(error)
return res.status(400).json({ message: error })
}
}),
Y esto lo debemos configurar en la ruta de la siguiente manera.
import { Router } from "express";
import notesController from "../../controllers/notes/notesControllers";
const notesRoutes = Router();
notesRoutes.get("/", notesController.GET_getAllNotes);
notesRoutes.get("/:id", notesController.GET_getOneNote);
export default notesRoutes;
Es posible tomar más de un dato desde los params, indicandolo al momento de crear la ruta.
Datos desde Query
Otra forma de tomas los datos es desde las queries, las cuales se indican con key=value
en la url, sin la necesidad de agregarlo a la hora de crear la ruta, por la que la misma debe quedar de la misma manera /notes
, y luego se toman esos valores en el back de la siguiente manera.
GET_getOneNote: asyncHandler(async (req: Request, res: Response) : Promise<any> => { // Envolvemos la respuesta asíncrona
const noteID = req.query.id // Tomamos los params
if (noteID === undefined) { // Si no existe el id
return res
.status(400) // Devolvemos el error y el código de estado
.json({ message: "data must contain 'id' and 'title' and 'content" })
}
try {
/*
Search note by id and return note if found
*/
} catch (error) {
console.log(error)
return res.status(400).json({ message: error })
}
}),
Los parámetros se pasan indicandolo de la siguiente manera
/notes/id=moon
, y se puede pasar mas de uno agregando un&
entre ellos
Datos desde Body
Si se quiere pasar los parámetros sin que quede en el historial de la URL se puede utilizar el body
de la petición, la cual se tiene que componer en la misma llamada, y se toma en el back de la siguiente manera.
GET_getOneNote: asyncHandler(async (req: Request, res: Response) : Promise<any> => { // Envolvemos la respuesta asíncrona
const noteID = req.body.id // Tomamos los params
if (noteID === undefined) { // Si no existe el id
return res
.status(400) // Devolvemos el error y el código de estado
.json({ message: "data must contain 'id' and 'title' and 'content" })
}
try {
/*
Search note by id and return note if found
*/
} catch (error) {
console.log(error)
return res.status(400).json({ message: error })
}
}),
LowDB
Se puede agregar una base de datos local para hacer las pruebas de la API, una de estas es lowdb, la cual agrega una base de datos gracias al manejo de un archivo .json. Para instalarlo se utiliza el siguiente comando.
npm i lowdb
pnpm add lowdb
Con esto hecho se debe crear un iniciador para la base de datos, para eso se crea el archivo en la ruta src/data/database.ts
, en la misma crearemos y exportaremos la base de datos con su respectivo iniciador.
export const upDB = async (): Promise<any> => {
const { JSONPreset } = await import("lowdb/node"); // Importamos el modulo
const defaultData = { notes: [] }; // Indicamos la data
const db = await JSONPreset("db.json", defaultData); // Y la creamos
await db.write(); // Agregamos la data a la base de datos
return db; // Y devolvemos la instancia
};
Con esto hecho podemos crear los handlers de la base de datos en el directorio src/services/handleNotes.ts
, en el cual podemos manejar las funciones necesarias de estas de al siguiente manera.
import { type notesType } from "../../types/dict"; // Importamos los tipos
import { upDB } from "../data/database"; // Y el iniciador de la base de datos
export const getAllNotes = async (): Promise<notesType> => {
// Creamos la función para traer todos los datos
try {
const notes = await upDB(); // Pedimos los datos
return notes.data.notes; // Y devolvemos las notas
} catch (error) {
throw new Error("Something went wrong with the database"); // Manejamos el error si hubiese
}
};
export const getOneNote = async (id: string): Promise<notesType> => {
// Creamos la función para traer una sola nota
const db = await upDB();
try {
const note = db.data.notes.find((note: notesType) => note.id === id); // La buscamos en la base de datos
return note; // Y la devolvemos si la encontró
} catch (error) {
throw new Error("Note not found");
}
};
export const createNote = async (note: notesType): Promise<notesType> => {
// Creamos la nota en base a los datos enviados
const db = await upDB();
db.data.notes.push(note); // Agregamos la nueva nota
await db.write(); // Guardamos los datos en el archivo
return note; // Y devolvemos la nota
};
export const updateNote = async (
// Creamos la función para editar la nota, recibiendo la nueva nota
noteForUpdate: notesType,
): Promise<notesType | Error> => {
const db = await upDB();
const noteIndex = db.data.notes.findIndex(
(note: notesType) => note.id === noteForUpdate.id, // Buscamos la nota por el ID
);
if (noteIndex === -1) {
throw new Error("Note not found"); // Manejamos el error si no lo encuentra
}
db.data.notes[noteIndex] = noteForUpdate; // Y si lo encuentra lo editamos
await db.write(); // Guardamos la nota editada
return noteForUpdate; // Y la devolvemos
};
export const deleteNote = async (
// Creamos la función para eliminar una nota por ID
noteID: string,
): Promise<notesType | Error> => {
const db = await upDB();
const noteIndex = db.data.notes.findIndex(
// Buscamos la nota por ID
(note: notesType) => note.id === noteID,
);
const noteInDB = db.data.notes[noteIndex]; // Y la guardamos para devolverla
if (noteIndex === -1) {
throw new Error("Note not found");
}
db.data.notes.splice(noteIndex, 1); // Si existe la eliminamos
await db.write(); // Y guardamos los cambios
return noteInDB;
};
Por ultimo usamos estas para manejar las respuestas de la API.
import type { Request, Response } from "express"; // Importamos los tipos
import asyncHandler from "express-async-handler"; // Importamos el handler para las llamadas asíncronas
import {
// Y las funciones
createNote,
deleteNote,
getAllNotes,
getOneNote,
updateNote,
} from "../../services/handleNotes";
import type { notesType } from "../../../types/dict";
import { isValid } from "../../services/functions"; // Además de la función para comprobar si es un dato valido
const notesController = {
// ==================== GET ====================
// Get all notes
GET_getAllNotes: asyncHandler(async (_req, res, _next): Promise<any> => {
// Envolvemos la función en el handler
try {
const notesFromDB = await getAllNotes(); // Utilizamos la función
return res.json(notesFromDB);
} catch (error) {
console.error(error);
return res.status(500).json({ message: error });
}
}), // Get one note
GET_getOneNote: asyncHandler(
async (req: Request, res: Response): Promise<any> => {
const noteID = req.params.id;
if (noteID === undefined) {
return res
.status(400)
.json({ message: "data must contain 'id' and 'title' and 'content" });
}
try {
const foundNote = await getOneNote(noteID);
return res.json({ message: "Note found", note: foundNote });
} catch (error) {
console.log(error);
return res.status(400).json({ message: error });
}
},
), // ==================== POST ==================== // Create new note
POST_createNote: asyncHandler(
async (req: Request, res: Response): Promise<any> => {
const { title, content } = req.body;
if (title === undefined || content === undefined) {
// Si no hay datos
return res
.status(400)
.json({ message: "data must contain 'title' and 'content" }); // Devolvemos el error
}
try {
const newNote = { id: crypto.randomUUID(), title, content }; // Creamos el ID automáticamente
const createdNote = await createNote(newNote);
return res
.status(200)
.json({ message: "Note created successfully", note: createdNote });
} catch (error) {
console.log(error);
return res.status(400).json({ message: error });
}
},
), // ==================== PUT ==================== // Update one note
PUT_updateNote: asyncHandler(
async (req: Request, res: Response): Promise<any> => {
const body: notesType = req.body;
if (typeof body.id !== "string") {
return res.status(400).json({ message: "'id' must be a string" });
}
if (!isValid(body)) {
// Comprobamos que sea un dato válido
return res
.status(400)
.json({ message: "id, title, and content must not be empty" });
}
try {
const updatedNote = await updateNote(body);
return res.json({
message: "note updated successfully",
note: updatedNote,
});
} catch (error) {
console.log(error);
return res.status(404).json({ message: error });
}
},
), // ==================== DELETE ==================== // Delete one note
DELETE_deleteNote: asyncHandler(
async (req: Request, res: Response): Promise<any> => {
const body: { id: string } = req.body;
if (typeof body.id !== "string") {
return res.status(400).json({ message: "'id' must be a string" });
}
if (body.id === undefined) {
return res.status(400).json({ message: "id must not be empty" });
}
try {
// Search by id, if note exist delete note, else return error
const deletedNote = await deleteNote(body.id);
return res.json({
message: "Note deleted successfully",
note: deletedNote,
});
} catch (error) {
console.log(error);
return res.status(404).json({ message: error });
}
},
),
};
export default notesController;
Por ultimo la función para comprobar si es valido es la siguiente.
import { type notesType } from "../../types/dict"; // Importamos los tipos
function isNote(note: object | notesType): note is notesType {
// Comprobamos qu sea una nota
return (note as notesType).id !== undefined;
}
export const isValid = (args: object): boolean => {
if (isNote(args)) {
// Si es una nota valida
if (typeof args.id !== "string") {
// Buscamos que contenga un ID
console.log("no string");
return false;
}
}
return Object.values(args).every(
(value) => value !== null && value !== undefined && value?.trim() !== "", // Sino comprobamos que los datos sean validos
);
};
Testing
Siempre es buena practica crear tests para nuestra API, para ello crearemos los mismos en el directorio src/__TESTS__/
.
Para empezar crearemos los tests de las rutas en el siguiente directorio src/__TESTS__/routes/notesRoutes.test.ts
, el cual quedará de la siguiente manera.
import { spec, request } from "pactum"; // Importamos los módulos
describe("notes", async () => {
request.setBaseUrl("http://localhost:5000"); // Creamos la base
describe("GET /api/notes -- Get notes", () => {
// Y las urls completas
// Get all notes
it("should return 200", async () => {
const res = await spec().get("/api/notes").toss();
expect(res.statusCode).toBe(200);
});
it("should return a json object", async () => {
const res = await spec().get("/api/notes").toss();
expect(res.headers["content-type"]).toContain("application/json");
});
});
describe("POST /api/notes -- Create notes", () => {
it("should throw if data is not passed", async () => {
const res = await spec()
.post("/api/notes")
.withBody({ title: "test" })
.toss();
expect(res.statusCode).toBe(400);
});
});
describe("PUT /api/notes/ -- Update notes", () => {
it("should throw if id is not passed", async () => {
const res = await spec().put("/api/notes").toss();
expect(res.statusCode).toBe(400);
});
it("should throw if id is passed but note is not found", async () => {
const res = await spec()
.put("/api/notes")
.withBody({ id: "1", title: "test", content: "test" });
expect(res.statusCode).toBe(404);
});
it("should throw if data is invalid", async () => {
const dataOptions = [
{
id: "fabb82f1-a689-4875-a3b4-f4550e9e7f57",
title: "",
},
{
id: "fabb82f1-a689-4875-a3b4-f4550e9e7f57",
title: null,
},
{
id: "fabb82f1-a689-4875-a3b4-f4550e9e7f57",
content: "",
},
{
id: "fabb82f1-a689-4875-a3b4-f4550e9e7f57",
title: "",
content: "",
},
{
id: "fabb82f1-a689-4875-a3b4-f4550e9e7f57",
title: "test",
content: "",
},
{
id: "fabb82f1-a689-4875-a3b4-f4550e9e7f57",
title: "",
content: "test",
},
{
id: 1,
title: "",
content: "test",
},
];
for (let index = 0; index < dataOptions.length; index++) {
const element = dataOptions;
const res = await spec().put("/api/notes").withBody(element);
expect(res.statusCode).toBe(400);
}
});
});
describe("DELETE /api/notes/ -- Delete notes", () => {
it("should throw if id is not passed", async () => {
const res = await spec().delete("/api/notes").toss();
expect(res.statusCode).toBe(400);
});
it("should throw if note is not found", async () => {
const res = await spec().delete("/api/notes").withBody({ id: "1" });
expect(res.statusCode).toBe(404);
});
});
describe("Test full functionality", () => {
it("should create, edit, and delete a note", async () => {
// create a note
const data = {
title: "test",
content: "test",
};
const createNoteRes = await spec()
.post("/api/notes")
.withBody(data)
.toss();
expect(createNoteRes.statusCode).toBe(200);
expect(createNoteRes.body).toBeInstanceOf(Object); // get all notes
const getNotesRes = await spec().get("/api/notes");
expect(getNotesRes.statusCode).toBe(200);
expect(getNotesRes.body).toBeInstanceOf(Array); // edit created note
const dataToEdit = {
id: createNoteRes.body.note.id,
title: "edit",
content: "edit",
};
const editNoteRes = await spec()
.put("/api/notes")
.withBody(dataToEdit)
.toss();
expect(editNoteRes.statusCode).toBe(200);
expect(editNoteRes.body.note.title).toBe("edit"); // delete created note
const deleteNoteRes = await spec()
.delete("/api/notes")
.withBody({ id: createNoteRes.body.note.id })
.toss();
expect(deleteNoteRes.statusCode).toBe(200);
expect(deleteNoteRes.body).toBeInstanceOf(Object);
});
});
});
Además podemos comprobar la validación individualmente creando el directorio __TESTS__/services/functions.test.ts
, el cual quedará de la siguiente manera.
import { isValid } from '../../services/functions' // Importamos la función
describe('functions', () => { // Creamos los tests
it('should be false if data is not passed', () => { // Si no se pasa ningún dato
expect(isValid({})).toBe(false)
})
it('should be false if data is empty or invalid', () => { // Si los datos son inválidos
const dataOptions = [
{
title: '',
content: 'test',
},
{
title: '',
content: '',
},
{
title: null,
content: '',
},
{
title: '',
content: null,
},
{
title: 'valid',
content: '',
},
{
title: '',
content: 'valid',
},
{
title: null,
content: 'valid',
},
{
title: null,
content: null,
},
]
dataOptions.forEach(data => {
expect(isValid(data)).toBe(false)
})
})
it('should be true if data is valid', () => { // Y si los datos son validos
const testData = [
{
id: 'fabb82f1-a689-4875-a3b4-f4550e9e7f57',
title: 'test',
content: 'test',
},
{
title: 'test',
content: 'test',
},
]
expect(isValid(testData)).toBe(true)
}
})