wiki:skript/rest

Version 1 (modified by tracadmin, 6 years ago) (diff)

--

REST-Interfaces

Literaturhinweis

Tilkov et al.: REST und HTTP

Einführung

REST (Representational State Transfer) ist ein architektureller Ansatz des Software Engineerings mit dem Ziel, einheitliche Schnittstellen für verteilte Systeme zu schaffen.

Eine Haupteigenschaft von REST ist die Verwendung von HTTP-Verben zur Kodierung der Methoden (Befehle), die ein Client am Server ausführen kann. Insbesondere sind die Befehle nicht eng an die Objekte gekoppelt, wie das bei vielen herkömmlichen Schnittstellen der Fall ist.

Beispiel: Für das Abrufen eines Objekts vom Typ Student werden in herkömmlichen Schnittstellen häufig Requests der Form

http://mydomain.tld/getStudent.php?id=42

verwendet, wobei die Methode (get) im Namen des Skripts enthalten ist. Die Methode könnte aber beispielsweise ebenso retrieve (...retrieveStudent.php...) oder abrufen (...abrufenStudent.php...) usw. sein. Eine derartige Kodierung der Methode hat keine semantische Bedeutung sondern ist lediglich eine Lesehilfe zum Verständnis der Schnittstelle. Dies wird insbesondere problematisch, wenn für die Skripte keine sprechenden Namen verwendet werden.

In einem REST-Interface würde der URL

http://mydomain.tld/student/42

lauten und es würde die HTTP-Methode GET verwendet werden. Der URL ist aus drei Teilen aufgebaut:

  • Adresse der Webanwendung: http://mydomain.tld/
  • Objekttyp, auf den sich die Anfrage bezieht: student
  • ID des Objekts: 42

Alle REST-Interfaces folgen im Wesentlichen dieser URL-Struktur, die ggf. etwas komplexer sein kann. Mit dieser Form ist aus dem Request eindeutig ableitbar, welches Ziel verfolgt wird. Jedes Objekt wird durch seinen URL weltweit eindeutig identifizierbar. Damit ist die Implementierung von REST-Schnittstellen nah am HTTP-Standard angesiedelt.

CRUD

Der Funktionsumfang vieler verteilter Informationssysteme kann nach dem CRUD-Schema vollständig beschrieben werden:

  • Create: Erzeugen von Objekten
  • Read: Lesen / Abrufen von Objekten
  • Update: Aktualisieren / Ändern von Objekten
  • Delete: Löschen von Objekten

Diesen Befehlen werden die HTTP-Methoden

  • GET: Read
  • POST: Create
  • PUT: Update
  • DELETE: Delete

zugeordnet.

REST-Interface mit Node.js

Im Folgenden wird ein REST-Interface für Artikel eines Shopsystems auf der Basis von Node.js entwickelt. Als Client zum Testen des Interfaces verwenden wir den Postman Client von Google Chrome.

Das Interface soll die folgenden Anforderungen erfüllen:

  • Abruf eines Artikels mit GET /article/id
  • Abruf aller Artikel mit GET /article/ (ohne id)
  • Erzeugen eines neuen Artikels mit POST /article/
  • Ändern eines Artikels mit PUT /article/id
  • Löschen eines Artikels mit DELETE /article/id

Das Interface ist in der Datei Web_Quellcode_FP/vorlesung/rest/shop.js implementiert.

Exkurs: serverseitige Datenhaltung

Grundsätzlich sollten serverseitig Daten stets in einer Datenbank gehalten werden. Für kleine Projekte oder Tests kann es jedoch sinnvoll sein, wenn die gesamten serverseitig verwalteten Daten immer als Objekte im RAM gehalten werden. Diese Objekte können dann einfach als JSON in eine Datei geschrieben und beim Start des Servers aus dieser Datei gelesen werden:

// Artikel im Shop
var articles = [];

// Einlesen aus Datei (falls vorhanden)
// Auf diese Weise lässt sich sehr einfach eine serverseitige Datenhaltung
// für sehr kleine Projekte implementieren.
// __dirname ist das Verzeichnis des aktuell laufenden Skripts
var filename = __dirname + '/articles.json';
//ggf. vorhandene Daten aus Datei einlesen
try {
    var filedata = fs.readFileSync(filename);
    articles = JSON.parse(filedata);
    console.log(articles.length + " Datensätze eingelesen.")
} catch(err) {
    // Datei exisitiert nicht, wir müssen nichts tun
    console.log("Keine Datensätze eingelesen.")
}

