Headless Blog mit dem JavaScript Framework Sails.js
SailsJS – Ohne Patenthalse zur API
Vorräte kontrollieren
Installation von Sails
Wir starten mit einem leeren Ordner, installierten Node.js und einem Webbrowser der uns die Seite https://sailsjs.com/get-started anzeigt.
In (relativ) schneller Abfolge führen wir
npm install sails -g sails new FullSails
aus und beantworten die Fragen des Installers. Wir wählen für unsere Zwecken das leere Sails Projekt aus und starten anschließend mit
cd FullSails sails lift

Der Ordner api beinhaltet alles was wir für unsere Anwendung benötigen. Ansonsten ist noch der Ordner config von Interesse, da er die Konfigurationsdateien beinhaltet um z.B. im nächsten Schritt unsere Datenbank anzubinden. Neben den genannten gibt es noch
assets: Beinhaltet die statischen Assets für die Auslieferung mittels des Webservers ( Bilder, js, etc )
tasks: Umfasst eine Sammlung von Tasks für das Entwickeln wie Linting und Kompilieren von js und scss
views: Dort werden generierte Ansichten abgelegt. ( Standartmäsig im ejs Format )
Da wir im Rahmen des Projektes nur eine API Entwickeln, können wir im Prinzip alles ignorieren außer api und config.

