This is an old revision of the document!
Table of Contents
Desarrollo de aplicaciones: backend + frontend
Configuración mínima de un proyecto
Crear un fichero package.json:
{ "name": "cities-backend", "version": "0.1", "description": "A sample project to learn Node.js", "type": "module", "scripts": { "start": "node src/app.js" }, "author": "Santiago Faci", "license": "GPL-2.0-only", "dependencies": { "express": "^4.21.1", "cors": "^2.8.6" } }
Algunos comandos útiles:
npm init: Crear un ficheropackage.jsonde forma interactiva (si se añade–yesse rellenará directamente con valores por defecto)npm run: Lista los scripts disponiblesnpm run <nombre_script: Ejecuta el script cuyo nombre se para como parámetronpm start: Ejecuta el scriptstart(se puede ejecutar sin añadirrunal tratarse de uno de los scripts definidos por nodejs)
Gestión de dependencias
Añadir las dependencias necesarias para el proyecto al fichero package.json en la sección dependencies (se pueden ir añadiendo a medida que son necesarias durante el desarrollo del proyecto):
. . . "dependencies": { "express": "^4.21.1" } . . .
Instalar todas las dependencias necesarias para el proyecto:
santi@localhost:$ npm install
Actualizar las dependencias necesarias para el proyecto, si es necesario:
santi@localhost:$ npm update
También se pueden instalar una dependencia concreta y ésta será añadida automáticamente al fichero package.json como tal:
santi@localhost:$ npm install express
Backend
¿Qué es una API?
API sencilla sólo lectura
En un proyecto sencillo como este donde solamente vamos a desarrollar un backend sencillo, la estructura quedará algo asi:
- src/app.js
import express from 'express'; import cors from 'cors'; const app = express(); app.use(cors()); app.use(express.json()); const cities = { 'Zaragoza': { altitude: 199, population: 673010 }, 'Huesca': { altitude: 488, population: 53305 }, 'Teruel': { altitude: 915, population: 25900 } }; app.get('/cities', (req, res) => { res.json(cities); }); app.get('/cities/:city', (req, res) => { const city = req.params.city; res.json(cities[city]); }); app.listen(8080, () => { console.log('Iniciando el backend en el puerto 8080'); });
Asumiendo que tenemos un fichero package.json como el explicado anteriormente, podremos ejecutar la API con el siguiente comando:
santi@localhost:$ nodemon
Y tendremos los siguiente endpoints disponibles:
- http://localhost:8080/cities: Para ver el listado de todas las ciudades en formato JSON
- http://localhost:8080/cities/Zaragoza: Para ver los datos de una ciudad concreta (en este caso Zaragoza pero podríamos indicar cualquiera de las existentes)
También podemos utilizar aplicaciones como Postman que son específicas para desarrolladores. Son clientes diseñados para realizar llamadas a APIs y testear que éstas funcionan correctamente.
Frontend
Para el desarrollo de un frontend, desarrollaremos otra aplicación con su propio package.json y las dependencias que necesitaremos:
- package.json
{ "name": "cities-frontend", "version": "0.1", "source": "src/index.html", "type": "module", "scripts": { "start": "parcel --no-cache", "build": "parcel build" }, "devDependencies": { "buffer": "^6.0.3", "parcel": "latest", "process": "^0.11.10" }, "dependencies": { "axios": "^1.7.7" } }
Recordad instalar las dependencias antes de iniciar el proyecto:
santi@localhost:$ npm install
Y también de instalar parcel, que es otra dependencia un tanto particular que debe ser instalada como comando del sistema operativo (si estáis en linux es probable que tengáis que hacerlo como usuario root):
santi@localhost:$ npm install -g parcel
parcel es un comando que nos permitirá construir (build) el frontend muy automáticamente (HTML + JS) como una única aplicación. También, al arrancarlo con npm start, hará que la aplicación frontend quede escuchando automáticamente en el puerto 1234 (puerto por defecto).
Nuestro cliente estará basado, por el momento, en una única página HTML donde mostraremos los datos:
- index.html
<!DOCTYPE html> <html> <head> <title>Ejemplo 1: API con Node.js</title> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous"> <script type="module" src="index.js"></script> </head> <body onload="readCities()"> <div class="container"> <h1>Ejemplo de API con Node.js</h1> <img src="nodejs.png"/> <ul id="cities" class="list-group"> </ul> </div> </body> </html>
Y este será nuestro código para llamar al backend, conseguir los datos y mostrarlos en el documento HTML anterior:
- index.js
import axios from 'axios'; function addCityNode(name) { const citiesUl = document.getElementById('cities'); const item = document.createElement('li'); item.className = 'list-group-item'; item.appendChild(document.createTextNode(name)); citiesUl.appendChild(item); }; window.readCities = function() { axios.get('http://localhost:8080/cities') .then((response) => { const cityList = response.data; Object.keys(cityList).forEach(cityName => { addCityNode(cityName); }); }); };
Tal y como hacemos con el backend, podremos ejecutar este frontend con el siguiente comando:
santi@localhost:$ npm start
Hay que tener en cuenta que backend y frontend deben escuchar en puertos diferentes para poder ejecutarse al mismo tiempo. En nuestro caso el backend escucha en el puerto 8080 y el frontend en el 1234 (es el puerto por defecto en el que escuchará porque estamos usando parcel para lanzarlo)
CRUD Completo Backend
GET /citiesDevuelve el listado de todas las ciudadesGET /cities/:cityDevuelve el detalle de una ciudadPOST /citiesRegistra una nueva ciudadDELETE /cities/:cityEliminar una ciudad (en caso de que la ciudad especificada no exista, se devuelve un mensaje de error)PUT /cities/:cityModifica los datos de una ciudad existente
import express from 'express'; import cors from 'cors'; const app = express(); app.use(cors()); app.use(express.json()); const cities = { 'Zaragoza': { altitude: 199, population: 673010 }, 'Huesca': { altitude: 488, population: 53305 }, 'Teruel': { altitude: 915, population: 25900 } }; app.get('/cities', (req, res) => { res.status(200).json(cities); }); app.get('/cities/:city', (req, res) => { const city = req.params.city; res.status(200).json(cities[city]); }); app.post('/cities', (req, res) => { const name = req.body.name; const altitude = req.body.altitude; const population = req.body.population; cities[name] = { altitude: altitude, population: population }; res.status(201).json(cities[name]); }); app.delete('/cities/:city', (req, res) => { const name = req.params.city; if (cities[name] === undefined) { res.status(404).json({ error: 'No se ha podido encontrar la ciudad' }); } delete cities[name]; res.status(204).end(); }); app.put('/cities/:city', (req, res) => { const name = req.params.city; const altitude = req.body.altitude; const population = req.body.population; cities[name] = { altitude: altitude, population: population }; res.status(200).json(cities[name]); }); app.listen(8080, () => { console.log('Iniciando el backend en el puerto 8080'); });
CRUD Backend + Frontend
Una vez que hemos visto una sencilla API con unicamente un par de operaciones de lectura, vamos a ver un ejemplo algo más completo. Es lo que también se conoce como CRUD que es el acrónimo que se utiliza para referirse a las 4 operaciones básica que permite que los usuarios puedan registrar (Create), leer (Read), modificar (Update) y borrar (Delete) información utilizando algún tipo de software.
En el caso de la API (backend) deberán ser diseñadas conforme a las convenciones que vimos un poco antes cuando explicamos lo que era una API.
En este caso necesitamos dos proyectos separados: backend y frontend. Asi, tendremos ambos lados de la aplicación (servidor y cliente) totalmente separados. La estructura del proyecto completo será algo asi:
Backend
Vamos a ampliar nuestro backendo con una nueva operación POST que permite añadir nuevas ciudades al listado que tenemos:
- backend/src/app.js
import express from 'express'; import cors from 'cors'; const app = express(); app.use(cors()); app.use(express.json()); const cities = { 'Zaragoza': { altitude: 199, population: 673010 }, 'Huesca': { altitude: 488, population: 53305 }, 'Teruel': { altitude: 915, population: 25900 } }; app.get('/cities', (req, res) => { res.json(cities); }); app.get('/cities/:city', (req, res) => { const city = req.params.city; res.json(cities[city]); }); app.post('/cities', (req, res) => { const name = req.body.name; const altitudeValue = req.body.altitude; const populationValue = req.body.population; cities[name] = { altitude: altitudeValue, population: populationValue }; res.status(201).end(); }); app.listen(8080, () => { console.log('Iniciando el backend en el puerto 8080'); });
Frontend
Y en el lado frontend añadiremos un formulario que permita introducir los datos necesarios para luego añadir esa ciudad y sus detalles a la lista que tenemos:
- frontend/src/index.html
<!DOCTYPE html> <html> <head> <title>Ejemplo 1: API con Node.js</title> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous"> <script type="module" src="index.js"></script> </head> <body onload="readCities()"> <div class="container"> <h1>Ejemplo de API con Node.js</h1> <img src="nodejs.png"/> <ul id="cities" class="list-group"> </ul> <hr/> <form> <div class="mb-3"> <label for="name" class="form-label">Nombre</label> <input class="form-control" type="text" id="name"/> </div> <div class="mb-3"> <label for="altitude" class="form-label">Altitud</label> <input class="form-control" type="text" id="altitude"/> </div> <div class="mb-3"> <label for="population" class="form-label">Población</label> <input class="form-control" type="text" id="population"/> </div> <button type="button" class="btn btn-primary" onClick="addCity()">Add city</button> </form> </div> </body> </html>
En la lógica del frontend añadimos lo necesario para recoger los datos del formulario e invocar al backend para “registrar” la ciudad con sus detalles:
- frontend/src/index.js
import axios from 'axios'; function addCityNode(name) { const citiesUl = document.getElementById('cities'); const item = document.createElement('li'); item.className = 'list-group-item'; item.appendChild(document.createTextNode(name)); const button = document.createElement('button'); button.className = 'btn-close' button.onclick = function() { removeCity(name); item.remove(); }; item.appendChild(button); citiesUl.appendChild(item); }; window.readCities = function() { axios.get('http://localhost:8080/cities') .then((response) => { const cityList = response.data; Object.keys(cityList).forEach(cityName => { addCityNode(cityName); }); }); }; window.addCity = function() { const name = document.getElementById('name').value; const altitude = document.getElementById('altitude').value; const population = document.getElementById('population').value; if (name === '') { alert('El nombre es un campo obligatorio'); return; } axios.post('http://localhost:8080/cities', { name: name, altitude: altitude, population: population }).then(() => { addCityNode(name); }); }; window.removeCity = function(name) { console.log(name + ' was removed'); // TODO Remove the city };
En el frontend veremos que también hemos añadido una X a modo de botón al lado de cada ciudad. Como ejercicio nos queda pendiente hacer que al pulsar en ese botón, la ciudad se elimine (también en el backend)
API con CRUD completo y base de datos
Backend: API
El primer paso será preparar el fichero de configuración de la base de datos, indicando el tipo (SQLite) y la ubicación (el fichero 'cities.db').
- src/configuration/database.js
import knex from 'knex'; // Configuración de la base de datos: tipo, ubicación y otros parámetros export const db = knex({ client: 'sqlite3', connection: { filename: 'cities.db' }, useNullAsDefault: true });
En el fichero principal simplemente añadiremos las operaciones definidas en el fichero 'route/cities.js' e iniciaremos el backend.
- src/app.js
import express from 'express'; import { citiesRouter } = from './route/cities.js'; const app = express(); app.use(express.json()); app.use('/', citiesRouter); app.listen(8080, () => { console.log('Iniciando el backend en el puerto 8080'); });
El fichero 'routes/cities.js' contiene la lista de todas las operaciones definidas en este backend y que tendremos que implementar correctamente en la capa controller:
- src/route/cities.js
import express from 'express'; const citiesRouter = express.Router(); import { getCities, getCity, postCity, putCity, deleteCity } from '../controller/cities.js'; citiesRouter.get('/cities', getCities); citiesRouter.get('/cities/:city', getCity); citiesRouter.post('/cities', postCity); citiesRouter.put('/cities/:city', putCity); citiesRouter.delete('/cities/:city', deleteCity); export { citiesRouter };
La capa controller implementa todas las operaciones que se han definido en la capa routes invocando a la capa service que será donde desarrollaremos toda la lógica. En esta capa controller simplemente leeremos la petición (que puede tener o no algún parámetro), validaremos dicha petición y sus parámetros e invocaremos a la capa service para ejecutar toda la lógica asociada a la misma:
- src/controller/cities.js
import { findAllCities, findCity, registerCity, modifyCity, removeCity} from '../service/cities.js'; // Operación que devuelve todas las ciudades de la base de datos const getCities = (async (req, res) => { const data = await findAllCities(); res.status(200).json(data); }); // Operación que devuelve una ciudad determinada const getCity = (async (req, res) => { const data = await findCity(req.params.city); res.status(200).json(data); }); // Operación que registra una nueva ciudad en la base de datos const postCity = (async (req, res) => { await registerCity(req.body.name, req.body.population, req.body.altitude); res.status(201).json({}); }); // Operación que modifica una ciudad en la base de datos const putCity = (async (req, res) => { await modifyCity(req.params.city, req.body.population, req.body.altitude); res.status(204).json({}); }); // Operación que elimina una ciudad de la base de datos const deleteCity = (async (req, res) => { await removeCity(req.params.city); res.status(204).json({}) }); export { getCities, getCity, postCity, putCity, deleteCity, };
En nuestro caso, en la capa service, será donde accedamos a la base de datos a por la información requerida, hagamos la operaciones de lógica necesarias (si las hay) y devolveremos la información a la capa anterior (controller) para que ésta la devuelva a su superior (que será quien haya realizado la petición, el frontend):
- src/service/cities.js
import { db } from '../configuration/database.js'; // Operación que devuelve todas las ciudades de la base de datos const findAllCities = (async () => { const result = await db('cities').select('*'); return result; }); // Operación que devuelve una ciudad determinada const findCity = (async (cityName) => { const result = await db('cities').select('*').where({name: cityName}).first(); return result; }); // Operación que registra una nueva ciudad en la base de datos const registerCity = (async (cityName, cityPopulation, cityAltitude) => { const result = await db('cities').insert({ name: cityName, population: cityPopulation, altitude: cityAltitude }); return result; }); // Operación que modifica una ciudad en la base de datos const modifyCity = (async (cityName, cityPopulation, cityAltitude) => { const result = await db('cities').where({ name: cityName }).update({ population: cityPopulation, altitude: cityAltitude }); return result; }); // Operación que elimina una ciudad de la base de datos const removeCity = (async (cityName) => { const result = await db('cities').del().where({name: cityName}); return result; }); export { findAllCities, findCity, registerCity, modifyCity, removeCity, };
Frontend: Utilidades
- documentUtils.js
const icons = new Map(); icons.set('edit', '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pencil" viewBox="0 0 16 16">' + '<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325"/>' + '</svg>' ); icons.set('delete', '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">' + '<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0z"/>' + '<path d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4zM2.5 3h11V2h-11z"/>' + '</svg>' ); // TODO Añadir más iconos const el = function (elementId) { return document.getElementById(elementId); }; const icon = function(iconName) { return icons.get(iconName); }; const td = function(text) { return '<td>' + text + '</td>'; } module.exports = { el, icon, td };
Frontend: carga de datos en una tabla
- index.html
<!DOCTYPE html> <html> <head> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous"> <script type="module" src="index.js"></script> </head> <body onload="readCities()"> <div class="container"> <ul class="nav nav-tabs"> <li class="nav-item"> <a class="nav-link active" aria-current="page" href="index.html">Listado</a> </li> <li class="nav-item"> <a class="nav-link" href="registro.html">Registro</a> </li> </ul> <h1>Listado de ciudades</h1> <table class="table"> <thead> <tr> <th>Nombre</th> <th>Población</th> <th>Área</th> <th>. . .</th> </tr> </thead> <tbody id="tableBody"> </tbody> </table> </ul> </div> </body> </html>
- index.js
import axios from 'axios'; import { el, icon, td } from './documentUtil'; import { notifyOk } from './dialogUtil'; window.readCities = function() { axios.get('http://localhost:8080/cities') .then((response) => { const cityList = response.data; const cityTable = el('tableBody'); cityList.forEach(city => { const row = document.createElement('tr'); row.id = 'city-' + city.id; row.innerHTML = td(city.name) + td(city.population) + td(city.area) + '<a class="btn btn-warning" href="modify.html?id=' + city.id + '">' + icon('edit') + '</a> ' + '<a class="btn btn-danger" href="javascript:removeCity(' + city.id + ')">' + icon('delete') + '</a>'; cityTable.appendChild(row); }) }); }; window.removeCity = function(id) { if (confirm('¿Está seguro de que desea eliminar esta ciudad?')) { axios.delete('http://localhost:8080/cities/' + id) .then((response) => { if (response.status == 204) { notifyOk('Ciudad eliminada correctamente'); el('city-' + id).remove(); } }); } };
Frontend: Diálogos
- dialogUtils.js
import Toastify from 'toastify-js'; import 'toastify-js/src/toastify.css'; const notifyError = function(message) { Toastify({ text: message, duration: 3000, gravity: 'top', position: 'center', style: { background: "red", }, }).showToast(); }; const notifyOk = function(message) { Toastify({ text: message, duration: 3000, gravity: 'top', position: 'center', style: { background: "green", }, }).showToast(); }; module.exports = { notifyError, notifyOk };
Frontend: formulario de registro
- register.html
<!DOCTYPE html> <html> <head> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous"> <script type="module" src="register.js"></script> </head> <body> <div class="container"> <ul class="nav nav-tabs"> <li class="nav-item"> <a class="nav-link" aria-current="page" href="index.html">Listado</a> </li> <li class="nav-item"> <a class="nav-link active" href="register.html">Registro</a> </li> </ul> <h1>Registro ciudades</h1> <form action="http://localhost:8080/cities"> <div class="mb-3"> <label for="title" class="form-label">Nombre</label> <input class="form-control" type="text" id="name"/> </div> <div class="mb-3"> <label for="description" class="form-label">Población</label> <input class="form-control" type="text" id="population"/> </div> <div class="mb-3"> <label for="year" class="form-label">Area</label> <input class="form-control" type="text" id="area"/> </div> <button type="button" class="btn btn-primary" onClick="addCity()">Registrar</button> </form> </div> </body> </html>
- register.js
import axios from 'axios'; import { notifyError, notifyOk } from './dialogUtil.js'; import { el } from './documentUtil.js'; window.addCity = function() { const name = el('name').value; const population = el('population').value; const area = el('area').value; // TODO Validación de datos if (name === '') { notifyError('El nombre es un campo obligatorio'); return; } axios.post('http://localhost:8080/cities', { name: name, population: population, area: area }); // TODO Confirmar al usuario que todo ha ido bien (o mal) notifyOk('Ciudad registrada'); // TODO Limpiar el formulario el('name').value = ''; el('population').value = ''; el('area').value = ''; };
Funciones de utilidad
function getDays(fromDate, toDate) { const days = toDate.getTime() - fromDate.getTime(); return Math.abs(Math.round(days / (1000 * 3600 * 24))); } function getDaysFromNow(date) { const now = new Date(Date.now()); return getDays(date, now); } function getYearsFromNow(date) { const now = new Date(Date.now()); return Math.ceil(getDays(date, now) / 365); } module.exports = { getDaysFromNow, getYearsFromNow, getDays }
MariaDB como base de datos
const knex = require('knex'); . . . const { config } = require('../config/configuration'); // Configuración de la base de datos: tipo, ubicación y otros parámetros const db = knex({ client: 'mysql', connection: { host: config.db.host, port: config.db.port, user: config.db.user, password: config.db.password, database: config.db.database }, useNullAsDefault: true });
Ejercicios
- Implementa una API que ofrezca información sobre peliculas. De cada pelicula se guardará: titulo, argumento, fecha de estreno, categoria, duracion en minutos, número de sala, si se ofrece o no en IMAX y pais de origen. Deberá tener las siguientes operaciones:
- Listados de todas las peliculas con todos los detalles
- Detalle de cada pelicula (por el titulo)
- Registro de nuevas peliculas
- Baja de peliculas
- Actualización de las peliculas existentes
- Añade control de errores a tu API de peliculas:
- La operación que muestra el detalle de una pelicula devolverá un mensaje de error cuando la pelicula no exista
- La operación de registro devolverá un mensaje de error si ya exista una pelicula con el nombre de la que se quiere dar de alta
- La operación de baja de peliculas devolverá un mensaje de error si la pelicula no existe
- La operación de actualización devolverá un mensaje de error si la pelicula indicada no existe
- Controlar también las siguientes restricciones, devolviendo un mensaje de error cuando no se cumplan:
- La duración de una película es siempre un número mayor que cero
- El titulo y el argumento son campos obligatorios a la hora de registrar una pelicula
- El campo sala solamente podrá ser un valor entre 1 y 9 (que son las salas que tiene el cine)
Proyectos de ejemplo
Todos los proyectos de ejemplo de esta parte están en el repositorio nodejs de GitHub.
- cities-backend: API sencilla. Sólo backend. Sin base de datos
- cities-backend-db: API con CRUD completo. Sólo backend. Los datos están almacenados en una base de datos SQLite
- cities-backend-db-routes: API con CRUD completo. Sólo backend. Los datos están almacenados en una base de datos SQLite. El código está organizado por capas (route, controller, service)
- cities-backend-frontend: API sencilla. Incluye un frontend HTML + Javascript con llamadas al backend usando axios. Sin base de datos
- cities-backend-frontend-crud: API sencilla con operación de registro. Incluye un frontend HTML + Javascript con llamadas al backend usando axios. Sin base de datos
- cities_collection.json: Colección de requests para usar con Hoppscotch y cualquiera de los proyectos cities-backend
© 2024 Santiago Faci

