zurück zum Artikel

REST-APIs mit Node.js und Swagger

Werkzeuge
REST-APIs mit Node.js und Swagger

Wer sich mit der Konzeption und Entwicklung von REST-APIs beschäftigt, landet früher oder später bei einer API-Beschreibungssprache wie RAML, API Blueprint oder Swagger. Gerade Letzteres lässt die Integration in Node.js-Anwendungen relativ einfach von der Hand gehen.

Bei RAML [1], API Blueprint [2] und Swagger [3] handelt es sich um Beschreibungssprachen für REST-APIs, wobei JSON oder YAML als Formate zum Einsatz kommen. Grundsätzlich hat die Verwendung einer API-Beschreibungssprache einige Vorteile: Zum einen lässt sich über sie exakt definieren, wie eine API auszusehen hat, beispielsweise welche Endpunkte angeboten werden, welche HTTP-Methoden die Schnittstelle unterstützt, wie Requests auszusehen haben, welche Parameter erwartet werden, welchen Datentyp die Parameter haben, wie Responses aufgebaut sind und vieles mehr. Auf diese Weise macht man sich zum einen von vornherein mehr Gedanken über die zu entwickelnde API, was sich wiederum positiv auf die Stabilität selbiger auswirkt. Zum anderen können API-Spezifikationen, die in einer der genannten Beschreibungssprachen definiert wurden, anschließend als Grundlage für das Erstellen von Tests oder für die automatische Codegenerierung dienen.

Welche der genannten Beschreibungssprachen dabei die "Beste" ist, lässt sich nicht pauschal beantworten und soll daher nicht das Thema sein. Wer sich ausführlicher mit ihren Details befassen möchte, dem seien die guten Dokumentationen samt Einsteigertutorials auf den jeweiligen Homepages empfohlen. Wer hingegen auf der Suche nach den Unterschieden zwischen den Beschreibungssprachen ist, wird in einer [4] Reihe [5] von Artikeln fündig.

Der folgende Artikel befasst sich speziell mit dem swagger [6]-Tool, einem offiziellen Node.js-Package vom Swagger-Team, das die Entwicklung von REST-APIs (im Swagger-Format) unter Node.js erleichtern soll.

Installation und Erstellen einer API

swagger lässt sich mit dem Node.js Package Manager (NPM) über folgenden Befehl global installieren:

$ npm install -g swagger

Anschließend lässt sich das Tool auf der Kommandozeile mit swagger verwenden, wobei weitere Kommandos zur Verfügung stehen, auf die gleich noch im Detail eingegangen wird. Ergänzende Informationen finden sich in der offiziellen Dokumentation [7].

Um ein neues Projekt zu erzeugen, verwendet man den Befehl swagger project create, wobei als Parameter der Name des Projekts zu übergeben ist:

$ swagger project create helloworld

Im anschließenden Auswahldialog lässt sich wählen, welches Web-Framework zu verwenden ist. Zur Verfügung stehen Connect [9], Express [10], Hapi [11], Restify [12] und Sails [13]. Hat man sich für ein Framework entschieden, stößt das Tool den Scaffolding-Prozess an und erzeugt ein komplettes Node.js-Projekt, dessen Struktur (für Express) wie folgt aussieht:

$ cd helloworld
$ tree -L 2
.
+-- README.md
+-- api
¦ +-- controllers
¦ +-- helpers
¦ +-- mocks
¦ +-- swagger
+-- app.js
+-- config
¦ +-- README.md
¦ +-- default.yaml
+-- node_modules
¦ +-- ...
+-- package.json
+-- test
¦ +-- api
+-- tree.txt

Folgende Dateien und Verzeichnisse sind dabei erwähnenswert:

Um das generierte Projekt zu starten, führt man einfach folgenden Befehl aus:

$ swagger project start

Das wiederum startet im Hintergrund einen HTTP-Server (im Beispiel einen, der Express nutzt) und stellt die API unter http://localhost:10010/ zur Verfügung. Zu Anfang enthält die API lediglich eine Route mit Namen hello, der man einen Parameter name übergeben kann und als Antwort eine entsprechende "Hello"-Begrüßung erhält:

