====== Desarrollo de aplicaciones: backend + frontend ======
===== Configuración del proyecto =====
Crear un fichero [[https://docs.npmjs.com/creating-a-package-json-file|package.json]]:
{
"name": "cities-backend",
"version": "0.1",
"description": "A sample project to learn Node.js",
"scripts": {
"start": "node src/app.js"
},
"author": "Santiago Faci",
"license": "GPL-2.0-only",
"dependencies": {
"express": "^4.21.1"
}
}
Algunos comandos útiles:
* ''npm init'': Crear un fichero ''package.json'' de forma interactiva (si se añade ''--yes'' se rellenará directamente con valores por defecto)
* ''npm run'': Lista los scripts disponibles
* ''npm run [[https://docs.npmjs.com/cli/v10/configuring-npm/package-json|Especificación completa para el fichero package.json]]
==== Usando nodemon ====
[[https://www.npmjs.com/package/nodemon|nodemon]] es una herramienta que permite que nuestra aplicación nodejs se reinicie automáticamente cada vez que detecta un cambio en el código. De esa manera podemos evitar tener que estar continuamente parando y arrancándola.
Primero tendremos que instalar ''nodemon'' de forma global (de forma que podamos ejecutarlo como un nuevo comando de nuestro equipo):
santi@localhost:$ npm install -g nodemon
A partir de entonces, si tenemos bien configurado el script ''start'' de nuestro proyecto en el fichero ''package.json'', podremos lanzar el proyecto simplemente ejecutando ''nodemon'' y éste monitorizará nuestro código en busca de cambios para reiniciarlo automáticamente. Asi podemos centrarnos simplemente en escribir código y comprobar si éste funciona correctamente:
santi@localhost:$ nodemon
[nodemon] 3.1.7
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,cjs,json
[nodemon] starting `node src/app.js`
Iniciando el backend en el puerto 8080
===== 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:
const express = require('express');
const app = express();
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/city/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 [[https://hoppscotch.com/|Hoppscotch]] 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:
{
"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"
}
}
Nuestro cliente estará basado, por el momento, en una única página HTML donde mostraremos los datos:
Ejemplo 1: API con Node.js
Ejemplo de API con Node.js
Y este será nuestro código para llamar al backend, conseguir los datos y mostrarlos en el documento HTML anterior:
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 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 [[https://en.wikipedia.org/wiki/Create,_read,_update_and_delete|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 [[https://entornos-desarrollo.codeandcoke.com/_detail/apuntes:urls.png?id=apuntes%3Abackend_frontend|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:
const express = require('express');
const cors = require('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
};
console.log(cities);
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:
Ejemplo 1: API con Node.js
Ejemplo de API con Node.js
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:
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)
===== Organización de un proyecto =====
En nuestro caso trabajaremos con las siguientes capas:
* **route**: Se definen las rutas o endpoints disponibles para cada uno de los elementos del modelo de datos
* **controller**: Se definen las funciones que reciben las peticiones de las rutas definidas en la capa ''route''
* **service**: Funciones que definen la lógica de aplicación
A continuación desarrollaremos una API completa (CRUD) utilizando una base de datos y con el código organizado correctamente siguiendo el modelo de capas que se ha planteado anteriormente.
===== 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').
const knex = require('knex');
// Configuración de la base de datos: tipo, ubicación y otros parámetros
const db = knex({
client: 'sqlite3',
connection: {
filename: 'cities.db'
},
useNullAsDefault: true
});
exports.db = db;
En el fichero principal simplemente añadiremos las operaciones definidas en el fichero 'route/cities.js' e iniciaremos el backend.
const express = require('express');
const cities = require('./route/cities.js');
const app = express();
app.use(express.json());
app.use('/', cities);
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//:
const express = require('express');
const router = express.Router();
const { getCities, getCity, postCity, putCity, deleteCity } = require('../controller/cities.js');
router.get('/cities', getCities);
router.get('/cities/:city', getCity);
router.post('/cities', postCity);
router.put('/cities/:city', putCity);
router.delete('/cities/:city', deleteCity);
module.exports = router;
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:
const { findAllCities, findCity, registerCity, modifyCity, removeCity} = require('../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({})
});
module.exports = {
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):
const db = require('../configuration/database.js').db;
// 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;
});
module.exports = {
findAllCities,
findCity,
registerCity,
modifyCity,
removeCity,
};
----
====== Proyectos de ejemplo ======
Todos los proyectos de ejemplo de esta parte están en el repositorio [[https://github.com/codeandcoke/nodejs|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
----
(c) 2024 Santiago Faci