Node und Express Grundlagen

Use case

Use case

express ist eine Library für Backend Entwicklung

im Grunde genommen kann es HTTP Requests entgegennehmen und zugehörige Responses versenden

typische Anwendungsfälle: Entwicklung einer Website (Responses sind HTML), Entwicklung eines APIs (Responses sind JSON oder andere Datenformate)

Use case

Beispiele:

Bekommt einen Request zu API-Daten (z.B. /api/article/19e2), sendet Daten zurück

Bekommt einen Request zu einer statischen Ressource, sendet diese Ressource zurück (z.B. styles.css, logo.png, ...)

Bekommt einen Request zu einer Seite (z.B. /news), rendert die Seite als HTML und sendet sie zurück

Libraries

Libraries

  • reines node (createServer)
  • connect (beinhaltet Middleware)
  • express (beinhaltet Middleware, Routing, Rendering, ...)

die meisten Projekte verwenden express

Hello world

Hello world

hello world mit express:

// server.js
import express from 'express';

const app = express();

// provide a function that handles a get request to "/"
// and sends a response
app.get('/', (req, res) => {
  // note: we should actually return a complete HTML file
  res.send('<h1>Hello World!</h1>\n');
});

// listen on localhost:3000
app.listen(3000);

Hello world

hello world ohne express (siehe https://nodejs.org/en/docs/guides/getting-started-guide/):

// server.js
import http from 'http';

const handler = (req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/html; charset=UTF-8');
  res.end('<h1>Hello World!</h1>\n');
};

const server = http.createServer(handler);
server.listen(3000);

Den Server starten

einfaches start Skript um den Server zu starten:

{
  "scripts": {
    "start": "node server.js"
  }
}

Den Server starten

Um den Server zu starten und bei Code-Änderungen neu zu starten:

Installiere nodemon von npm und füge ein dev-Skript zu package.json hinzu:

{
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
  }
}

Request und Response

Request und Response

Web-Entwicklung in node geschieht über Request-Handler-Funktionen, z.B.:

(req, res) => {
  res.send('<h1>Hello World!</h1>\n');
};

Request und Response

Ein Request Handler bekommt zwei Argumente übergeben:

  • req - repräsentiert den eingehenden request
  • res - repräsentiert die response / Antwort, die wir senden werden

Das Request-Objekt

Beispiel für ein Request-Objekt:

{
  "method": "GET",
  "path": "/products/123",
  "params": { "id": "123" },
  "headers": { "user-agent": "Mozilla/5.0 (Windows ..." }
}

Klasse in reinem Node: IncomingMessage

Unterklasse in express: Request

Das Response-Objekt

Klasse in reinem Node: ServerResponse

Unterklasse in express: Response

Beispiele

Beispiele

antworte für /hello mit einer Hello World-Nachricht:

app.get('/hello', (req, res) => {
  res.send('<h1>Hello World!</h1>\n');
});

Beispiele

Sende eine statische Datei:

app.get('/favicon.ico', (req, res) => {
  res.sendFile('public/favicon.ico');
});

Beispiele

Lade Datenbankeinträge und sende sie als JSON:

app.get('/api/products', async (req, res) => {
  // use a DB library for accessing the database
  const sqlQuery = 'SELECT * FROM product';
  const [results] = await db.query(sqlQuery);

  // send the results as JSON
  res.json(results);
});

Beispiele

Lade einen Datenbankeintrag basierend auf einem URL-Parameter und sende ihne als JSON:

app.get('/api/products/:id', async (req, res) => {
  const id = Number(req.params.id);

  const sqlQuery = 'SELECT * FROM product WHERE id = ?';
  const [results] = await db.query(sqlQuery, [id]);

  res.json(results[0]);
});

Beispiele

Lade Datenbankeinträge basierend auf Query Parametern

Beispiel für eine angefragte URL: /api/products?min_rating=4&max_price=200

app.get('/api/products', async (req, res) => {
  const minRating = Number(req.query.min_rating);
  const maxPrice = Number(req.query.max_price);

  const sqlQuery =
    'SELECT * FROM product WHERE rating >= ? AND price <= ?';
  const sqlParams = [minRating, maxPrice];
  const [results] = await db.query(sqlQuery, sqlParams);

  res.json(results);
});

Beispiele

Rendere eine HTML-Seite basierend auf einem Datenbankeintrag

app.get('/products/:id', async (req, res) => {
  const sqlQuery = 'SELECT * FROM product WHERE id = ?';
  const sqlParams = [req.params.id];
  const [results] = await db.query(sqlQuery, sqlParams);

  // look for the "product_detail" template and render it based on
  // the retrieved data
  res.render('product_detail', {
    name: results[0].name,
    price: results[0].price,
  });
});