Starting: /Users/ackermann/workspaces/helloworld/app.js...
project started here: http://localhost:10010/
project will restart on changes.
to restart at any time, enter `rs`
try this:
curl http://127.0.0.1:10010/hello?name=Scott

Editieren und Testen der API

Besonders nützlich bei der Entwicklung einer Swagger-API-Spezifikation ist der Swagger Editor [15], der sowohl zum Download [16] als auch als Online-Service [17] zur Verfügung steht. Das Praktische: beim Anlegen des Projektes über swagger project create helloworld wurde der Editor als Abhängigkeit heruntergeladen und lässt sich daher lokal starten. Den folgenden Befehl sollte man dabei in einem zweiten Tab parallel zur gestarteten Anwendung aufrufen:

$ swagger project edit

Wie in folgender Abbildung zu sehen ist, teilt sich der Editor in zwei Bereiche: auf der linken Seite befindet sich der eigentliche Editor, auf der rechten eine gerenderte HTML-Vorschau. Der Editor beherrscht Syntax-Highlighting und kann syntaktische und semantische Fehler erkennen, beispielsweise wenn er referenzierte Komponenten (dazu später mehr) nicht findet.

Der Swagger Editor verschafft Überblick über die inneren Zusammenhänge Hello-World-Anwendung.
Der Swagger Editor verschafft einen Überblick über die inneren Zusammenhänge Hello-World-Anwendung.


Alle Änderungen, die man nun im Editorbereich an der API-Spezifikation vornimmt, erscheinen direkt im Vorschaubereich. Aber nicht nur das: das Programm speichert die Änderungen auch direkt in der lokalen API-Datei, sodass man sie nicht händisch in den Editor der Wahl kopieren muss.

Für das Testen einer API verwendet swagger das Tool supertest [18], wobei als Assertion-Bibliothek das beliebte should [19] zum Einsatz kommt.

Die Tests lassen sich entweder mit dem Befehl swagger project test ausführen oder alternativ als Shortcut per npm test. Für die generierte Beispiel-API sieht die Ausgabe der Tests beispielsweise wie folgt aus:

$ swagger project test
try this:
curl http://127.0.0.1:10010/hello?name=Scott
controllers
hello_world
GET /hello
✓ should return a default string
✓ should accept a name parameter

Erstellen einer eigenen API

Der Inhalt der generierten Datei swagger.yaml lässt sich nach Belieben ändern, wodurch Entwickler eine eigene API-Beschreibung definieren können. Im Folgenden soll nun gezeigt werden, wie sich mit Swagger eine einfache API für das Verwalten von Büchern definieren lässt. Die API soll dabei über folgende Routen verfügen:

* "/books":
* "GET": Liefert alle Bücher.
* "POST": Speichert ein neues Buch.
* "/books/{id}"
* "GET": Liefert ein bestimmtes Buch zu einer ID.
* "PUT": Aktualisiert ein Buch.
* "DELETE": Löscht ein Buch.

Der Befehl swagger project create bookstore erzeugt das Projekt. Anschließend ist wie zuvor in einem Kommandozeilen-Tab der Befehl swagger project create zum Starten sowie in einem zweiten Tab swagger project edit zum Editieren des Projekts auszuführen.

Allgemeiner Aufbau

Der Aufbau einer API-Spezifikation in Swagger ist relativ einfach zu verstehen – ein Beispiel im YAML-Format ist im folgenden zu sehen:

swagger: "2.0"
info:
version: "0.0.1"
title: Bookstore
host: localhost:10010
basePath: /
#
schemes:
- http
- https
consumes:
- application/json
produces:
- application/json
paths:
/books:
x-swagger-router-controller: books
get:
description: Returns a list of all books
operationId: books
responses:
"200":
description: Success
schema:
$ref: "#/definitions/Books"
default:
description: Error
schema:
$ref: "#/definitions/ErrorResponse"
/swagger:
x-swagger-pipe: swagger_raw

Zu Beginn stehen unter anderem die verwendete Swagger-Version, allgemeine Informationen zur API wie Name und Version, Angaben zum Server, auf dem die API läuft, und darüber, welchen Content-Typ sie standardmäßig konsumiert und produziert.