Bei der Pfadangabe der Datei ist zu beachten, dass das aktuelle Verzeichnis von der verwendeten Node.js-Instanz abhängig sein kann. Daher sollte die Pfadangabe explizit gemacht werden. Die erfolgt durch Voranstellen des absoluten Pfades zum aktuellen Skript. Der absolute Pfad wird unter Node.js in der Variablen __dirname bereitgestellt:

// __dirname ist das Verzeichnis des aktuell laufenden Skripts
var filename = __dirname + '/articles.json';

Bei jeder Aktualisierung des Arrays wird dieses neu in die Datei geschrieben und kann damit beim nächsten Serverstart wieder geladen werden:

function updateFile() {
    fs.writeFileSync(filename, JSON.stringify(articles));
}

Artikelobjekte

Die Objekte haben die Attribute name, preis und beschreibung:

{
    name: 'Wasserwaage',
    preis: 14.99,
    beschreibung: 'Das Horizontalste seit der Erfindung des Salzsees.'
}

Allgemeine fachliche Informationen aus dem Request lesen

Bevor die eigentliche Unterscheidung des Befehls entsprechend der HTTP-Methode erfolgt, werden der Objekttyp und ggf. die ID des Objekts aus dem Request gelesen:

function serve(req, res) {
    // welches Objekt?
    // Komponenten aus dem URL holen
    var parts = url.parse(req.url, true).path.split("/");
    console.log(parts);
    
    // Ziel des Requests (Objekttyp)
    var target = parts[1];

    // Wurde eine ID mitgeliefert? Wäre das dritte Element des Requests
    var id = -1;
    if (parts.length > 2 && parts[2] !== "") {
        id = parts[2];
    }
    
    // Debug Ausgabe
    console.log('Ziel: ' + target + ' - ID: ' + id);

    // ...
}

Sofern keine ID im Request enthalten ist, wird diese auf -1 gesetzt.

HTTP-Methoden

Alle Anfragen werden von der Laufzeitumgebung an die als Server registrierte Funktion serve weitergeleitet. Dort muss demnach zwischen den verschiedenen Methoden unterschieden werden. Dies kann mit einer switch-case Anweisung erfolgen:

function serve(req, res) {

    // ...

    console.log('Ziel: ' + target + ' - ID: ' + id);


    // Methode auswerten
    switch (req.method) {
    case "GET":
        // Artikel-Objekt(e) angefordert
        // ...
        break;
    case "POST":
        // neues Objekt erzeugen
        // ...
        break;
    case "PUT":
        // Objekt ändern
        // ...
        break;
    case "DELETE":
        // Objekt löschen
        // ...
        break;
    default:
        // Nicht unterstützte HTTP-Methode
        // ...
        break;
    }
}

Im Folgenden werden die in den jeweiligen cases enthaltenen Codesegmente implementiert. Da vorerst nur ein Objekttyp (article) verarbeitet werden soll, ist es nicht notwendig, auf den jeweiligen Typ zu prüfen. Wir gehen also vorerst von freundlichen Clients aus. Die Struktur einer objektspezifischen Implementierung ist weiter unten beschrieben.

Abrufen mit GET

Für GET sollen die folgenden Anforerungen erfüllt werden:

  • der URL enthält eine gültige ID: das zugehörige Objekt wird zurückgeliefert
  • der URL enthält keine ID: der Server liefert alle Objekte
  • der URL enthält eine ungültige ID: der Server antwortet mit dem Statuscode 404 (Not Found)