Themenüberblick

Themenüberblick

  • gemeinsame Themen
    • grundlegendes Routing
    • Responses senden: Inhalt, Status, Header
    • Routenparameter und Query-Parameter
    • Middleware
  • API-Themen
    • Senden und Empfangen von JSON-Daten
    • same origin policy
    • Rest APIs
    • Testen von APIs
  • HTML-Themen
    • Senden von Dateien und statischen Assets
    • Rendern von HTML
    • Behandeln von Formular-Submissions

Grundlegendes Routing

Grundlegendes Routing

app.get('/', (req, res) => {
  res.send('<h1>Home</h1>\n');
});
app.get('/about', (req, res) => {
  res.send('<h1>About</h1>\n');
});

andere HTTP-Methoden werden behandelt via: .post, .put, .delete, ...

Responses Senden

Responses Senden

Methoden von Response-Objekten:

  • .status() - um den Statuscode zu setzen
  • .set() - um Header zu setzen
  • .send() - um Inhalte zu senden

Responses Senden

explizites Beispiel:

res.set({ 'Content-Type': 'text/html' });
res.status(200);
res.send('<!DOCTYPE html><html>...</html>');

Responses Senden

standardmäßig wird der Content-Type auf text/html und der Status auf 200 gesetzt

kürzere Version:

res.send('<!DOCTYPE html><html>...</html>');

Responses Senden

weiteres Beispiel:

res.status(404);
res.set({ 'Content-Type': 'text/plain' });
res.send('Document not found.\n');

Responses Senden

Response-Objekte unterstützen Method Chaining:

res
  .status(404)
  .set({ 'Content-Type': 'text/plain' })
  .send('Document not found.\n');

URL-Parameter

URL-Parameter

Beispiele für URLs mit Parametern:

  • /products/123 (verweist über eine ID auf ein einzelnes Produkt)
  • /products?min_rating=4&max_price=200 (filtert anhand von Kriterien)

Routenparameter

Beispiel-URL: /products/123 (verweist über eine ID auf ein einzelnes Produkt)

app.get('/products/:id', (req, res) => {
  const productId = Number(req.params.id);
  // ...
});

Query-Parameter

Beispiel-URL: /products?min_rating=4&max_price=200 (filtert anhand von Kriterien)

app.get('/products', (req, res) => {
  const minRating = Number(req.query.min_rating);
  const maxPrice = Number(req.query.max_price);
  // ...
});

Middleware

Middleware

Middleware kann auf Requests reagieren sowie die req / res - Objekte abändern

Middleware

Beispiel:

import compression from 'compression';
import express from 'express';
import morgan from 'morgan';

const app = express();

// log all incoming requests
app.use(morgan('common'));
// serve content of the "public" folder as-is if found
app.use(express.static('public'));
// compress all responses
app.use(compression());

app.get('/', (req, res) => {
  // ...
});

Middleware und Routing

// log all incoming requests
app.use(morgan('common'));
// for any request that starts with /api, parse its
// JSON body if it exists
app.use('/api', express.json());
// compress API responses
app.use('/api', compression());
// requests to /assets/...: send back static files
app.use('/assets', express.static('public'));

Beispiele für Middleware

  • express.json, express.urlencoded, ... : parsen den Inhalt eines Requests und stellen ihn als req.body zur Verfügung
  • cookie-parser: liest Cookies aus und stellt sie unter req.cookies zur Verfügung
  • compression: komprimiert den Inhalt der Response
  • express.static: antwortet mit vorhandenen statischen Dateien (z.B. index.html), falls vorhanden
  • express-session: speichert Sitzungsdaten (verfügbar unter req.session)
  • express-openid-connect oder passport: Benutzer-Authentifizierung
  • morgan: Logging
  • ... (siehe: list of available express middleware)

Senden und Empfangen von JSON-Daten

Senden und Empfangen von JSON-Daten

senden von JSON (manuell):

res.setHeader({"Content-Type", "application/json"});
res.send(JSON.stringify(data));

mit dem .json()-Helper:

res.json(data);

Senden und Empfangen von JSON-Daten

Empfangen (parsen) eines JSON-Bodys via middleware:

app.use('/api', express.json());

Daten sind dann verfügbar als req.body

Same-origin Policy und CORS

Same-origin Policy

Same-origin Policy: Sicherheitsregel im Browser:

Standardmäßig darf eine Seite von einer Website (z.B. www.example.com) keine Requests an eine andere Website (z.B. api.example.com) schicken

Same-origin Policy