Installation von PostgreSQL
Nun müssen wir noch unsere Datenbank verbinden. Die Entscheidung ist hierbei auf eine PostgreSQL Datenbank gefallen.
Es ist nicht nötig an dieser Stelle eine lokale Datenbank zu installieren, da später sowieso auf Docker umgestellt wird. Wer auf Docker verzichten will, der sollte sich für sein Betriebssystem nun eine PostgreSQL Datenbank zulegen. Am besten richtet ihr dann gleich eine neue Datenbank mit Nutzer ein. Dieser Artikel beinhaltet alles was ihr dafür benötigt.
Ist das erledigt, öffnen wir einfach mal die Datei config/datastores.js.
default: { /*************************************************************************** * * * Want to use a different database during development? * * * * 1. Choose an adapter: * * https://sailsjs.com/plugins/databases * * * * 2. Install it as a dependency of your Sails app. * * (For example: npm install sails-mysql --save) * * * * 3. Then pass it in, along with a connection URL. * * (See https://sailsjs.com/config/datastores for help.) * * * ***************************************************************************/ // adapter: 'sails-mysql', // url: 'mysql://user:password@host:port/database', },
Im Prinzip sagt uns der Kommentar genau was getan werden muss, also fix auf https://sailsjs.com/plugins/databases und die Anweisungen befolgen:
npm install sails-postgresql --save
... // adapter: 'sails-mysql', // url: 'mysql://user:password@host:port/database', adapter: 'sails-postgresql', url: 'localhost://sails:fullsailsahead@host:port/fullsails', ...
An dieser Stelle benutze ich nun die Datenbank fullsails mit dem Nutzer sails und dem Password fullsailsahead. ( Anm.: Die Datenbank ist hier für eine lokale Verbindung eingerichtet )
Leinen Los
Nun, da das Projekt lokal läuft, sollten wir uns um die Docker Container kümmern. Dieser wird für die CI Pipeline benötigt und erlaubt es uns später ein Rechnerunabhängiges deployment einfacher umzusetzen.
Um die Anwendung mit Docker zu versehen, benötigen wir 2 Dockerfile und 1 docker-compose.yml. Es bietet sich herbei die Docker Container als Ordnerstruktur unter der docker-compose.yml anzulegen. Konkret sollten wir unsere Anwendung, die momentan so aussieht:
- Fullsails
- api .…
- assets .…
- .…
umbauen, sodass die eigentlich Sails App gekapselt wird:
- FullSails
- backend
- api .…
- assets .…
- Dockerfile
- .…
- postgresql
- Dockerfile
- .….
- docker-compose.yml
- backend
PostgreSQL mit Docker
Um eine PostgreSQL Datenbank mit Docker zu versehen, bietet sich das Dockerimage postgres:latest an. Somit lautet die Datei postgresql/Dockerfile
FROM postgres:latest
Und das wärs auch gewesen, aber wir benötigen ja noch die Datenbank mit User und Password. Dafür bietet das PostgreSQL Image die Möglichkeit eine init.sql Datei anzulegen, die beim Containerstart alles anlegt.
CREATE USER sails with PASSWORD 'fullsailahead'; CREATE DATABASE fullsails; GRANT ALL PRIVILEGES ON DATABASE fullsails TO sails;
FROM postgres:latest COPY init.sql /docker-entrypoint-initdb.d/
Sails mit Docker
Sails ist nicht ganz so komfortabel wie die Datenbank, aber immer noch sehr simpel. Die backend/Dockerfile lautetFROM node:latest # add sources COPY . /app WORKDIR /app RUN npm install CMD ["npm","run", "start"]
Kurz umrissen wird hier einfach das neueste Node.js Image geladen, unser Quellcode wird in /app kopiert und anschließend wechseln wir dorthin und installieren mit npm install
unsere Abhängigkeiten.
Wichtig ist hier das wir die Anwendung mit npm run start
hochfahren um sie in der Produktion Environment laufen zu lassen.
Komposition der beiden Docker Container
Nun fehlt noch die Verknüpfung der beiden Container. Hierfür legen wir uns eine docker-compose.yml an.
version: '3.1' services: api: build: ./backend ports: - "80:80" - "9229:9229" volumes: - ./backend:/app command: bash -c "npm install && npm start" environment: - NODE_ENV=development networks: - test postgresql: build: ./postgresql ports: - "5432:5432" networks: - test networks: test: external: name: test
Von unten nach oben passiert nun folgendes:
- Anlegen eines Netzwerkes zum kapseln der Container Verbindung
- Definition des
postgresql
services. Hier wird die Dockerfile in ./postgresql gebaut, der Standartport nach außen geöffnet und der Container in das Netzwerktest
gelegt. - Definition des
api
services. Analog wird der Inhalt von ./backend gebaut. Anschließend werden die ports 80 (Webserver) und 9229 (Node Debug, Optional) geöffnet.
Anschließend wird noch das Volume eingebunden um die lokalen Änderungen ohne Neubau des Containers zu übernehmen. Wir setzen nun noch dieNODE_ENV
variable und fügen den Service ebenfalls zum Netzwerk hinzu.
Dascommand
wird hier zum starten des Containers benutzt und überschreibt den Befehl in der backend/Dockerfile. Dies erlaubt es uns mittelsdocker-compose up
die Abhängigkeiten zu aktualisieren.
Nun müssen wir noch die Verbindung zur Datenbank mittels Docker anpassen. Hierfür ersetzen wir in backend/config/datastore.js die hostadresse mit dem Namen des PostrgreSQL Services
... adapter: 'sails-postgresql', url: 'postgresql://sails:fullsailahead@postgresql:5432/fullsails', ...
docker-compose up

Also öffnen wir die beschriebene Datei in backend/config/env/production.js und ersetzen die werte für onlyAllowOrigins sowie port. Dadurch wird der production server auch auf port 80 gestartet werden, was unser gewünschter Port für die API ist.
... sockets: { /*************************************************************************** * * * Uncomment the `onlyAllowOrigins` whitelist below to configure which * * "origins" are allowed to open socket connections to your Sails app. * * * * > Replace "https://example.com" etc. with the URL(s) of your app. * * > Be sure to use the right protocol! ("http://" vs. "https://")         * * * ***************************************************************************/ onlyAllowOrigins: [ "http://0.0.0.0" ], ... /************************************************************************** * * * Lift the server on port 80. * * (if deploying behind a proxy, or to a PaaS like Heroku or Deis, you * * probably don't need to set a port here, because it is oftentimes * * handled for you automatically. If you are not sure if you need to set * * this, just try deploying without setting it and see if it works.) * * * ***************************************************************************/ port: 80, ...
docker-compose up
Anker Lichten
Nun da unser Projekt in Docker läuft, können wir beginnen die API zu Programmieren. Sails bietet dafür mehrere Optionen:- Blueprints
- Actions
- Controller
Blueprints
Blueprints bieten die Möglichkeit ohne jegliche Implementierung gewisse Standartaktionen an unseren Datenbankmodels durchzuführen. Diese automatisierten Routen werden aus unseren Models generiert und bieten ein umfangreiche REST API mit erweiterten Konfigurationen an. Die Verfügbaren Routen sind unter Sails Blueprint actions zu finden. Im Rahmen unseres Projektes benötigen wir jedoch Routen die mittels POST / PUT / DELETE funktionieren sowie in mehreren Fällen andere Routen. Deshalb wurde hier gegen die Benutzung der Blueprints entschieden. Theoretisch ist es jedoch möglich die Blueprint Routen und Aktionen anzupassen.Actions
Actions sind prinzipiell in einzelne Dateien aufgeteilte Routen. Der Gedanke hierbei ist, das manche Controller im Verlauf der Entwicklung zu groß und unübersichtlich werden. Durch die Action Dateien werden diese aufgetrennt und in unabhängige Funktionen geteilt. Da wir hier eine sehr überschaubare API bauen, werden wir auf die Actions verzichten und den klassischen Controller Weg gehenController
Der klassische Controller. Eine Datei die alle Routen zu einem Model/Bereich beinhaltet.Generatoren
Egal für was wir uns entscheiden, sails liefert einen ganzen Satz an möglichen Generatoren mit. Falls du einen Generator willst der dir nicht nur normale Sails Komponenten genierert finden sich auch ein paar nützliche Community Generatoren ( z.b. um Views nicht in .ejs sonder als React Componente zu generieren ).
Da wir uns für die Benutzung von Controllern entschieden haben, bleiben uns zwei Optionen:
sails generate controller
+sails generate model
: Bietet uns die Option direkt in der Console Aktionen im Controller und Felder im Model anzugeben.sails generate api
: Generiert ein Controller/Model paar
Der zweite Ansatz erlaubt es uns zwar beide Einträge mit einer Zeile zu definieren, jedoch nicht die Angabe von Feldern und Aktionen vorzunehmen. Wir verwenden daher die generatoren für model + controller getrennt.
Starten wir mit dem User
cd backend/ sails generate controller User list add get edit delete sails generate model User name email token
Werfen wir nun einen Blick in api/controllers so finden wir dort den UserController.js
/** * UserController * * @description :: Server-side actions for handling incoming requests. * @help :: See https://sailsjs.com/docs/concepts/actions */ module.exports = { /** * `UserController.list()` */ list: async function (req, res) { return res.json({ todo: 'list() is not implemented yet!' }); }, /** * `UserController.add()` */ add: async function (req, res) { return res.json({ todo: 'add() is not implemented yet!' }); }, /** * `UserController.get()` */ get: async function (req, res) { return res.json({ todo: 'get() is not implemented yet!' }); }, /** * `UserController.edit()` */ edit: async function (req, res) { return res.json({ todo: 'edit() is not implemented yet!' }); }, /** * `UserController.delete()` */ delete: async function (req, res) { return res.json({ todo: 'delete() is not implemented yet!' }); } };
und unter api/models das model User.js
/** * User.js * * @description :: A model definition represents a database table/collection. * @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models */ module.exports = { attributes: { // ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗ // ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗ // ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝ name: { type: 'string' }, email: { type: 'string' }, token: { type: 'string' } // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ // ║╣ ║║║╠╩╗║╣ ║║╚═╗ // ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝ // ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ // ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗ // ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ }, };
Durch die Angabe der benötigten Aktionen für den Controller und Felder für das Model müssen wir nur noch die Leerstellen auffüllen.
Analog generieren wir nun noch die restlichen Models und Controller
sails generate controller Comment add get edit delete list sails generate model Comment message sails generate controller Post add get edit delete list sails generate model Post message
Damit erhalten wir alle benötigten JS Filtes für unsere API. Jetzt müssen wir nur noch unsere Routen auf die korrekte Aktion zeigen lassen. Dafür öffnen wir die Datei config/routes.js und tragen unter dem Block „API ENDPOINTS“ alle benötigten Routen ein, Das Schema lautet dabei ‚<request type> <route>’: ‚<controller>.<function>’
... // ╔═╗╔═╗╦ ╔═╗╔╗╔╔╦╗╔═╗╔═╗╦╔╗╔╔╦╗╔═╗ // ╠═╣╠═╝║ ║╣ ║║║ ║║╠═╝║ ║║║║║ ║ ╚═╗ // ╩ ╩╩ ╩ ╚═╝╝╚╝═╩╝╩ ╚═╝╩╝╚╝ ╩ ╚═╝ // User 'post /api/users': 'UserController.add', 'get /api/users/:id': 'UserController.get', 'put /api/users/:id': 'UserController.edit', 'delete /api/users/:id': 'UserController.delete', 'get /api/users': 'UserController.list', // Post 'post /api/posts': 'PostController.add', 'get /api/posts/:id': 'PostController.get', 'put /api/posts/:id': 'PostController.edit', 'delete /api/posts/:id': 'PostController.delete', 'get /api/posts': 'PostController.list', // Comments 'post /api/posts/:postId/comments': 'CommentController.add', 'get /api/posts/:postId/comments/:id': 'CommentController.get', 'put /api/posts/:postId/comments/:id': 'CommentController.edit', 'delete /api/posts/:postId/comments/:id': 'CommentController.delete', 'get /api/posts/:postId/comments': 'CommentController.list', // ╦ ╦╔═╗╔╗ ╦ ╦╔═╗╔═╗╦╔═╔═╗ // ║║║║╣ ╠╩╗╠═╣║ ║║ ║╠╩╗╚═╗ // ╚╩╝╚═╝╚═╝╩ ╩╚═╝╚═╝╩ ╩╚═╝ ...
Öffnen wir nun die URL http://localhost/api/users im Browser/Postman/Curl so erhalten wir eine gültige Antwort vom Server und die API ist damit live. Nun können wir anfangen die Leerstellen zu füllen.docker-compose restart
Segel setzen
Um die API zu vervollständigen sind nun noch eine Handvoll Schritte von Nöten. Wir müssen unsere Models um nicht-primitive Typen und Beziehungen erweitern, unsere Business Logik in den Controller eintragen und unsere Daten initialisieren.
Model befüllen
Wir fügen nun zu unseren Models noch die benötigten Verknüpfungen hinzu.
- User: Keine Beziehung zu anderen Models
- Post: 1 zu 1 Beziehung mit User für die Author Information
Wir erweitern das Model um das Attributauthor
... attributes: { // ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗ // ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗ // ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝ message: {type: 'string'}, // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ // ║╣ ║║║╠╩╗║╣ ║║╚═╗ // ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝ // ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ // ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗ // ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ author: { model: 'User' } ...
-
Comments: 1 zu 1 Beziehung mit Post
Analog zu User erweitern wir um das AttributpostId
... attributes: { // ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗ // ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗ // ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝ message: { type: 'string' }, // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ // ║╣ ║║║╠╩╗║╣ ║║╚═╗ // ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝ // ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ // ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗ // ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ postId: { model: 'Post' }, ...
Controller befüllen
Durch die Vorgegebene Struktur füllen wir nun noch schnell die benötigten Aktionen.
api/controllers/UserController.js:
... module.exports = { /** * `UserController.index()` */ list: async function (req, res) { const users = await User.find({ where: {}, select: ['name', 'email'] }) return res.json({ users: users }) }, /** * `UserController.create()` */ add: async function (req, res) { const token = uuid(); const createdUser = await User.create({ name: req.body.name, email: req.body.email, token: token }).fetch(); return res.json({ id: createdUser.id, token: createdUser.token, name: createdUser.name, email: createdUser.email }); }, /** * `UserController.show()` */ get: async function (req, res) { const foundUser = await User.findOne({ id: req.params.id }); return res.json({ id: foundUser.id, name: foundUser.name, email: foundUser.email }); }, /** * `UserController.edit()` */ edit: async function (req, res) { const updateUser = await User .update({id: req.params.id}) .set({ name: req.body.name, email: req.body.email }) .fetch() .then( (users) =&gt; users[0]); return res.json({ id: updateUser.id, name: updateUser.name, email: updateUser.email }); }, /** * `UserController.delete()` */ delete: async function (req, res) { await User.destroy({id: req.params.id}); return res.ok(); } };
api/controllers/PostController.js:
... module.exports = { /** * `PostController.add()` */ add: async function (req, res) { const foundUser = await User.findOne({token: req.headers.token}); if (!foundUser) return res.forbidden(); const createdPost = await Post.create({ message: req.body.message, author: foundUser.id, }) .fetch() .then((post) =&gt; Post.findOne({id: post.id}).populate('author')); return res.json(Post.toResJson(createdPost)); }, /** * `PostController.get()` */ get: async function (req, res) { const foundPost = await Post.findOne({id: req.params.id}) .populate('author'); return res.json(Post.toResJson(foundPost)); }, /** * `PostController.edit()` */ edit: async function (req, res) { const updatedPost = await Post.update({id: req.params.id}) .set({ message: req.body.message }) .fetch() .then((post) =&gt; Post.findOne({id: post[0].id}).populate('author')); return res.json(Post.toResJson(updatedPost)); }, /** * `PostController.delete()` */ delete: async function (req, res) { await Post.destroy({id: req.params.id}); return res.ok(); }, /** * `PostController.show()` */ list: async function (req, res) { const listPosts = await Post.find() .populate('author') .then( (results) =&gt; results.map((post) =&gt; {return Post.toResJson(post)})); return res.json({ posts: listPosts }); } };
api/controllers/CommentController.js
... module.exports = { /** * `CommentController.add()` */ add: async function (req, res) { const createdComment = await Comment.create({ postId: req.params.postId, message: req.body.message, }) .fetch() return res.json(Comment.toResJson(createdComment)); }, /** * `CommentController.get()` */ get: async function (req, res) { const foundComment = await Comment.findOne({id: req.params.id}); return res.json(Comment.toResJson(foundComment)); }, /** * `CommentController.edit()` */ edit: async function (req, res) { const updatedComment = await Comment.update({id: req.params.id}) .set({message: req.body.message}) .fetch() .then( (results) =&gt; results[0]); return res.json(Comment.toResJson(updatedComment)); }, /** * `CommentController.delete()` */ delete: async function (req, res) { await Comment.destroy({id: req.params.id}) return res.ok(); }, /** * `CommentController.list()` */ list: async function (req, res) { const listComment = await Comment.find({postId: req.params.postId}) .then( (results) =&gt; results.map((comment) =&gt; {return Comment.toResJson(comment)})); return res.json({ postId: req.params.postId, comments: listComment }) } };
Post.js... toResJson: function (comment) { return { id: comment.id, message: comment.message, postId: comment.postId.id ? comment.postId.id : comment.postId, } } };
... toResJson: function (post) { return { id: post.id, message: post.message, author: { id: post.author.id, name: post.author.name } } } };
Daten initialisieren
Da wir unsere Anwendung noch nie mit Models gestartet haben, befinden sich auch noch keine Daten in der Datenbank. Sails bietet eine automatisierte Migration an, solange die Anwendung nicht mit NODE_ENV=production
läuft.
Schalten wir diese migration für die dev Umgebung ein und setzen das migrate flag:
config/model.js
... /*************************************************************************** * * * How and whether Sails will attempt to automatically rebuild the * * tables/collections/etc. in your schema. * * * * &amp;gt; Note that, when running in a production environment, this will be * * &amp;gt; automatically set to `migrate: 'safe'`, no matter what you configure * * &amp;gt; here. This is a failsafe to prevent Sails from accidentally running * * &amp;gt; auto-migrations on your production database. * * &amp;gt; * * &amp;gt; For more info, see: * * &amp;gt; https://sailsjs.com/docs/concepts/orm/model-settings#?migrate * * * ***************************************************************************/ migrate: 'alter', ...
Nun müssen wir unsere Anwendung stoppen
docker-compose down
und anschließend einmalig nicht mit NODE_ENV=production
starten. Hierfür passen wir kurzerhand die package.json Datei an.
und führen danach... &quot;start&quot;: &quot;NODE_ENV=dev node app.js&quot;, ...
aus. Wir sollten in der Ausgabe folgende Zeilen sehendocker-compose up

Nun müssen wir die Änderung in package.json wieder rückgängig machen
und die Anwendung mit... &quot;start&quot;: &quot;NODE_ENV=production node app.js&quot;, ...
neu starten.docker-compose restart
Volle Fahrt voraus
Das wars. Nun können wir unsere Tests gegen die API laufen lassen. Hierfür benutzten wir die vorbereitete Postman collection um den Stand der Anwendung lokal zu verifizieren.
Im letzten Schritt müssen wir die Anwendung noch an die CI Umgebung anbinden um die Tests beim push mitlaufen zu lassen.
Nützliche Hinweise
Was hier nicht gemacht wurde, ist die Einrichtung eines hot-reload Systems. Dies lässt sich jedoch schmerzfrei mittel sails-hook-autoreload umsetzen, zumindest bis weitere plugins hinzukommen. Wer kein Interesse an einem inoffiziellem Plugin hat, der kann mittels nodemon/gulp-watch oder ähnlichem bei einer Dateiänderung sails neu laden lassen.
Interessant ist auch die Möglichkeit sich direkt Views mittels Generatoren zu erzeugen, e.g. ReactJs
Happy Sailing,
Maggistro