Sesiuna 3 - Implementarea unui serviciu CRUD simplu

Scopul sedintei:

  • Realizarea unui REST API in NodeJS
  • Interactiunea cu o baza de date PostgreSQL
  • Realizarea actiunilor CRUD peste un set de date

Resurse

Filmuletul cu prezentarea poate fi gasit la acest link

Un REST API poate fi structurat, cel mai des, in doua moduri:

  1. in functie de context
  2. in functie de functionalitate

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:

Scriptul de start (start.js)

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

Fisierul de configurare al rutelor (routes.js)

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

Interactiunea cu baza de date (data/index.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

Obiect custom de erori (errors/index.js)

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
 

Controllerele pentru Authors (authors/controllers.js)

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.

Obiectele req si res sunt obiecte ce vor fi folosite pentru orice cerere. Req vine de la request si contine informatii despre cerere, precum url, parametrii, corp, etc…, in timp ce Res vine de la response si are metode pentru stabilirea statusului, a tipului de raspuns returnat precum si pentru returnarea (si, deci, terminarea actiunii cerere-raspuns) unui raspuns

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

Serviciile pentru Authors (authors/services.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 (.env)

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 - folosit in data/index.js → retine ip-ul unde ruleaza baza de date
  • PGUSER - folosit in data/index.js → retine usernameul de autentificare cu baza de date
  • PGPASSWORD - folosit in data/index.js → retine parola de autentificare cu baza de date
  • PGPORT - folosit in data/index.js → retine portul de conectare cu baza de date
  • PGDATABASE - folosit in data/index.js → retine numele bazei de date
  • PORT - folosit in start.js → retine portul pe care va rula serverul
  • NODE_ENV - folosit in mai multe locuri → retine modul in care va rula serverul (development sau production)
PGHOST=localhost
PGUSER=test
PGPASSWORD=test
PGPORT=5555
PGDATABASE=workshop-library
 
PORT=3000
NODE_ENV=development

Package.json

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"
  }
}

Baza de date

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.

Exercitii

  1. Recapitulare javascript
  2. Recapitulare NodeJS
  3. Realizati controllerele si serviciile pentru interactiunea cu Books

Pentru Books, va trebui sa dati si id-ul utilizatorului care a introdus cartea. Pentru ca, momentan, nu avem serviciul de autentificare si autorizare scris, ignorati acest lucru si puneti orice valoare de tip int. Vom modifica acest lucru in momentul in care realizam serviciul de auth si, deci, vom avea utilizatori

moby/backend/03.txt · Last modified: 2020/07/27 15:14 by alexandru.hogea
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0