Beispiel: Gehe auf eine Website (z.B. Wikipedia) öffne die JavaScript-Konsole des Browsers und sende einen request zu einer anderen Website, z.B. via fetch("https://google.com")

Resultat: der Request wird verhindert

Grund: um das Ausnützen von cookie-basierter Authentifizierung durch Angreifer zu verhindern

Cross-Origin resource sharing

Die angefragte Website kann Cross-Origin resource sharing (CORS) für manche URLs oder für alle URLs erlauben

Das geschieht mit dem HTTP-Header "Access-Control-Allow-Origin"

Beispiel: die jsonplaceholder API erlaubt CORS für alle Websites - also funktioniert fetch("https://jsonplaceholder.typicode.com/todos") von jeder Seite

CORS mit express

CORS in express aktivieren:

import cors from 'cors';

Erlauben von Requests von allen Domains:

app.use('/api', cors());

Erlauben von Requests von einer bestimmten Domain:

app.use('/api', cors({ origin: 'https://example.com' }));

Rest APIs

Rest APIs

Wir erstellen eine Rest API mit express und einer Datenbank, die mittels der mysql2 Library verbunden ist.

Die Datenbank enthält eine einzelne Tabelle namens "task"

Rest APIs

Bemerkung: Verschiedene Rest APIs können sich bei Mutationen oft unterschiedlich verhalten

Beispiel: Mögliche Antworten, wenn ein Eintrag mittels POST erstellt wurde:

  • der vollständige neue Eintrag wird im Response Body zurückgeschickt (inklusive neu zugewiesener id)
  • nur die neue id wird im Body zurückgeschickt
  • kein Response Body wird zurückgeschickt

Rest APIs

grundlegendes Setup:

app.use('/api', express.json());
app.use('/api', cors());

Rest APIs

Alle Einträge einer Ressource abfragen:

app.get('/api/tasks', async (req, res) => {
  const [results] = await db.query('SELECT * FROM task;');
  res.json(results);
});

Rest APIs

nach einer bestimmten id abfragen:

app.get('/api/tasks/:id', async (req, res) => {
  const id = Number(req.params.id);
  const sqlStatement = 'SELECT * FROM task WHERE id = ?';
  const [results] = await db.query(sqlStatement, [id]);
  if (results.length === 0) {
    // 404 Not Found
    res.status(404).send();
  } else {
    res.json(results[0]);
  }
});

Rest APIs

nach bestimmten Parametern abfragen (is_done, due_date):

app.get('/api/tasks/', async (req, res) => {
  // array of conditions - e.g. ['is_done = ?', 'due_date = ?']
  const conditions = [];
  // array of corresponding values - e.g. [1, '2020-10-30']
  const values = [];

  if (req.query.due_date !== undefined) {
    conditions.push('due_date = ?');
    values.push(req.query.due_date);
  }

  if (req.query.is_done !== undefined) {
    conditions.push('is_done = ?');
    // can be either "0" or "1"
    values.push(Number(req.query.is_done));
  }

  let sqlStatement = 'SELECT * FROM task';
  if (conditions.length > 0) {
    sqlStatement += ` WHERE ${conditions.join(' AND ')}`;
  }

  // sqlStatement = complete SQL statement with variables, e.g.:
  // SELECT * FROM task WHERE is_done = ? AND due_date = ?
  // values = corresponding values, e.g.:
  // [1, '2020-10-30']

  const [results] = await db.query(sqlStatement, values);
  res.json(results);
});

Rest APIs

Einen neuen Eintrag erstellen:

app.post('/api/tasks', async (req, res) => {
  const isDone = req.body.is_done;
  const dueDate = req.body.due_date;
  if (isDone === undefined || dueDate === undefined) {
    // 400 Bad Request
    res.status(400).send();
    return;
  }
  const sqlStatement = `INSERT INTO task (due_date, is_done) VALUES (?, ?)`;
  const values = [dueDate, isDone];

  const [results] = await db.query(sqlStatement, values);

  // 201 Created (or 200 OK)
  res.status(201).json({ id: results.insertId });
});

Rest API

einen Eintrag ersetzen - via put:

app.put('/api/tasks/:id', async (req, res) => {
  const isDone = req.body.is_done;
  const dueDate = req.body.due_date;

  if (isDone === undefined || dueDate === undefined) {
    // 400 Bad Request
    res.status(400).send();
    return;
  }
  const id = Number(req.params.id);
  const sqlStatement = `
    UPDATE task
    SET due_date = ?, is_done = ?
    WHERE id = ?
  `;
  const values = [dueDate, isDone, id];
  const [results] = await db.query(sqlStatement, values);
  if (results.affectedRows === 0) {
    // 404 Not Found
    res.status(404).send();
  } else {
    res.send();
  }
});