case 'GET':
    // Elemente der Antwort initialisieren (happy path)
    var status = 200;
    var returnType = 'application/json';
    var message = '';

    if (id !== -1) {        
        // falls ID vorhanden: Artikel aus Array lesen
        var artIndex = findIndexById(id);
        
        if (artIndex !== -1) {
            // Artikel ausliefern
            message = JSON.stringify(articles[artIndex]);
        } else {
            // nicht gefunden: 404
            status = 404;
            returnType = 'text/plain';
            message = 'Artikel ' + id + ' nicht gefunden';
        }
    } else {
        // keine ID: alle Artikel
        message = JSON.stringify(articles);
    }
    
    // Antwort senden
    res.writeHead(status, {
        'Content-Type': returnType 
    });
    res.write(message);
    res.end();
    
    break;

findIndexById(id)ist eine Hilfsfunktion, welche das Array articles nach einem Artikel mit der übergebenen ID durchsucht und den Index im Array zurückliefert. Eine Implementierung mit einer Map als Datenstruktur und der ID als Schlüssel wäre hier deutlich effizienter, aber auch umfangreicher.

function findIndexById(id) {
    for (var i = 0; i < articles.length; i++) {
        if (articles[i].id == id) {
            return i;
        }
    }
    // Falls es keinen Artikel mit der id gibt
    return -1;
}

Zum Testen der GET-Methode des REST-Interfaces kann die Datei articles.json im Projektverzeichnis abgelegt werden. Bei den Tests mit dem Postman Client erhalten wir diese Ergebnisse:

Request:
GET http://localhost:3000/article/2

Ergebnis:
{
    "name": "Bohrmaschine",
    "preis": 75.99,
    "beschreibung": "Bohrt Beton wie Japanpapier.",
    "id": 2
}

----------------------------------------
Request:
GET http://localhost:3000/article

Ergebnis:
[
    {
        "name": "Wasserwaage",
        "preis": 19.99,
        "beschreibung": "Das Horizontalste seit der Erfindung des Salzsees.",
        "id": 1
    },
    {
        "name": "Bohrmaschine",
        "preis": 75.99,
        "beschreibung": "Bohrt Beton wie Japanpapier.",
        "id": 2
    }
]

----------------------------------------
Request:
http://localhost:3000/article/42

Ergebnis (Statuscode 404):
Artikel 42 nicht gefunden

Erzeugen mit POST

Für die Methode POST muss nur eine Funktion implementiert werden: Sobald ein Artikelobjekt angeliefert wird, wird das Objekt an das Array angehängt und das erhaltene Objekt mit der neu vergebenen ID an den Client zurückgesendet. Hier gilt es wieder zu beachten, dass die Daten in Chunks gesendet werden könnten und daher entsprechende Eventhandler implementiert werden müssen:

case 'POST':
    // Objekt erzeugen
    // Zwischenspeicher für Chunks
    var data = '';
    
    // Eventhandler für Chunk
    req.on('data', function(chunk) {
        data = data + chunk.toString();
    });
    
    // Eventhandler für Ende des Requests (alle Chunks eingetroffen)
    req.on('end', function() {
        // Objekt aus dem Request lesen
        var artikel = JSON.parse(data);
        
        // ID für neues Objekt festlegen
        artikel.id = getMaxId() + 1;
        
        // Artikel in das Array schreiben
        articles.push(artikel);
        // lokale Datei aktualisieren
        updateFile();
        
        // Antwort schreiben
        res.writeHead(201, {
            'Content-Type': 'application/json'
        });
        res.write(JSON.stringify(artikel));
        res.end();
    });

    break;

Der Statuscode 201 (Created) teilt dem Client mit, dass das Objekt erzeugt wurde. Die Funktion getMaxId() durchsucht das Array nach der höchsten ID, um die nächsthöhere ID an den neuen Artikel zu vergeben. Dies ist an dieser Stelle zur Illustration eingefügt. Effizienter wäre es, diese Funktion direkt nach dem Laden der Daten aus der Datei beim Serverstart einmal auszuführen, des Ergebnis in einer globalen Variablen zu speichern und fortan diese globale Variable bei jedem POST-Request zu inkrementieren.

function getMaxId() {
    var max = 0;
    for (var a of articles) {
        if (a.id > max) {
            max = a.id;
        }
    }
    return max;
}

Der Test mit dem Postman Client ergibt:

Request:
POST http://localhost:3000/article

Request-Body:
{"name":"Kombizange","preis":23.00,"beschreibung":"Beißt kräftig zu und hält sicher."}

