phpMyAdmin ha reso popolare il concetto di un'applicazione web che permette di gestire un database MySQL fornendo un'interfaccia di amministrazione. Partendo da questo esempio, in questo articolo svilupperemo delle API con Node.js che ci consentiranno di svolgere delle operazioni essenziali su un'istanza di MySQL.
Requisiti
- Un'installazione di MySQL.
- Un'installazione di MongoDB.
- Node.js e NPM.
I moduli NPM richiesti
express
body-parser
cors
dotenv
mongoose
mysql2
validator
jsonwebtoken
Il file .env
Questo file di configurazione verrà usato dal modulo dotenv
e servirà a fornire alla nostra applicazione alcuni parametri fondamentali per il suo funzionamento.
# .env
JWTSECRET=secret
JWTEXPIRESIN=604800
DBURI=mongodb://localhost:27017/mysql_client
HASHKEY=KvoF9FIYlg89DeiNbE4JOOlJl6GcQVTJ
HASHIV=8niXOZKtrJnSnXNC
JWTSECRET
: La stringa privata usata per generare un JSON Web Token che consentirà l'accesso alle rotte protette.JWTEXPIRESIN
: Il numero di secondi che imposterà la scadenza del token di autenticazione.DBURI
: La stringa di connessione a MongoDB.HASHKEY
,HASHIV
: Stringhe private per la cifratura e la decifratura delle password di connessione ai database MySQL.
Le collezioni di MongoDB
In MongoDB andremo a definire le due collezioni fondamentali per le nostre API.
users
connections
Con Mongoose definiremo il modello per la collezione users
come segue:
// models/user.js
const { Schema, model } = require('mongoose');
const userSchema = new Schema({
name: String,
email: String,
password: String,
role: {
type: String,
default: 'user',
},
connections: [
{
type: Schema.Types.ObjectId,
ref: 'Connection',
},
]
});
module.exports = model('User', userSchema, 'users');
Il modello della collezione connections
sarà invece il seguente:
// models/connection.js
const { Schema, model } = require('mongoose');
const connectionSchema = new Schema({
host: String,
port: String,
username: String,
password: String,
database: String,
user: {
type: Schema.Types.ObjectId,
ref: 'User',
}
});
module.exports = model('Connection', connectionSchema, 'connections');
Un utente ha una relazione One-To-Many con le connessioni MySQL, rappresentate ciascuna da un ObjectID all'interno dell'array connections
del modello User
. Tramite il suo metodo populate()
, Mongoose ci permetterà all'occorrenza di accedere ai vari documenti di tipo Connection
.
Si tenga sempre presente che con Mongoose una stringa che rappresenta un ObjectID verrà sempre convertita e gestita nel formato richiesto da MongoDB.
Utility
Autenticazione
Per le funzioni di autenticazione abbiamo bisogno di una funzione middleware che convalidi il JSON Web Token passato con la richiesta e un'altra che lo generi con i dati dell'utente che ha effettuato il login.
// utils/auth.js
const jwt = require('jsonwebtoken');
const authMiddleware = (req, res, next) => {
const header = req.headers.authorization;
const token = header && header.split(' ')[1];
if (!token) {
return res.status(401).json({ message: 'Token is required' });
}
jwt.verify(token, process.env.JWTSECRET, (err, decoded) => {
if (err) {
return res.status(401).json({ message: 'Invalid token' });
}
req.user = decoded;
next();
});
};
const generateToken = (data) => {
const expires = parseInt(process.env.JWTEXPIRESIN, 10);
const plainData = { name: data.name, email: data.email };
try {
return jwt.sign(plainData, process.env.JWTSECRET, { expiresIn: expires });
} catch (error) {
return '';
}
};
module.exports = { authMiddleware, generateToken };
Per generare un token con successo, è di fondamentale importanza che l'oggetto JavaScript contenga solo le proprietà essenziali, ossia sia di tipo plain. In questo caso l'oggetto plainData
contiene solo il nome e l'email dell'utente.
Cifratura e hashing
Per cifrare la password della connessione ad un database MySQL e quindi decifrarla all'occorrenza, possiamo usare il modulo core crypto
e l'algoritmo aes-256-cbc
. L'hashing della password di un utente sfrutterà invece l'algoritmo SHA-256.
// utils/hash.js
const crypto = require('crypto');
const algorithm = 'aes-256-cbc';
const key = Buffer.from(process.env.HASHKEY);
const iv = Buffer.from(process.env.HASHIV);
const encrypt = (text) => {
const cipher = crypto.createCipheriv(algorithm, key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return encrypted;
};
const decrypt = (encryptedText) => {
const decipher = crypto.createDecipheriv(algorithm, key, iv);
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
};
const hash = (password) => {
return crypto.createHash('sha256').update(password).digest('hex');
};
module.exports = { encrypt, decrypt, hash };
MySQL
Il modulo mysql2
permette di gestire pool di connessioni. Questa caratteristica è indicata nei casi in cui abbiamo bisogno di connetterci efficientemente a più di un database, come nel nostro caso in cui un utente può salvare più connessioni.
// utils/mysql.js
const mysql = require('mysql2/promise');
const User = require('../models/user');
const { decrypt } = require('./hash');
const getUserConnectionDetails = async (userId, databaseName) => {
try {
const user = await User.findById(userId).populate('connections').exec();
const connections = user.connections.filter((connection) => connection.database === databaseName);
return connections.length > 0 ? connections[0] : null;
} catch (error) {
return null;
}
};
const createMySQLConnectionPool = async (userId, databaseName) => {
const connectionDetails = await getUserConnectionDetails(userId, databaseName);
if (!connectionDetails) {
return null;
}
const { host, port, username, password, database } = connectionDetails;
const decryptedPassword = decrypt(password);
return mysql.createPool({
host,
port,
user: username,
password: decryptedPassword,
database,
});
};
const executeQueryForUser = async (userId, databaseName, query) => {
const pool = await createMySQLConnectionPool(userId, databaseName);
if (!pool) {
return null;
}
const [rows] = await pool.query(query);
pool.end();
return rows;
};
module.exports = { executeQueryForUser };
Come si può notare, dobbiamo innanzitutto recuperare la connessione che corrisponde al database scelto dall'utente. Il pool della connessione viene creato dopo aver effettuato la decifratura della password. La funzione esportata, executeQueryForUser(userId, databaseName, query)
esegue una query sul database selezionato e restituisce le righe trovate come risultato.
Le API
// routes/index.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/users');
const connectionController = require('../controllers/connections');
const auth = require('../utils/auth');
router.post('/users/create', userController.createUser);
router.post('/users/login', userController.loginUser);
router.put('/users/update/:id', auth.authMiddleware, userController.updateUser);
router.delete('/users/delete/:id', auth.authMiddleware, userController.deleteUser);
router.get('/connections/:id/tables', auth.authMiddleware, connectionController.listTablesInUserDatabase);
router.get('/connections/:id/data', auth.authMiddleware, connectionController.listColumnsAndDataInTable);
router.post('/connections/create', auth.authMiddleware, connectionController.createConnection);
module.exports = router;
CRUD User
Le operazioni CRUD (Create,Read,Update,Delete) non presentano particolare interesse e non ci soffermeremo su di esse in questo articolo, limitandoci a riportarle per completezza di informazioni.
// controllers/users.js
const { hash } = require('../utils/hash');
const auth = require('../utils/auth');
const User = require('../models/user');
const createUser = async (req, res, next) => {
try {
const { name, email, password } = req.body;
const hashedPassword = hash(password);
const user = new User({
name,
email,
password: hashedPassword
});
await user.save();
return res.status(201).json(user);
} catch (error) {
return res.status(500).json({ error });
}
};
const loginUser = async (req, res, next) => {
try {
const { email, password } = req.body;
const hashedPassword = hash(password);
const user = await User.findOne({ email, password: hashedPassword });
if (!user) {
return res.status(200).json({ error: 'Invalid credentials' });
}
const token = auth.generateToken(user);
if (!token) {
return res.status(200).json({ error: 'Token generation failed' });
}
return res.status(200).json({ token, email: user.email, name: user.name });
} catch (error) {
return res.status(500).json({ error });
}
};
const updateUser = async (req, res, next) => {
try {
const id = req.params.id;
const body = req.body;
let update = {};
if (body.name) {
update.name = body.name;
}
if (body.email) {
update.email = body.email;
}
if (body.password) {
update.password = hash(body.password);
}
const user = await User.findByIdAndUpdate(id, update, { new: true });
if (!user) {
return res.status(200).json({ error: 'User not found' });
}
return res.status(200).json(user);
} catch (error) {
return res.status(500).json({ error });
}
};
const deleteUser = async (req, res, next) => {
try {
const id = req.params.id;
const user = await User.findByIdAndDelete(id);
if (!user) {
return res.status(200).json({ error: 'User not found' });
}
return res.status(200).json(user);
} catch (error) {
return res.status(500).json({ error });
}
};
module.exports = {
createUser,
loginUser,
updateUser,
deleteUser
};
Operazioni su MySQL
Il primo passo per operare su MySQL è quello di creare una nuova connessione e associarla all'utente che ne ha inserito i parametri.
// controllers/connections.js
const Connection = require('../models/connection');
const validator = require('validator');
const { encrypt } = require('../utils/hash');
const { executeQueryForUser } = require('../utils/mysql');
const User = require('../models/user');
const createConnection = async (req, res, next) => {
const { host, port, username, password, database } = req.body;
if (!host || !port || !username || !password || !database) {
return res.status(200).json({ error: 'All fields are required' });
}
if(host !== 'localhost') {
if(!validator.isIP(host)) {
return res.status(200).json({ error: 'Invalid host' });
}
}
if(!validator.isPort(port)) {
return res.status(200).json({ error: 'Invalid port' });
}
const encryptedPassword = encrypt(password);
const connection = new Connection({
host,
port,
username,
password: encryptedPassword,
database,
user: req.body.id
});
try {
const conn = await connection.save();
const user = await User.findById(req.body.id);
user.connections.push(conn._id);
await user.save();
return res.status(201).json({ message: 'Connection created' });
} catch (err) {
return res.status(500).json({ error: err });
}
};
I dati in ingresso vengono validati dal modulo validator
. Se il parametro host
è diverso da localhost
, viene qui richiesto un indirizzo IP valido. Allo stesso modo viene richiesto un numero valido per il parametro port
, dove per valido si intende un valore numerico compreso nel range di porte standard.
Qui notiamo una limitazione nel nostro codice: non sono di fatto ammessi nomi di host diversi da localhost
e questo potrebbe essere un problema se volessimo connetterci ad un server remoto che, per via della sua configurazione, non accetta un indirizzo IP come parametro.
Soffermiamoci un istante sulla creazione della connessione e sul collegamento alla collection users
:
const conn = await connection.save();
const user = await User.findById(req.body.id);
user.connections.push(conn._id);
await user.save();
Mongoose enfatizza nella sua documentazione l'uso del metodo save()
sia in fase di creazione che di aggiornamento di un documento. Qui conn
ci permette di accedere all'ObjectID del documento appena creato. Allo stesso modo, invece di usare l'approccio con $push
per inserire l'ObjectID nell'array connections
, possiamo modificare l'istanza user
usando un semplice approccio JavaScript e quindi invocare save()
per salvare la modifica.
Definiamo ora un metodo per elencare le tabelle presenti nel database scelto dall'utente.
// controllers/connections.js
const listTablesInUserDatabase = async (req, res, next) => {
const { database } = req.query;
if (!database) {
return res.status(200).json({ error: 'Database name is required' });
}
const query = 'SHOW TABLES';
const tables = await executeQueryForUser(req.params.id, database, query);
const tableNames = tables.map((table) => table[`Tables_in_${database}`]);
if (!tables) {
return res.status(500).json({ error: 'Failed to fetch tables' });
}
return res.status(200).json({ tables: tableNames });
};
La query MySQL che stiamo eseguendo è SHOW TABLES
che nell'implementazione di mysql2
restituisce un array di oggetti contenenti una sola proprietà identica per tutti (Tables_in_${database}
) e su cui dobbiamo applicare map()
per trasformarlo in un array lineare di nomi di tabelle.
Come ultimo passaggio, possiamo elencare le colonne ed i dati presenti in una tabella scelta dall'utente.
// controllers/connections.js
const listColumnsAndDataInTable = async (req, res, next) => {
const { database, table } = req.query;
if (!database || !table) {
return res.status(200).json({ error: 'Database and table names are required' });
}
const page = req.query.page ? parseInt(req.query.page, 10) : 1;
const perPage = 10;
const offset = (page - 1) * perPage
const queryColumns = `SHOW COLUMNS FROM ${table}`;
const columns = await executeQueryForUser(req.params.id, database, queryColumns);
const query = `SELECT * FROM ${table} LIMIT ${offset}, ${perPage}`;
const data = await executeQueryForUser(req.params.id, database, query);
if (!data) {
return res.status(500).json({ error: 'Failed to fetch data' });
}
return res.status(200).json({ columns, data });
};
module.exports = { createConnection, listTablesInUserDatabase, listColumnsAndDataInTable };
La query SHOW COLUMNS FROM ${table}
mostra metadati importanti che possiamo mostrare all'utente. Ad esempio:
const columns = [
{
"Field": "id",
"Type": "int",
"Null": "NO",
"Key": "PRI",
"Default": null,
"Extra": "auto_increment"
},
{
"Field": "title",
"Type": "varchar(255)",
"Null": "NO",
"Key": "",
"Default": null,
"Extra": ""
},
//...
];
Come si può notare, abbiamo tutti i dati necessari per realizzare nel frontend una view che mostri la tabella selezionata.
L'applicazione
// app.js
require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors');
const mongoose = require('mongoose');
const PORT = process.env.PORT || 3000;
const routes = require('./routes');
mongoose.connect(process.env.DBURI);
const app = express();
app.use(cors());
app.use(bodyParser.json());
app.use('/api', routes);
app.listen(PORT);
È possibile passare all'applicazione il numero di porta su cui creare l'istanza semplicemente aggiungendo la variabile PORT
al file .env
.
Conclusione
Abbiamo implementato i fondamenti da cui possiamo estendere la nostra applicazione andando in futuro ad aggiungere altri endpoint con cui gestire i nostri database MySQL. Sicuramente è un'ottimo modo per studiare gli aspetti core di MySQL a cui di solito siamo abituati a pensare come a qualcosa di disponibile solo dalla console.