Die Routen sind im Abschnitt paths definiert. Auf oberster Ebene steht jeweils der Pfad (im Beispiel /books), unterhalb dessen die unterstützten HTTP-Methoden (im Codeauszug momentan nur get) und darunter wiederum Informationen zum Aufbau des Requests (in späteren Beispielen zu sehen) sowie zum Aufbau der Response. Requests und Responses gleicht Swagger automatisch gegen die API-Spezifikation ab, um deren Gültigkeit festzustellen. Entsprechen sie nicht den dortigen Angaben, wird zur Laufzeit ein Fehler ausgegeben.

Um doppelte Definitionen innerhalb von Responses oder Requests zu vermeiden, lassen sich wiederkehrende Definitionen beziehungsweise Typen im Abschnitt definitions ablegen und anschließend über die Eigenschaft $ref referenzieren. Im folgenden Codebeispiel sind beispielsweise unterschiedliche Typen wie der Typ Books definiert, der dann innerhalb der Response-Definition (siehe vorheriger Code) referenziert wird (und selbst wiederum den Typ Book referenziert).

definitions:
Book:
properties:
id:
type: string
description: Unique identifier representing a book
title:
type: string
description: Title of the book
author:
type: string
description: Author of the book
pages:
type: number
description: Number of pages of the book
year:
type: number
description: Year the book was released
Books:
type: array
items:
$ref: '#/definitions/Book'
Response:
type: object
properties:
success:
type: number
description:
type: string
required:
- success
- description
ErrorResponse:
required:
- message
properties:
message:
type: string

Implementierung von Controllern

Die Eigenschaft x-swagger-router-controller ist, wie der Name durch das vorangestellte "x" bereits erkennen lässt, kein Standardeigenschaft der Swagger-Spezifikation, sondern eine Erweiterung, die in dem Fall den Namen des Controllers definiert, der bei Aufruf des Endpoints die Abarbeitung des Requests übernimmt. Die Implementierung ist dabei für das Beispiel in der Datei books.js unterhalb des Verzeichnisses controller zu speichern.

Welche der von dieser Datei exportierten Funktionen aufzurufen ist, definiert wiederum die Eigenschaft operationId unterhalb der Routen-Definition. Im Beispiel wird auf die Weise für die Route GET /books die Funktion books() referenziert. Eine simple Implementierung Letzterer zeigt folgendes Listing. Der Einfachheit halber hält das Programm die Buch-Instanzen lediglich in einer Map vorn, im Produktiveinsatz würde man die Daten stattdessen dauerhaft persistieren, etwa mit der (nicht nur) im Zusammenhang mit Node.js-Anwendungen beliebten NoSQL-Datenbank MongoDB.

let BOOKS = new Map();
BOOKS.set(
'9780201485677',
{
id: '9780201485677',
title: 'Refactoring: Improving the Design of Existing Code',
author: 'Martin Fowler',
pages: 431,
year: 1999
}
);
BOOKS.set(
'9780132350884',
{
id: '9780132350884',
title: 'Clean Code: A Handbook of Agile Software Craftsmanship',
author: 'Robert C. Martin',
pages: 462,
year: 2008
}
);
BOOKS.set(
'9780321356680',
{
id: '9780321356680',
title: 'Effective Java',
author: 'Joshua Bloch',
pages: 368,
year: 2008
}
);

function books(request, response) {
response.json(Array.from(BOOKS.values()));
}

module.exports = {
books: books,
};

Anfragen

Die API lässt sich nun relativ einfach um weitere Routen erweitern. Der Zugriff auf einzelne Bücher etwa geschieht REST-konform über GET /books/{id}, wobei der Platzhalter {id} die ID (in dem Fall die ISBN-Nummer) des jeweiligen Buchs angibt. Als Controller kommt wie zuvor books zum Einsatz, als auszuführende Operation die Funktion find(). Die Eigenschaft parameters spezifiziert zudem den Parameter id genauer: angeben lassen sich der Name, eine Beschreibung und der Typ des Parameters, die Art (Path-, Query-Parameter oder Request-Body-Parameter) sowie eine Angabe darüber, ob der Parameter vorhanden sein muss oder optional ist.

