Scopul sedintei:
Filmuletul cu prezentarea poate fi gasit la acest link
Un REST API poate fi structurat, cel mai des, in doua moduri:
Noi am ales o impartire bazata pe context. Fiecare context din cadrul serviciului are propriul lui folder (de exemplu, folderul authors). O impartire pe functionalitate implica foldere dedicate functionaltiatilor (de exemplu, folder controllers, folder services, etc…).
Puteti pleca de la urmatorul schelet de cod. Sunt implementate urmatoarele:
In scriptul de start se initializeaza serverul. Deoarece express se bazeaza pe conceptul de middlewares, adica lanturi de functii, initializarea nu inseamna nimic mai mult decat mai multe functii ale unor pachete puse impreuna, la inceput.
const express = require('express'); // pachet folosit pentru partea de REST API server const morgan = require('morgan'); // pachet folosit pentru formatarea log-urilor const helmet = require('helmet'); // pachet folosit pentru adaugarea unor headere de securitate const createError = require('http-errors'); // pachet folosit pentru trimiterea de erori require('express-async-errors'); // pachet folosit pentru captarea erorilor pe rute. In spate, de fapt, face un try {} catch {} pe fiecare ruta definita de noi si apeleaza next() in cazul in care intalneste o eroare. Este util pentru a nu scrie try...catch de fiecare data require('log-timestamp'); // pachet folosit pentru injectarea timpului in momenul in care se da console.log() const routes = require('./routes.js'); // fisierul (scris de noi) care are configurate toate rutele const app = express(); // instantierea serverului efectiv app.use(helmet()); // adaugarea primului middleware, cel oferit de pachetul helmet app.use(morgan(':remote-addr - :remote-user [:date[web]] ":method :url HTTP/:http-version" :status :res[content-length]')); // adaugarea celui de-al doilea middleware, cel oferit de pachetul morgan app.use(express.json()); // adaugarea celui de-al treilea middleware, cel care extrage obiecte JSON din corpul cererilor. Util pentru POST si PUT app.use(express.urlencoded({ extended: false })); // adaugarea celui de-al patrulea middleware, cel care extrage obiecte x-www-urlencoded din corpul cererilor. Util pentru POST si PUT, daca nu se foloseste JSON app.use('/api', routes); // adaugarea rutelor configurate de noi in lantul de rute, cu radacina /api app.use((err, req, res, next) => { console.error(err); let status = 500; let message = 'Something Bad Happened'; if (err.httpStatus) { status = err.httpStatus; message = err.message; } return next(createError(status, message)); }); // adaugarea unui middleware ce intercepteaza erorile si foloseste createError pentru incapsularea lor const port = process.env.PORT || 80; // stabilirea portului pe care va rula serverul in functie de variabila de mediu. Daca nu exista, este implicit 80 app.listen(port, () => { console.log(`App is listening on ${port} and running on ${process.env.NODE_ENV} mode`); }); // deschiderea serverului pe portul stabilit
Acest fisier are rol de a centraliza toate controllerele folosite in aplicatie si de a le servi sub forma unui obiect Router care este, apoi, consumat in start.js.
const Router = require('express').Router(); // instantierea obiectului Router din biblioteca express. Ajuta la rutare modulara const authorsControllers = require('./authors/controllers.js'); // controllerul folosit pentru autori Router.use('/authors', authorsControllers); // adaugarea controllerului de autori in rutele obiectului Router, cu radacina /authors module.exports = Router; // expunerea obiectului Router pentru a putea fi folosit de alte fisiere. Bineinteles, dupa cum ati vazut, este folosit in start.js
Baza de date este PostgreSQL, asa ca, pentru interactiune, am utilizat un driver facut pentru NodeJS, care se numeste pg. PG ofera posibilitatea scrierii de cereri SQL ad-hoc. Se puteau folosi si alte pachete, care abstractizeaza acest proces, precum Knex.js, insa, pentru simplitate, am optat pe SQL “de mana”.
Scopul acestui fisier este de a initializa conexiunea cu baza de date si de a oferi, catre public, o functie (query) prin care se pot trimite cereri SQL catre baza de date.
const { Pool } = require('pg'); // preluarea obiectului Pool din pachetul pg care initializeaza conexiunea cu baza de date const { getSecret } = require('docker-secret'); // folosit mai tarziu, o sa vedeti intr-o sedinta viitoare ;) const options = { host: process.env.PGHOST, database: process.env.PGDATABASE, port: process.env.PGPORT, user: process.env.NODE_ENV === 'development' ? process.env.PGUSER : getSecret(process.env.PGUSER_FILE), password: process.env.NODE_ENV === 'development' ? process.env.PGPASSWORD : getSecret(process.env.PGPASSWORD_FILE) } // preluarea informatiilor de conectare din variabilele de mediu const pool = new Pool(options); // realizarea conexiunii folosind informatiile de conectare si obiectul Pool const query = async (text, params) => { const { rows, } = await pool.query(text, params); return rows; }; // crearea functiei de interactiune cu baza de date. Functia are doi parametri, un string, care reprezinta cererea SQL si un vector de valori, din care sunt preluate valorile si injectate in cerere. Atentie, functia va returna **intotdeauna un vector**. module.exports = { query, }; // expunerea functiei
Cateodata dorim sa avem anumite campuri adaugate in plus, in obiectele de eroare. Pentru acest lucru, trebuie extinsa clasa de erori din js (Error) si creata o clasa noua ce contine campurile dorite.
class ServerError extends Error { // extinderea clasei Error constructor(message, httpStatus) { // cele doua campuri pe care le va contine clasa super(message); // campul message este continut si de Error, asa ca se poate trimite mai sus, pe lantul de mostenire, prin apelul super() this.name = this.constructor.name; this.httpStatus = httpStatus; // campul httpStatus este un camp nou, asa ca il vom initaliza aici Error.captureStackTrace(this, this.constructor); // util pentru debug } } module.exports = { ServerError, }; //expunerea clasei public
Deoarece am ales o structura bazata pe context, fiecare context are un set de controllere si servicii.
Controllerele au rolul de a descrie logica de rutare si verificare a parametrilor.
Pentru cod modular, am folosit si aici obiectul Router.
const Router = require('express').Router(); // initializarea obiectului Router, ce permite crearea de rute in mod modular const { ServerError } = require('../errors'); // clasa de erori custom, definita mai sus const { getAll, getById, add, update, remove } = require('./services.js'); // functiile din servicii Router.get('/:id', async (req, res) => { // ruta de GET /api/authors/:id. Returneaza informatiile despre autorul cu id-ul dat in parametri const { id } = req.params; // preluarea parametrului id din parametrii de cerere. Observati propritatea **.params** a obiectului **req** const author = await getById(id); // se apeleaza functia respectiva din servicii res.json(author); // se returneaza autorul, sub forma de obiect json. Express face acest lucru usor, prin metoda **.json()** a obiectului **res** }); Router.get('/', async (req, res) => { // ruta de GET /api/authors. Returneaza toti autorii const authors = await getAll(); // se apeleaza functia din servicii res.json(authors); // se returneaza autorii sub forma de obiect json }); Router.post('/', async (req, res) => { // ruta de POST /api/authors. Ruta pentru adaugarea unui autor const { nume } = req.body; // se preia campul **nume** din corpul cererii if (typeof nume !== 'string') { throw new ServerError('Nume invalid!', 400); } // se verifica daca este de tip string. Altfel, se arunca o eroare custom. Eroarea este interceptata de pachetul **express-async-errors** si redirectionata prin lantul de middlewares catre handler-ul creat de noi in start.js. const id = await add(nume); // se apeleaza functia din servicii res.json({id, nume}); // se returneaza autorul nou creat, sub forma id-ului sau din baza de date si a numelui. }); Router.put('/:id', async (req, res) => { // ruta de PUT /api/authors/:id. Ruta pentru modificarea informatiilor unui autor cu id-ul dat in parametri const { id } = req.params; // se preia id-ul din parametrii cererii const { nume } = req.body; // se preia noul nume din corpul cererii await update(id, nume); // se apeleaza functia din servicii res.json({id, nume}); // se returneaza autorul sub forma unui obiect ce contine id-ul sau si noul nume }); Router.delete('/:id', async (req, res) => { // ruta de DELETE /api/authors/:id. Ruta pentru stergerea unui autor cu id-ul dat in parametri const { id } = req.params; // se preia id-ul din parametri cererii await remove(id); // se apeleaza functia din servicii res.status(200).end(); // de data aceasta nu se mai intoarce nimic, asa ca se seteaza statusul http de succes (200) prin apelul metodei **.status()** din obiectul **res** si apoi se apeleaza metoda **.end()** care raspunde cererii si, deci, inchide fluxul cerere-raspuns }); module.exports = Router; // se expune obiectul Router in afara pentru a putea fi folosit in fisierul routes.js
Daca controllerele se ocupa de partea de rutare, serviciile se ocupa de partea de computatie si interactiune cu alte module. In cazul nostru, vom interactiona cu functia query din data/index.js
const { query } = require('../data'); // preluarea functiei de acces baza de date const { ServerError } = require('../errors'); // clasa de erori custom, definita mai sus const getAll = async () => { console.info('Getting all authors...'); // indicat sa logati fiecare actiune, in special daca faceti debug const authors = await query('SELECT * FROM authors'); // obtinem toti autorii din baza de date printr-un SELECT return authors; // returnam autorii }; const getById = async (id) => { console.info(`Getting author ${id}...`); const authors = await query('SELECT * FROM authors WHERE id=$1', [id]); // obtinem autorul (sub forma de vector) care are id-ul dat ca parametru. Pentru securitate, in apelul query variabilele sunt inserate la executie, asa ca in locul valorii efective se pune $1. Valoarea este preluata din vectorul de valori trimis ca parametru secundar functiei query. if (authors.length !== 1) { throw new ServerError('Author does not exist!', 404); } // se verifica dimensiunea vectorului de raspuns rezultat. Ar trebui sa fie 1 (autorul cautat de noi). Daca nu este 1, inseamna ca nu exista si returnam un mesaj corespunzator. return authors[0]; // returnam autorul (adica primul - si singurul - element din vectorul de raspuns) }; const add = async (nume) => { console.info(`Adaug un autor cu numele ${nume}`); try { const authors = await query('INSERT INTO authors (nume) VALUES ($1) RETURNING id', [nume]); // adaugam un autor in tabela folosind un apel SQL de INSERT return authors[0].id; // returnam id-ul sau. Din nou, query returneaza un vector, asa ca preluam primul (si singurul) element din vector } catch (e) { // postgres va returna o eroare cu codul 23505 in cazul in care se adauga o informatie duplicat. Acest lucru trebuie tratat manual, printr-un try...catch if (e.code === '23505') { throw new ServerError('Author already exists!', 409); } throw e; } }; const update = async (id, nume) => { console.info(`Modific numele autorului ${id} in ${nume}`); await query('UPDATE authors SET nume=$1 WHERE id=$2', [nume, id]); // actualizam numele autorului, printr-un apel INSERT. Observam ca se pune si $2, pentru ca sunt 2 valori in vectorul de parametri dat ca parametru secundar functiei query }; const remove = async (id) => { console.info(`Sterg autorul ${id}...`); await query('DELETE FROM authors WHERE id=$1', [id]); // stergerea autorului cu id specificat printr-un apel DELETE. } module.exports = { getAll, getById, add, update, remove } // expunerea functiilor public
Variabilele de mediu pot fi injectate prin mai multe moduri, insa cel mai uzual este utilizarea unui fisier .env in care sunt trecute numele variabilelor si valorile lor. In cadrul acestui mic server, avem nevoie de urmatoarele variabile de mediu:
PGHOST=localhost PGUSER=test PGPASSWORD=test PGPORT=5555 PGDATABASE=workshop-library PORT=3000 NODE_ENV=development
Pe langa informatiile uzuale trecute in package.json, ce tin de pachetele folosite, am introdus 3 scripturi, la sectiunea scripts. Rolul lor este sa ne ajute sa diferentiam modurile de rulare ale serverului.
{ "name": "practic", "version": "1.0.0", "description": "", "main": "start.js", "scripts": { "start-dev-docker": "nodemon start.js", // comanda de rulare in interiorul dockerului, rol de development "start-dev": "nodemon -r dotenv/config start.js", // comanda de rulare local, folosind fisierul de variabile de mediu .env si pachetul dotenv pentru injectarea lor "start": "node start.js" // comanda de rulare in interiorul dockerului, in productie }, "author": "", "license": "ISC", "dependencies": { "docker-secret": "^1.1.2", "dotenv": "^8.2.0", "express": "^4.17.1", "express-async-errors": "^3.1.1", "helmet": "^3.23.3", "http-errors": "^1.8.0", "log-timestamp": "^0.3.0", "morgan": "^1.10.0", "nodemon": "^2.0.4", "pg": "^8.3.0" } }
Pentru a nu va pune sa instalati PostgreSQL pe sistemul vostru, si pentru ca traim in 2020, am ales sa folosim docker si docker-compose pentru rularea postgres local. Chiar daca inca nu stiti ce inseamna, nu este nicio problema, peste 3 workshop-uri veti cunoaste toate aceste lucruri.
Pentru rularea bazei de date, trebuie sa dati comanda de mai jos in folderul database-deploy
docker-compose up
Structura bazei de date pentru partea de biblioteca o puteti regasi aici, urmarind tabelele care sunt prefixate cu Library.