Rest API

Löschen eines Eintrags:

app.delete('/api/tasks/:id', async (req, res) => {
  const id = Number(req.params.id);
  const sqlStatement = `DELETE FROM task WHERE id = ?`;
  const values = [id];
  const [results] = await db.query(sqlStatement, values);

  if (results.affectedRows === 0) {
    // 404 Not Found
    res.status(404).send();
  } else {
    res.send();
  }
});

Testen von APIs

Testen von APIs

APIs sind nicht so einfach zu testen wie Scripts oder UIs

Möglichkeiten, ein API zu testen:

  • Anwendungen wie Postman, mit denen HTTP Requests gesendet und Responses begutachtet werden können
  • Schreiben von automatisierten Tests

Automatisierte Tests

Einfacher Test ohne zusätzliche Tools:

async function testGetTodos() {
  console.log('/todos: returns an array of 200 todos');
  const res = await fetch(
    'https://jsonplaceholder.typicode.com/todos'
  );
  const data = await res.json();
  if (data.length !== 200) {
    console.log('FAILED');
  }
}
await testGetTodos();

Automatisierte Tests

Test mit nodes eingebauten Test-Tools:

import { test } from 'node:test';
import assert from 'node:assert';

test('/todos: returns an array of 200 todos', async () => {
  const res = await fetch(
    'https://jsonplaceholder.typicode.com/todos'
  );
  const data = await res.json();
  assert.strictEqual(data.length, 200);
});

Statische Dateien und Ordner

Statische Dateien und Ordner

senden von nur einer statischen Datei:

app.get('/favicon.ico', (req, res) => {
  res.sendFile('public/favicon.ico');
});

Statische Dateien und Ordner

mit Middleware kann ein Ordner für statische Dateien angegeben werden:

Um für jeden Request nach statischen Inhalten zu suchen:

app.use(express.static('public'));

Um statische Inhalte nur für Requests nach /static zu durchsuchen:

app.use('/static', express.static('public'));

Rendern von HTML

Rendern von HTML

express kann HTML mit dynamischen Inhalten generieren - das HTML wird für jeden Request neu generiert (gerendert)

Rendern von HTML

manuell (kann angreifbar sein):

app.get('/', (req, res) => {
  const name = 'world';
  res.send(`
    <!DOCTYPE html>
    <html>
      <head><title>Hello ${name}</title></head>
      <body>Hello ${name}</body>
    </html>
  `);
});

Rendern von HTML

mit Hilfe einer Template Engine:

Rendern von HTML

allgemeines Verfahren:

import express from 'express';
import myengine from 'myengine';

const app = express();

// specify the rendering engine
app.set('view engine', 'myengine');

app.get('/', (req, res) => {
  const name = 'world';
  // renders 'views/index.myengine'
  res.render('index', { name: name });
});

Rendern von HTML

Beispiel mit handlebars:

npm install hbs
app.set('view engine', 'hbs');

// ...

app.get('/', (req, res) => {
  const name = 'world';
  // renders the file 'views/index.hbs'
  res.render('index', { name: name });
});

(ersetze hbs durch ejs / pug für andere Template-Enginges)

Übungen

Übungen:

  • erstelle eine Website mit verschiedenen Seiten (home, about, newsletter, ...)
  • erstelle eine Website, die Informationen von einem öffentlichen API anzeigt (z.B. https://pokeapi.co/)
  • erstelle eine Website, die Informationen aus einer lokalen Datenbank darstellt

Übungen

Pokeapi Teil 1:

app.get('/pokemon/:id', async (req, res) => {
  const id = req.params.id;
  const dataRes = await fetch(
    `https://pokeapi.co/api/v2/pokemon/${id}`
  );
  const data = await dataRes.json();
  res.render('pokemon', {
    id: id,
    name: data.name,
    imgSrc: data.sprites.front_default,
  });
});
app.get('/pokemon', (req, res) => {
  res.redirect('/pokemon/1');
});

Übungen

Pokeapi Teil 2:

views/pokemon.hbs:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>{{name}}</title>
  </head>
  <body>
    <h1>{{name}}</h1>
    <img src="{{imgSrc}}" />
  </body>
</html>

Formulare

Request-Parameter

Standardmäßig sendet der Browser Formular-Inhalte im URL-encoded Format, z.B.:

foo=1&bar=2&baz=3

in get-Requests: als Teil der URL, z.B. https://google.com/search?ei=xyzg&q=foo...

in post-Requests: im Request-Body

Auslesen von Request-Parametern

in einem get-Request: lies req.query

in einem post-Request: verwende express.urlencoded als Middleware, lies req.body