/books/{id}:
x-swagger-router-controller: books
get:
description: Get a book
operationId: find
parameters:
- name: id
description: Book id
type: string
in: path
required: true
responses:
"200":
description: Success
schema:
$ref: "#/definitions/Book"
default:
description: Error
schema:
$ref: "#/definitions/ErrorResponse"

Die Implementierung der Funktion find() im Controller zeigt folgendes Listing. Über die Eigenschaft swagger des request-Objekts gelangt man an den Path-Parameter id und greift über ihn auf die Map zu. Wurde ein Eintrag gefunden, wird dieser im JSON-Format an den Client zurückgesendet, andernfalls ein entsprechender HTTP-Fehlercode.

...
function find(request, response) {
const id = request.swagger.params.id.value;
if(BOOKS.has(id)) {
const book = BOOKS.get(id);
response.json(book);
} else {
response.status(204).send();
}
}
...
module.exports = {
books,
find,
};
...

Nach dem gleichen Prinzip lassen sich anschließend die restlichen Routen definieren.

POST-Anfrage für neue Bücher

Für das Anlegen neuer Bücher erstellt man in der API-Beschreibung einen Abschnitt post unterhalb des unter paths definierten Pfades /books. Als einziger Parameter wird das anzulegende Buch innerhalb des Request-Bodies vorausgesetzt, wobei auf den zuvor definierten Typen Book referenziert wird.

post:
description: Add a new book to the list
operationId: save
parameters:
- name: book
description: Book properties
in: body
required: true
schema:
$ref: "#/definitions/Book"
responses:
"201":
description: Success
schema:
$ref: "#/definitions/Response"
default:
description: Error
schema:
$ref: "#/definitions/ErrorResponse"

Der Controller wird um die Funktion save() erweitert, die mehr oder weniger selbsterklärend sein sollte:

...
function save(request, response) {
const book = request.body;
BOOKS.set(book.id, book);
response.json(
{
success: 1,
description: 'Book saved',
}
);
}
...
module.exports = {
books,
find,
save,
};
...

PUT-Anfrage zum Aktualisieren

Die Definition einer PUT-Anfrage für das Aktualisieren von Büchern gestaltet sich ähnlich wie die der POST-Anfrage. Wesentlicher Unterschied ist die Definition eines Path-Parameters wie zuvor im Beispiel für die GET-Anfrage, über den die ID des zu aktualisierenden Buchs anzugeben ist:

put:
description: Update a book
operationId: update
parameters:
- name: id
description: Book id
type: string
in: path
required: true
- name: book
description: Book properties
in: body
required: true
schema:
$ref: "#/definitions/Book"
responses:
"200":
description: Success
schema:
$ref: "#/definitions/Response"
default:
description: Error
schema:
$ref: "#/definitions/ErrorResponse"

Innerhalb des Controllers übernimmt die Funktion update() das Aktualisieren:

...
function update(request, response) {
const id = request.swagger.params.id.value;
if(BOOKS.has(id)) {
const book = BOOKS.get(id);
const bookUpdate = request.body;
BOOKS.set(id, bookUpdate);
response.json(
{
success: 1,
description: 'Book updated',
}
);
} else {
response.status(204).send();
}
}
...
module.exports = {
books,
find,
save,
update,
};
...

DELETE-Anfrage zum Löschen

Zu guter Letzt fehlt noch ein Befehl zum Löschen von Büchern. Als Parameter erwartet das Programm lediglich die ID des Buchs innerhalb des Pfades:

delete:
description: Delete a book
operationId: remove
parameters:
- name: id
description: Book id
type: string
in: path
required: true
responses:
"200":
description: Success
schema:
$ref: "#/definitions/Response"
default:
description: Error
schema:
$ref: "#/definitions/ErrorResponse"

Der Controller wird um die folgende Funktion remove() ergänzt:

