REST-APIs mit Node.js und Swagger

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.