Ergebnis (Statuscode 201):
{
    "name": "Kombizange",
    "preis": 23,
    "beschreibung": "Beißt kräftig zu und hält sicher.",
    "id": 3
}

Dem Objekt wurde die ID 3 hinzugefügt.

Ändern mit PUT

Das Ändern von Objekten mit PUT erfordert wieder etwas mehr Aufwand, da die vom Client mitgelieferte ID ungültig oder nicht vorhanden sein könnte. Im ersten Fall soll analog zum GET-Request mit 404 geantwortet werden, im zweiten Fall mit dem HTTP-Statuscode 400 (Bad Request).

Unter bestimmten Umständen muss die ID des Zielobjekts nicht im URL des Requests enthalten sein, nämlich dann, wenn die ID des Objekts Bestandteil des übermittelten JSON ist und vom Server aus dem Objekt gelesen werden kann. In unseren Beispiel ist dies der Fall. Der guten Form halber sollte man die ID jedoch immer im URL einkodieren, weswegen wir bei fehlender ID den Status 400 (Bad Request) zurückliefern.

Diese Anforderungen sollen umgesetzt werden:

  • der URL enthält eine gültige ID: das zugehörige Objekt wird mit dem Inhalt des Requests geändert
  • der URL enthält eine ungültige ID: der Server antwortet 404 (Not Found)
  • der URL enthält keine ID: der Server antwortet 400 (Bad Request)

Wie bei POST werden PUT-Requests ggf. ebenfalls in Chunks gesendet, so dass entsprechende Eventhandler zu implementieren sind:

case 'PUT':
    // Objekt ändern
    // Zwischenspeicher für Chunks
    var data = '';
    
    // Eventhandler für Chunk
    req.on('data', function(chunk) {
        data = data + chunk.toString();
    });
        
    // Eventhandler für Ende des Requests (alle Chunks eingetroffen)
    req.on('end', function() {
        var status = 200;
        var returnType = 'application/json';
        var message = '';

        if (id !== -1) {
            // Artikel ID im Request enthalten
            var artIndex = findIndexById(id);

            if (artIndex !== -1) {
                // Artikel mit der ID existiert: ersetzen
                var artikel = JSON.parse(data);
                articles[artIndex] = artikel;
                updateFile();

                // geänderten Artikel zurückliefern
                message = JSON.stringify(articles[artIndex]);
            } else {
                // nicht gefunden: 404
                status = 404;
                returnType = 'text/plain';
                message = 'Artikel ' + id + ' nicht gefunden';
            }
        } else {
            // Keine ID im Request --> 400: Bad Request
            status = 400;
            returnType = 'text/plain';
            message = 'Fehlerhafter Request: Artikel ID fehlt.';
        }

        // Antwort senden
        res.writeHead(status, {
            'Content-Type': returnType
        });
        res.write(message);
        res.end();
    });

    break;

Die Tests liefern folgende Ergebnisse:

Request:
PUT localhost:3000/article/3

Request-Body:
{"name":"Kombizange","preis":31.99,"beschreibung":"Beißt kräftig zu und hält sicher.","id":3}

Ergebnis:
{
    "name": "Kombizange",
    "preis": 31.99,
    "beschreibung": "Beißt kräftig zu und hält sicher.",
    "id": 3
}

----------------------------------------
Request:
PUT localhost:3000/article

Request-Body:
beliebig

Ergebnis (Statuscode 400):
Fehlerhafter Request: Artikel ID fehlt.

----------------------------------------
Request:
localhost:3000/article/42

Request-Body:
beliebig

Ergebnis (Statuscode 404):
Artikel 42 nicht gefunden

Löschen mit DELETE

Beim Löschen sind ebenfalls drei Fälle abzudecken:

  • der URL enthält eine gültige ID: das zugehörige Objekt wird gelöscht
  • der URL enthält eine ungültige ID: der Server antwortet 404 (Not Found)
  • der URL enthält keine ID: der Server antwortet 400 (Bad Request)