...
function remove(request, response) {
const id = request.swagger.params.id.value;
const deleted = BOOKS.delete(id);
if(deleted) {
response.json(
{
success: 1,
description: 'Book deleted',
}
);
} else {
response.status(204).send();
}
}
...
module.exports = {
books,
find,
save,
update,
remove
};
...

Testen der API

Die Tests für den erstellten Controller speichert man im Verzeichnis test/api/controllers in der Datei books.js. Wie erwähnt verwendet Swagger standardmäßig supertest und should, wobei ersteres die Bibliothek superagent (https://visionmedia.github.io/superagent/) zur Grundlage nimmt. Sie vereinfacht REST-Anfragen per JavaScript und stellt sie über eine Fluent-API zur Verfügung. supertest erweitert die Bibliothek unter anderem um die Methode expect() für die Definition von REST- beziehungsweise HTTP-spezifischen Test-Assertions. Weitere Assertions lassen sich mit should.js definieren, wobei sich prinzipiell jede beliebige andere Assertion-Bibliothek einbinden lässt.

Folgender Code zeigt exemplarisch zwei Tests für GET-Anfragen an die Routen /books und /books/:id:

const should = require('should');
const request = require('supertest');
const server = require('../../../app');

describe('controllers', () => {
describe('books', () => {
describe('GET /books', () => {
it('should return all books as array', (done) => {
request(server)
.get('/books')
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect(200)
.end((error, response) => {
should.not.exist(error);
response.body.should.be.an.Array();
done();
});
});
});
describe('GET /books/:id', () => {
it('should return the book for a given id', (done) => {
const expectedBook = {
id: '9780201485677',
title: 'Refactoring: Improving the Design of Existing Code',
author: 'Martin Fowler',
pages: 431,
year: 1999
}
request(server)
.get(`/books/${expectedBook.id}`)
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect(200)
.end((error, response) => {
should.not.exist(error);
response.body.id.should.be.equal(expectedBook.id);
response.body.title.should.be.equal(expectedBook.title);
response.body.author.should.be.equal(expectedBook.author);
response.body.pages.should.be.equal(expectedBook.pages);
response.body.year.should.be.equal(expectedBook.year);
done();
});
});
/* Hier weitere Tests */
});
});
});

Ausführen lassen sich die Tests dann mit den Befehlen swagger project test beziehungsweise npm test.

Fazit

Swagger als API-Beschreibungssprache und das Node.js-Tool swagger-node eignen sich hervorragend, um REST-APIs zu konzipieren, zu entwickeln und zu testen. Hat man sich erst einmal in den Aufbau der Beschreibungssprache eingearbeitet, lassen sich Schnittstellen mit dem integrierten Editor relativ schnell definieren. Ebenfalls praktisch: zur Laufzeit erkennt Swagger fehlerhafte Requests oder Responses und generiert entsprechende Fehler. (jul [20])

Philip Ackermann
ist Autor mehrerer Fachbücher und Fachartikel über Java und JavaScript und arbeitet als Senior Software Developer bei der Cedalo AG in den Bereichen Industrie 4.0 und Internet of Things. Seine Schwerpunkte liegen in der Konzeption und Entwicklung von Node.js- und JEE-Projekten.


URL dieses Artikels:
http://www.heise.de/-3820025

Links in diesem Artikel:
[1] https://raml.org/
[2] https://apiblueprint.org/
[3] https://swagger.io/
[4] http://nordicapis.com/top-specification-formats-for-rest-apis/
[5] http://modeling-languages.com/modeling-web-api-comparing/
[6] https://github.com/swagger-api/swagger-node
[7] https://github.com/swagger-api/swagger-node/blob/master/docs/cli.md
[8] https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md
[9] https://github.com/senchalabs/connect
[10] http://expressjs.com/
[11] https://hapijs.com/
[12] http://restify.com/
[13] http://sailsjs.com/
[14] https://github.com/visionmedia/supertest
[15] http://swagger.io/swagger-editor/
[16] http://swagger.io/docs/swagger-tools/#swagger-editor-documentation-0
[17] http://editor.swagger.io/#/
[18] https://github.com/visionmedia/supertest
[19] https://github.com/tj/should.js/
[20] mailto:jul@heise.de