Introduction
In this tutorial, we’ll build a full stack application using Vue.js, Node.js, Express, and Couchbase Server. In addition to those frameworks, we’ll use the Google Maps and Here Places REST APIs.
What We’ll Build
We’re going to create a single-page web app that shows points of interest (POI) around hotels selected from a list of cities. The POI will be displayed on an interactive Google map. Here’s an animation showing the final results.
There are a few extra twists to show some more advanced techniques.
- The cities are chosen by matching airports that have nearby hotels in the same city.
- We retrieve the POI using a REST call, but save them in our database.
- The client side receives data via pushes using server sent events.
Although the code is short, it shows off several techniques with Vue’s reactive data binding and property dependency features. Combined with some powerful features of Couchbase, we’ll have a nice, functioning app with not much work.
What You Need
The app is built entirely in JavaScript. You need just a few things in place to start.
- Node.js installed
- Couchbase Server 5.5.0 or later installed
You’ll also need to get keys for the Google Maps JavaScript API and the HERE REST APIs. Both can be used for free (with limitations).
The data for the application comes as a sample built-in to the Couchbase Server distribution.
Getting Started
We’ll build up the structure of the application starting with the web client code. Next comes the server side Node + Express code. Finally we’ll look at the Couchbase Server side.
We’ll take a more detailed look at the N1Ql queries, including ANSI Joins. This app makes use of the new Eventing Service and Functions. We’ll finish up by looking at the JavaScript code there.
To start, create a new directory where you want to keep the project. Open a command prompt and switch to that directory.
The Web Client Skeleton
Generating the Vue.js Client Scaffolding
The web client uses Vue.js.
We’ll use the Vue CLI to create the base project for us. I’m going to show an easy integration between the client and server side with webpack. This will mean rearranging files a little.
Install the Vue CLI using npm if you don’t already have it.
1 |
npm install -g @vue/cli |
I like using Bootstrap. There are at least a couple of projects out there that integrate Boostrap with Vue. I chose Bootstrap-Vue. This isn’t really necessary. It’s not too hard to remove this dependency if you want.
Create the project boilerplate. This is where the simple webpack template comes in. The init
command will ask some questions. Using the defaults is fine.
1 2 |
npm install -g @vue/cli-init vue init bootstrap-vue/webpack-simple client |
Restructuring and Fixup
Now, switch to the client directory. Move the package.json
and .gitignore
files created up a level. This way they’ll be shared across the project.
1 2 |
cd client/ mv package.json .gitignore .. |
The webpack configuration has a small bug in it, too. Open
webpack.config.js
. Under the section that starts with
1 |
test: /\.(png|jpg|gif|svg)$/, |
change the options line to read
1 |
name: 'assets/[name].[ext]?[hash]' |
Install Dependencies and Build
Initialize and install the base dependencies.
1 |
npm install |
Install our other dependencies. Many of these are standard packages (morgan, body-parser). I use axios for network calls. sse-channel is a nice server sent event package. It’s a bit more sophisticated and easier to use than others I’ve tried. And there’s a package to ease working with Google Maps in Vue called vue2-google-maps.
Install the rest of the dependencies as follows. This includes what we’ll need for the server.
1 |
npm install --save vue2-google-maps axios express sse-channel dotenv morgan debug cookie-parser body-parser bluebird couchbase |
This will give you a functioning Vue-based front end. To build it, since we moved
package.json
up a level, we need a tweak the npm script section. Edit package.json
in the project root and change the build line to
1 |
"build": "cd client && cross-env NODE_ENV=production webpack --progress --hide-modules && cp index.html dist/" |
Now in the client do
npm run build
.
You can open the index.html
file now, but it won’t work. We’ll skip ahead to create the server, or you can try fixing the problem here if you just want to see the stand-alone client.
The Web Server Skeleton
Navigate back to the project root and prepare the server directory.
1 2 |
mkdir server cd server |
We’re going to create the server directly. Start the base app by editing a new file app.js
. Paste the following in and save.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
const express = require('express'); const debug = require('debug')('poi:server'); const path = require('path'); const logger = require('morgan'); const cookieParser = require('cookie-parser'); const bodyParser = require('body-parser'); const http = require('http'); const app = express(); app.use(logger('dev')); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: false })); app.use(cookieParser()); app.use(function(req, res, next) { res.header("Access-Control-Allow-Origin", "*"); res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); next(); }); app.use(express.static(path.join(__dirname, '../client'))); // catch 404 and forward to error handler app.use(function(req, res, next) { console.dir(req); console.dir(res); let err = new Error('Not Found'); err.status = 404; next(err); }); // error handler app.use(function(err, req, res, next) { // set locals, only providing error in development res.locals.message = err.message; res.locals.error = req.app.get('env') === 'development' ? err : {}; // render the error page res.status(err.status || 500); res.render('error'); }); // HTTP server http.createServer(app).listen(8080); |
This is a simplified version of the final. It just serves up the boilerplate client we created earlier.
At this point, you should be able to run node app.js
in the server directory. Open a browser tab and navigate to http://localhost:8080
. You should see something like this.
Fleshing Out the Client and Server
The Web Client Code
Now we’ll go back and create the real client. In the client directory, under the subdirectory src
, open the file App.vue
. Update it as follows.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 |
<template> <div id="app"> <div class="container"> <div class="row justify-content-center"> <div class="col-12"> <h2>Points of Interest</h2> </div> <div class="col-md-12"> <b-dropdown id="cities" v-bind:text=display class="m-md-2"> <b-dropdown-item-button v-for="city in cities" v-bind:key="city.name" v-on:click="selected = city">{{ city.name }}</b-dropdown-item-button> </b-dropdown> <b-table id="destinations" :items="destinationsProvider" :fields="fields" @row-clicked="hotelSelected" striped hover></b-table> </div> <div class="col-md-12"> <GmapMap ref="map" style="width: 100%; height: 400px;" :zoom="16" :center="{lat: 43.542619, lng: 6.955665}"> <GmapMarker v-for="(marker, index) in poi" :key="index" :position="{ lat: marker.position[0], lng: marker.position[1] }" :icon="{ url: marker.icon }" /> </GmapMap> </div> <div class="col-8" /> <div class="col-4" id="tagline"> Powered by <img src="./assets/logo.png"> </div> </div> </div> </div> </template> <script> import axios from 'axios' const serverURL = location.origin; const server = axios.create({ baseURL: serverURL }); const es = new EventSource(`${serverURL}/events/poi`); export default { name: 'app', data() { return { fields: [ { key: 'name', label: 'Hotel Name', sortable: true }, { key: 'address', sortable: false }, { key: 'airportname', label: 'Airport Name', sortable: true }, { key: 'icao', label: 'ICAO Code', sortable: true } ], selected: null, cities: [], poi: [] } }, computed: { display: function() { return this.selected ? this.selected.name : 'Choose a city'; } }, watch: { selected: function() { this.$root.$emit('bv::refresh::table', 'destinations'); } }, methods: { destinationsProvider(context) { if (null === this.selected) return []; let promise = server.get(`/records/hotels/byCity/${this.selected.name}`); return promise.then(response => { return(response.data); }).catch(error => { return []; }); }, hotelSelected(record, index) { this.$refs.map.panTo({ lat: record.geo.lat, lng: record.geo.lon }); server.post('/records/select/geo', record.geo) .catch(error => { console.log(error) }); } }, mounted: function() { es.addEventListener('poi', event => this.poi = JSON.parse(event.data)); server.get('/records/destinations') .then(response => { this.cities = response.data; }) .catch(error => { console.log(error) }); } } </script> <style> #app { font-family: 'Avenir', Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-align: center; color: #2c3e50; margin-top: 60px; } #tagline img { height: 38px; margin: 10px; } h1, h2 { font-weight: normal; } ul { list-style-type: none; padding: 0; } li { display: inline-block; margin: 0 10px; } a { color: #42b983; } </style> |
This is the bulk of the client-side code.
I won’t go into detail on the template section or the css. I will point out one nice element. The Here API returns, among other things, links to icons suitable for use on Maps. If you follow the flow through, you’ll see the map markers are loading those icons directly using the include URLs.
Wiring Up the Vue Databinding
Walking through the script section, you’ll see I make heavy use of the reactive capabilities in Vue. To understand this part, it will help if you have at least some familiarity with Vue, especially computed properties and watchers, data, method, and lifecycle hooks.
We make use of the mounted
lifecycle callback to add a listener for server sent events, and to initially populate the dropdown list of cities. The heavier lifting of the business logic here happens in the database query, as we’ll see.
Let’s track how selecting a city works. Notice every item in the button dropdown is has a click listener bound that sets selected
to the city data for that entry. We have a watch method defined on selected
. Vue also automatically knows that the computed property display
depends on selected
.
That means whenever a city gets selected via the dropdown, we get a cascade of activity. Changing selected
causes display
to be recomputed. This, in turn, sets the dropdown button text, since that’s bound to display
. The selected
method in the watch
section triggers a refresh of the hotel listing table every time a new city is picked.
The table items
are bound to destinationsProvider
under methods
. Refreshing the table causes that code to execute. Like the original city list, it pulls in the hotels via an asynchronous call to our database through a server REST endpoint.
Vue takes care of a lot here for us. For example, the call to refresh the table doesn’t receive data right away. Vue will rerender the relevant parts of the DOM automatically whenever the REST call returns. We don’t have to supply any of the wiring, other than specifying the binding between items
and destinationProvider
.
Completing the Web Client
main.js
. Add the an import line and tell Vue to use the new component. Here’s the final code.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import config from './config' import Vue from 'vue' import BootstrapVue from "bootstrap-vue" import App from './App.vue' import "bootstrap/dist/css/bootstrap.min.css" import "bootstrap-vue/dist/bootstrap-vue.css" import * as VueGoogleMaps from 'vue2-google-maps' Vue.use(BootstrapVue) Vue.use(VueGoogleMaps, { load: { key: config.googleMapsKey } }) new Vue({ el: '#app', render: h => h(App) }) |
We load the Google Maps API key from a file config.js
. Create that file and for now add this placeholder code.
1 2 3 |
export default { googleMapsKey: '' } |
Build the project again (npm run build
). Start the server, reload the site, and you should see the beginnings of our real client looking like this.
The Web Server Code
Next we’ll fill out the server side. Our server both feeds the web pages out and exposes the REST API we need. The API is mostly just convenience packaging around database functionality.
In the server source, replace our original app.js
with this.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 |
global.Promise = require('bluebird'); require('dotenv').config(); const express = require('express'); const debug = require('debug')('poi:server'); const path = require('path'); const favicon = require('serve-favicon'); const logger = require('morgan'); const cookieParser = require('cookie-parser'); const bodyParser = require('body-parser'); const http = require('http'); const https = require('https'); const fs = require('fs'); const couchbase = require('couchbase'); const cluster = new couchbase.Cluster(process.env.CLUSTER); cluster.authenticate(process.env.CLUSTER_USER, process.env.CLUSTER_PASSWORD); const app = express(); app.locals.couchbase = couchbase; app.locals.cluster = cluster; app.locals.travel = cluster.openBucket('travel-sample'); app.locals.eventing = cluster.openBucket('eventing'); app.use(favicon(path.join(__dirname, 'images/favicon.ico'))); app.use(logger('dev')); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: false })); app.use(cookieParser()); app.use(function(req, res, next) { res.header("Access-Control-Allow-Origin", "*"); res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); next(); }); app.use(express.static(path.join(__dirname, '../client'))); const records = require('./routes/records'); app.use('/records', records); const events = require('./routes/events'); app.use('/events', events); // catch 404 and forward to error handler app.use(function(req, res, next) { console.dir(req); console.dir(res); let err = new Error('Not Found'); err.status = 404; next(err); }); // error handler app.use(function(err, req, res, next) { // set locals, only providing error in development res.locals.message = err.message; res.locals.error = req.app.get('env') === 'development' ? err : {}; // render the error page res.status(err.status || 500); res.render('error'); }); // HTTP server const http_port = process.env.HTTP_PORT; const http_server = http.createServer(app); http_server.listen(http_port); http_server.on('error', onError); http_server.on('listening', onListening); // HTTPS server const options = { key: fs.readFileSync(path.join('ssl', 'key.pem')), cert: fs.readFileSync(path.join('ssl', 'cert.pem')) }; const https_port = process.env.HTTPS_PORT; const https_server = https.createServer(options, app); https_server.listen(https_port); https_server.on('error', onError); https_server.on('listening', onListening); /** * Event listener for HTTP/S server "error" event. */ function onError(error) { if (error.syscall !== 'listen') { throw error; } let bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port; // handle specific listen errors with friendly messages switch (error.code) { case 'EACCES': console.error(bind + ' requires elevated privileges'); process.exit(1); break; case 'EADDRINUSE': console.error(bind + ' is already in use'); process.exit(1); break; default: throw error; } } /** * Event listener for HTTP/S server "listening" event. */ function onListening() { let addr = this.address(); let bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port; debug('Listening on ' + bind); } |
The key differences are the Couchbase Server Node client setup, and wiring in the routes for the REST endpoints. There’s other additional code for things like serving over both http and https, too. We won’t look at those parts.
Connecting to Couchbase Server
The two blocks of code for connecting to our database are very straightforward.
1 2 3 4 5 6 7 8 9 10 |
const couchbase = require('couchbase'); const cluster = new couchbase.Cluster(process.env.CLUSTER); cluster.authenticate(process.env.CLUSTER_USER, process.env.CLUSTER_PASSWORD); ... app.locals.couchbase = couchbase; app.locals.cluster = cluster; app.locals.travel = cluster.openBucket('travel-sample'); app.locals.eventing = cluster.openBucket('eventing'); |
The first three lines import the Couchbase Node client, create a new cluster object representing a cluster of database nodes, and authenticate to that cluster. That initiates the connection to the database.
For convenience, we add references to the client and cluster objects to app.locals
. This makes them globally available.
Finally, the code establishes and saves connections to two buckets. Buckets are a high-level organizational structure in Couchbase.
The first bucket we will populate with sample data that comes with Couchbase Server installations. For the second bucket, I’m fudging things a little here. We need a meta-data bucket for the Eventing Service. We need just a couple of extra documents stored, as we’ll see, that need to go somewhere besides the main bucket. Rather than create a third bucket, I just put them in with the eventing data. You wouldn’t typically use this shortcut in production.
Static Files and API Routes
We have just a few lines of code we need to direct Express to serve our static pages built from the client code and to organize our server data API.
1 2 3 4 5 6 |
app.use(express.static(path.join(__dirname, '../client'))); const records = require('./routes/records'); app.use('/records', records); const events = require('./routes/events'); app.use('/events', events); |
The boilerplate index.html
home page for the app adds dist
to all the file paths. This means our static files are actually served from a root directory of <project path>/client/dist
.
I’ve separated the data API into two groups, organized under a general routes
subdirectory. There’s the endpoints beginning with records
. These will retrieve data from the database.
The events
route is unique. The endpoints are used by both the web client and by the Couchbase Eventing Service.
Let’s look at the records
code first.
Database Access API
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
const express = require('express'); const router = express.Router(); router.get('/destinations', async function(req, res, next) { let couchbase = req.app.locals.couchbase; let travel = req.app.locals.travel; let queryPromise = Promise.promisify(travel.query, { context: travel }); let query = `SELECT DISTINCT airport.city as name FROM `travel-sample` airport INNER JOIN `travel-sample` hotel USE HASH(probe) ON hotel.city = airport.city WHERE airport.type = 'airport' AND hotel.type = 'hotel';`; query = couchbase.N1qlQuery.fromString(query); await queryPromise(query) .then(rows => res.json(rows)) .catch(err => { console.log(err); res.status(500).send({ error: err }); }); }); router.get('/hotels/byCity/:id', async function(req, res, next) { let couchbase = req.app.locals.couchbase; let travel = req.app.locals.travel; let queryPromise = Promise.promisify(travel.query, { context: travel }); let query = `SELECT hotel.name, hotel.address, airport.airportname, airport.icao, hotel.geo FROM `travel-sample` airport INNER JOIN `travel-sample` hotel ON hotel.type = 'hotel' AND hotel.city = airport.city WHERE airport.type = 'airport' AND airport.city = '${req.params.id}' LIMIT 5;`; query = couchbase.N1qlQuery.fromString(query); await queryPromise(query) .then(rows => res.json(rows)) .catch(err => { console.log(err); res.status(500).send({ error: err }); }); }); router.post('/select/geo', async function(req, res, next) { let couchbase = req.app.locals.couchbase; let travel = req.app.locals.travel; let queryPromise = Promise.promisify(travel.query, { context: travel }); let location = JSON.stringify(req.body); let query = `UPSERT INTO `travel-sample` (KEY, VALUE) VALUES('trigger', ${location})`; query = couchbase.N1qlQuery.fromString(query); await queryPromise(query) .then(response => res.json(response)) .catch(err => { console.log(err); res.status(500).send({ error: err }); }); }); module.exports = router; |
We have three routes defined here, /destinations
, /hotels/byCity/:id
, and /select/geo
. They all have the same basic structure. We get our database references, use bluebird to create a promise versions of the query method, construct a N1QL query, fire it off and return the results.
Let’s work through the queries, starting with the simplest.
N1QL Queries
We use The /select/geo
endpoint to store the current hotel choice made by the user. Here’s the query broken out.
1 |
UPSERT INTO `travel-sample` (KEY, VALUE) VALUES('trigger', ${location}) |
UPSERT
will modify a document, or create it if it doesn’t already exist. We store the geolocation of the chosen hotel in a document with an id of trigger
. That probably sounds odd. It will make more sense later, when we get to the Eventing code. What we’re really interested in isn’t just the hotel location, but the points of interest nearby. This document will set off the sequence that retrieves those POI. Hence the reason for calling the document trigger
.
Here’s an example of the document created.
trigger
1 2 3 4 5 |
{ "accuracy": "APPROXIMATE", "lat": 43.9397954, "lon": 4.805895400000054 } |
To understand the /hotels/byCity/:id
query, first take a look at a couple of example documents.
hotel_1359
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
{ "address": "13-15 Avenue Monclar", "alias": null, "checkin": null, "checkout": null, "city": "Avignon", "country": "France", "description": "Family run hotel overlooking a flowered garden, within a private carpark. Internet wi-fi available in the whole building. Recently renovated rooms with the typical Provencal style. 7 languages spoken. Private taxi service.", "directions": "just behind the central station, which faces the main avenue of downtown and the bus station", "email": null, "fax": "04 26 23 68 31", "free_breakfast": true, "free_internet": false, "free_parking": true, "geo": { "accuracy": "APPROXIMATE", "lat": 43.9397954, "lon": 4.805895400000054 }, "id": 1359, "name": "Avignon Hotel Monclar", "pets_ok": true, "phone": "+33 4 90 86 20 14", "price": "Double room with ensuite shower and bathroom €30-60, studios and apartments from €75, breakfast €7 can be taken in the garden in season 7:30AM 11AM", "public_likes": ["Vicente Williamson"], "reviews": [...], "state": "Provence-Alpes-Côte d'Azur", "title": "Avignon", "tollfree": null, "type": "hotel", "url": "http://hotel-monclar.com/en", "vacancy": true } |
airport_1361
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
{ "airportname": "Caumont", "city": "Avignon", "country": "France", "faa": "AVN", "geo": { "alt": 124, "lat": 43.9073, "lon": 4.901831 }, "icao": "LFMV", "id": 1361, "type": "airport", "tz": "Europe/Paris" } |
For our hotels table, we need the hotel name, address, geolocation, the airport name, and airport code. Obviously that’s combining data from both documents. We do this using an INNER JOIN
. Here’s the query.
1 2 3 4 5 6 7 |
SELECT hotel.name, hotel.address, airport.airportname, airport.icao, hotel.geo FROM `travel-sample` airport INNER JOIN `travel-sample` hotel ON hotel.type = 'hotel' AND hotel.city = airport.city WHERE airport.type = 'airport' AND airport.city = '${req.params.id}' LIMIT 5; |
Walking through it, you can see we are able to perform the join using documents from the same bucket. I use aliases to make things clearer. We use the city from each document to form the join condition. Notice I also use the document type
, both in the join condition, and in the WHERE
clause. The join conditions can be quite sophisticated. Read this blog post for more details and examples.
Finally, let’s examine how we came up with our city list in the first place. This is the query for the /destinations
endpoint.
1 2 3 4 5 6 7 |
SELECT DISTINCT airport.city as name FROM `travel-sample` airport INNER JOIN `travel-sample` hotel USE HASH(probe) ON hotel.city = airport.city WHERE airport.type = 'airport' AND hotel.type = 'hotel'; |
The only result returned is a list of city names. In this case, we’re using an inner join effectively as a filter. By matching airport cities to hotel cities, we get back a list of only cities that have both.
Inner joins can use two different approaches algorithmically. The first join we looked at uses the default nested-loop join.
This last example uses an in-memory hash table. This can significantly speed up a join, particularly where one of the two datasets is small. We used the “USE HASH()” hint to inform N1QL how we wanted the query optimized. There’s a “probe” side and a “build” side. The hash table is built from the build side data. The the join is performed doing lookups from the probe side data.
The hint we gave above tells N1QL to use the hotel data for the probe side in this case. I.e. it will build the table from the airport data, then do the hash lookups using the hotel data.
If you haven’t before, I encourage you to try these queries directly in the Couchbase Server Query Workbench, part of the web administration console.
Server Sent Events
We already mentioned setting up an event listener for server sent events on the client side. These two endpoints show what’s needed on the server.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
const express = require('express'); const router = express.Router(); const sse = require('sse-channel'); const poi = new sse(); router.get('/poi', (req, res) => poi.addClient(req, res)); router.post('/poi', async function(req, res, next) { res.send(''); let msg = { event: 'poi' }; msg.data = JSON.stringify(req.body); poi.send(msg); }); module.exports = router; |
The “get” version of the poi
endpoint is called by the browser when the EventSource
is constructed. You can see we simply add the caller as a client.
We use the “post” version as the go between to send data to the client. The res.send('');
line gives a hint about how this works. In the Eventing code, we’ll use the N1QL cURL capabilities to push data to this endpoint. The empty reply is there to close out that transaction.
The server then forwards the data to any listening clients. There are a lot more details. If you want to find out more, this article has good information.
Finishing the Server
To finish the server side of our project, create a subdirectory routes
under the server directory.
Copy the records
code above into a file under routes called records.js
. Copy the events
code above into a file called events.js
. And, finally, in the server directory itself, create a new file named .env
. Paste the following configuration parameters there and save. (Of course, change any settings as you need.)
1 2 3 4 5 6 |
HTTP_PORT=8080 HTTPS_PORT=8081 DEBUG=node,http,poi:* CLUSTER='couchbase://localhost:8091' CLUSTER_USER=Administrator CLUSTER_PASSWORD=password |
The server should be ready to go now. In the server root directory, run node app.js
. Don’t forget to build the client code first.
The Couchbase Server Eventing Service Code
Here’s the last bit of special sauce that makes this app work. In version 5.5.0, Couchbase introduced the Eventing Service. This is probably my favorite new feature in the 5.5 release series. Couchbase Functions is the first component offered as part of this service. In short, Functions let you run code on the database server in response to changes in the database.
Functions are written in standard JavaScript, with some additions and restrictions. To create the function we need, follow these steps.
Eventing Meta-Data Bucket
First, create a bucket for the eventing meta-data.
- Open the Couchbase Server console and log in if needed
- Click “Buckets” in the left side menu
- Click “Add Bucket” in the upper right corner
- Enter
eventing
for the bucket name in the dialog that pops up - Click “Add Bucket” to finish
Adding a Function
Now, configure the function and add the code.
- Click “Eventing” in the left side menu
- Click “Add Function” in the upper right hand corner
This will bring up a dialog box.
- Select
travel-sample
as the Source Bucket - Select
eventing
as the Metadata Bucket - Enter
monitor
(or whatever you want) for the Function Name - Under “Bindings” set
type
to “Alias”,name
to “travel-sample”, andvalue
to “db” - Click “Next: Add Code”
This will drop you into the code editor. It’s pre-populated with the function signatures. Copy this code in instead.
https://gist.github.com/HodGreeley/9e25f9072247e180ec5cd764d9048c3b#file-poi-js
Deploying a Function
To deploy this code, first click “Save”, then click “Eventing” in the left side menu again. You should see an entry for the function. Click anywhere in that bar. You should see it expand.
Click “Deploy”, then just click “Deploy Function”.
Understanding the Function Code
OnUpdate
gets called any time a document changes. It receives the document and document meta-data as parameters.
We’re looking for the trigger
document to change, indicating the selection of a new hotel. The first line filters out all other docs based on the document id (sometimes referred to as the document key).
The next line shows a few interesting things. Recall db
is an alias for the travel sample bucket. db['here']
directly retrieves a document with id here
. This is where we will store the credentials needed for the HERE mapping services.
We prepare the URL and data for our request for points of interest. Here has a lot of interesting features in their API. We’re just making a basic request.
With that information in hand, we’re ready for our cURL call. Building the N1QL query, we see one of the modifications to standard JavaScript: You can write your queries in-line just the way you would construct them in the Query Workbench.
We see another nice detail in the cURL query. N1QL provides a convenient syntax for filtering results. By adding the path .results.item
to the end, we grab only the data we want.
We then execute the query and, using that same db[<key>]
shorthand, update our poi
document. This is an example of using a Function to augment data. In another scenario, we might derive our update entirely from records in the database. For example, you could fill out all the details of a shopping cart as a customer makes selections.
Finally, with our points of interest in hand, we again use cURL to push the data to our web server endpoint. Recall the “post” version of the poi
API ingests incoming data and pushes it back out to any registered clients. Thus we can have the client UI react to database changes without having to poll.
Final Steps
We’re now ready to put this all together. You can try out the app as is, but the maps part won’t work yet. For that, you need a Google Maps API key, and a set of credentials from HERE.
The Maps key goes in the config.js
file in the client code. Save the HERE keys in a document in the eventing
bucket. You can do this directly in the admin console by clicking “Documents” in the left menu, then “Add Document” in the upper right. Use this as a template.
here
1 2 3 4 |
{ "id": "TPxxxxxxxxxxxxxxxxxx", "code": "whsxxxxxxxxxxxxxxxxxxx" } |
And, last of all, as a security measure, cURL is disabled by default. In the admin console, do the following.
- Click “Settings” in the left side menu
- Click to expand “Advanced Query Settings”
- Select “Unrestricted” under “CURL() Function Access”
This is not what you want for production. Instead you would want a whitelist of a select set of URLs. This will do for our project, though.
With that, in the web server directory, run node app.js
. Open localhost:8080
in your browser (or whatever you chose in .env
) and try it out.
Source
You can find the source for the whole app on GitHub here. I’ve included a script setup
to simplify prepping everything. Just run ./setup
and supply your keys. (You may have to make it executable first.) You still need to run npm install
and build the client code.
Webinar
Postscript
Couchbase is open source and free to try out.
Get started with sample code, example queries, tutorials, and more.
Find more resources on our developer portal.
Follow us on Twitter @CouchbaseDev.
You can post questions on our forums.
We actively participate on Stack Overflow.
Hit me up on Twitter with any questions, comments, topics you’d like to see, etc. @HodGreeley
Good post! Thank you.
The first link to the github repo is bad. It has an “:” at the end.