case "DELETE":
    // Objekt löschen
    var status = 200;
    var returnType = 'text/plain';
    var message = '';

    if (id !== -1) {
        // Artikel ID im Request enthalten
        var artIndex = findIndexById(id);

        if (artIndex !== -1) {
            // Artikel mit der ID existiert: löschen
            articles.splice(artIndex, 1);
            updateFile();
            
            message = 'Artikel ' + id + ' gelöscht';
        } else {
            // nicht gefunden: 404
            status = 404;
            message = 'Artikel ' + id + ' nicht gefunden';
        }
    } else {
        // Keine ID im Request --> 400: Bad Request
        status = 400;
        message = 'Fehlerhafter Request: Artikel ID fehlt.';
    }

    // Antwort senden
    res.writeHead(status, {
        'Content-Type': returnType,
    });
    res.write(message);
    res.end();

    break;

Die Tests mit Postman zeigen die folgenden Ergebnisse:

Request:
DELETE localhost:3000/article/3

Ergebnis:
Artikel 3 gelöscht

----------------------------------------
Request:
DELETE localhost:3000/article/

Ergebnis (Statuscode 400):
Fehlerhafter Request: Artikel ID fehlt.

----------------------------------------
Request:
DELETE localhost:3000/article/42

Ergebnis (Statuscode 404):
Artikel 42 nicht gefunden

Nicht unterstützte HTTP-Methoden

Bei den meisten Implementierungen ist es sinnvoll, dass der Client immer eine Antwort erhält, auch wenn eine nicht unterstützte HTTP-Methode verwendet wird. Dafür kann der Statuscode 405 (Method Not Allowed) zurückgesendet werden. In diesem Fall erwartet der Client den obligatorischen HTTP-Header Allow, in welchem die erlaubten Methoden mitgeteilt werden:

default:
    // Nicht unterstützte HTTP-Methode
    // 405: Method Not Allowed
    // Allow-Header MUSS enthalten sein: https://tools.ietf.org/html/rfc7231#section-6.5.5
    res.writeHead(405, {
        'Content-Type': 'text/plain',
        'Allow': 'GET, POST, PUT, DELETE'
    });
    res.write('HTTP-Methode ' + req.method + ' wird nicht unterstützt. Dies ist ein REST-Interface. Bitte nutzen Sie GET, POST, PUT, DELETE.');
    res.end();

    break;

Diese Verwendung von 405 in der beschriebenen Weise ist möglicherweise nicht ganz sauber, da sich der Fehlercode eigentlich darauf bezieht, dass eine bestimmte Ressource (ein Objekt) nicht mit der gewünschten Methode angesprochen werden darf (s. RFC7231). Da die Beschränkung auf die CRUD-Befehle in REST-Interfaces generell jedoch für alle Ressourcen gilt, erscheint die Verwendung von 405 auf die dargestellte Weise gerechtfertigt, zumal es an einer besser geeigneten Methode (z. B. Method Unknown oder Method Not Supported) im HTTP-Protokoll mangelt.

Der Test mit Postman ergibt das folgende Ergebnis:

Request:
COPY localhost:3000/article/2

Ergebnis (Statuscode 405):
Headers: Allow --> GET, POST, PUT, DELETE
Body: HTTP-Methode COPY wird nicht unterstützt. Bitte nutzen Sie GET, POST, PUT, DELETE.

Unterstützung verschiedener Objekttypen

Im vorgenannten Beispiel werden ausschließlich Objekte vom Typ Artikel verarbeitet. Daher muss die Variable

// Ziel des Requests (Objekttyp)
var target = parts[1];

nicht ausgewertet werden.

Sofern das REST-Interface - was bei komplexen Systemen durchaus üblich ist - mehrere Objekttypen verarbeiten kann, sind entsprechende Prüfungen einzubauen, welcher Objekttyp durch den Request verarbeitet werden soll. Dabei empfiehlt es sich für eine verbesserte Wartbarkeit, den Code zu entzerren und die diversen Fachlichkeiten in getrennte Funktionen und ggf. getrennte Quellcodedateien auszulagern?:

switch (req.method) {
case 'GET':
    switch (target) {
    case 'article':
        handleGetArticle(req, res);
        break;
    case 'customer':
        handleGetCustomer(req, res);
        break;
    case 'payment':
        handleGetPayment(req, res);
        break;
    default:
        // ...
    }
    break;
case 'POST':
    // ...
}