npm install -g @vue/cli
init
command will ask some questions. Using the defaults is fine.npm install -g @vue/cli-init vue init bootstrap-vue/webpack-simple client
package.json
and .gitignore
files created up a level. This way they’ll be shared across the project.cd client/ mv package.json .gitignore ..
webpack.config.js
. Under the section that starts with
test: /\.(png|jpg|gif|svg)$/,
name: 'assets/[name].[ext]?[hash]'
npm install
npm install --save vue2-google-maps axios express sse-channel dotenv morgan debug cookie-parser body-parser bluebird couchbase
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
"build": "cd client && cross-env NODE_ENV=production webpack --progress --hide-modules && cp index.html dist/"
npm run build
.
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.mkdir server cd server
app.js
. Paste the following in and save.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);
node app.js
in the server directory. Open a browser tab and navigate to http://localhost:8080
. You should see something like this.src
, open the file App.vue
. Update it as follows.<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>
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.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
.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.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.items
and destinationProvider
.main.js
. Add the an import line and tell Vue to use the new component. Here’s the final code.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) })
config.js
. Create that file and for now add this placeholder code.export default { googleMapsKey: '' }
npm run build
). Start the server, reload the site, and you should see the beginnings of our real client looking like this.app.js
with this.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); }
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');
app.locals
. This makes them globally available.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);
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
.routes
subdirectory. There’s the endpoints beginning with records
. These will retrieve data from the database.events
route is unique. The endpoints are used by both the web client and by the Couchbase Eventing Service.records
code first.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;
/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./select/geo
endpoint to store the current hotel choice made by the user. Here’s the query broken out.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
.{ "accuracy": "APPROXIMATE", "lat": 43.9397954, "lon": 4.805895400000054 }
/hotels/byCity/:id
query, first take a look at a couple of example documents.{ "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 }
{ "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" }
INNER JOIN
. Here’s the 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;
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./destinations
endpoint.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';
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;
poi
endpoint is called by the browser when the EventSource
is constructed. You can see we simply add the caller as a client.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.routes
under the server directory.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.)HTTP_PORT=8080 HTTPS_PORT=8081 DEBUG=node,http,poi:* CLUSTER='couchbase://localhost:8091' CLUSTER_USER=Administrator CLUSTER_PASSWORD=password
node app.js
. Don’t forget to build the client code first.eventing
for the bucket name in the dialog that pops uptravel-sample
as the Source Bucketeventing
as the Metadata Bucketmonitor
(or whatever you want) for the Function Nametype
to “Alias”, name
to “travel-sample”, and value
to “db”OnUpdate
gets called any time a document changes. It receives the document and document meta-data as parameters.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).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..results.item
to the end, we grab only the data we want.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.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.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.{ "id": "TPxxxxxxxxxxxxxxxxxx", "code": "whsxxxxxxxxxxxxxxxxxxx" }
node app.js
. Open localhost:8080
in your browser (or whatever you chose in .env
) and try it out.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.