diff --git a/.gitignore b/.gitignore index b512c09d..ab05030f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -node_modules \ No newline at end of file +node_modules +*.log \ No newline at end of file diff --git a/controller/suggest.js b/controller/suggest.js new file mode 100644 index 00000000..f2a2230c --- /dev/null +++ b/controller/suggest.js @@ -0,0 +1,32 @@ + +var logger = require('../src/logger'), + responder = require('../src/responder'), + query = require('../query/suggest'), + backend = require('../src/backend'); + +module.exports = function( req, res, next ){ + + var reply = { + date: new Date().getTime(), + body: [] + }; + + var cmd = { + index: 'pelias', + body: query( req.clean ) // generate query from clean params + }; + + // Proxy request to ES backend & map response to a valid FeatureCollection + backend().client.suggest( cmd, function( err, data ){ + + if( err ){ return responder.error( req, res, next, err ); } + if( data && data.pelias && data.pelias.length ){ + + // map options to reply body + reply.body = data['pelias'][0].options; + } + + return responder.cors( req, res, reply ); + }); + +}; \ No newline at end of file diff --git a/express.js b/express.js index 10621442..b48e0560 100644 --- a/express.js +++ b/express.js @@ -1,6 +1,9 @@ -var express = require('express'); -var app = express(); +var express = require('express'), + app = express(); + +// middleware modules +// app.use( require('cookie-parser')() ); // enable client-side caching of 60s by default app.use(function(req, res, next){ diff --git a/index.js b/index.js index 9bfec708..15543369 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,9 @@ var app = require('./express'); // api root -app.get( '/', require('./controller/index' ) ); +app.get( '/', require('./controller/index') ); + +// suggest API +app.get( '/suggest', require('./sanitiser/suggest'), require('./controller/suggest') ); app.listen( process.env.PORT || 3100 ); \ No newline at end of file diff --git a/package.json b/package.json index 34ca0fb2..c9fb2fc1 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,9 @@ "elasticsearch": ">=1.2.1" }, "dependencies": { - "express": "^4.8.8" + "express": "^4.8.8", + "geopipes-elasticsearch-backend": "0.0.7", + "pelias-esclient": "0.0.25" }, "devDependencies": { "ciao": "^0.3.4", diff --git a/query/indeces.js b/query/indeces.js new file mode 100644 index 00000000..dbeacf43 --- /dev/null +++ b/query/indeces.js @@ -0,0 +1,12 @@ + +// querable indeces + +module.exports = [ + 'geoname', + 'osmnode', + 'osmway', + 'admin0', + 'admin1', + 'admin2', + 'neighborhood' +]; \ No newline at end of file diff --git a/query/suggest.js b/query/suggest.js new file mode 100644 index 00000000..b54e2518 --- /dev/null +++ b/query/suggest.js @@ -0,0 +1,31 @@ + +var logger = require('../src/logger'); + +// Build pelias suggest query +function generate( params ){ + + var cmd = { + 'pelias' : { + 'text' : params.input, + 'completion' : { + 'size' : params.size, + 'field' : 'suggest', + 'context': { + 'dataset': params.layers, + 'location': { + 'value': [ params.lon, params.lat ], + + // // commented out until they fix the precision bug in ES 1.3.3 + 'precision': 2 // params.zoom > 9 ? 3 : (params.zoom > 7 ? 2 : 1) + } + } + } + } + }; + + logger.log( 'cmd', JSON.stringify( cmd, null, 2 ) ); + return cmd; + +} + +module.exports = generate; \ No newline at end of file diff --git a/sanitiser/suggest.js b/sanitiser/suggest.js new file mode 100644 index 00000000..ea5296db --- /dev/null +++ b/sanitiser/suggest.js @@ -0,0 +1,77 @@ + +var logger = require('../src/logger'), + indeces = require('../query/indeces'); + +// validate inputs, convert types and apply defaults +function sanitize( params, cb ){ + + var clean = {}; + + // ensure the input params are a valid object + if( Object.prototype.toString.call( params ) !== '[object Object]' ){ + params = {}; + } + + // input text + if('string' !== typeof params.input || !params.input.length){ + return cb( 'invalid input text length, must be >0' ); + } + clean.input = params.input; + + // total results + var size = parseInt( params.size, 10 ); + if( !isNaN( size ) ){ + clean.size = Math.min( size, 40 ); // max + } else { + clean.size = 10; // default + } + + // which layers to query + if('string' === typeof params.layers && params.layers.length){ + var layers = params.layers.split(',').map( function( layer ){ + return layer.toLowerCase(); // lowercase inputs + }); + for( var x=0; x 90 ){ + return cb( 'invalid lat, must be >0 and <90' ); + } + clean.lat = lat; + + // lon + var lon = parseFloat( params.lon, 10 ); + if( isNaN( lon ) || lon < -180 || lon > 180 ){ + return cb( 'invalid lon, must be >-180 and <180' ); + } + clean.lon = lon; + + // zoom level + var zoom = parseInt( params.zoom, 10 ); + if( !isNaN( zoom ) ){ + clean.zoom = Math.min( zoom, 18 ); // max + } else { + clean.zoom = 10; // default + } + + return cb( undefined, clean ); + +} + +module.exports = function( req, res, next ){ + sanitize( req.query, function( err, clean ){ + if( err ){ next( err ); } + req.clean = clean; + next(); + }); +}; \ No newline at end of file diff --git a/src/backend.js b/src/backend.js new file mode 100644 index 00000000..2a9d60ae --- /dev/null +++ b/src/backend.js @@ -0,0 +1,21 @@ + +var Backend = require('geopipes-elasticsearch-backend'), + backends = {}, + client; + +// set env specific client +if( process.env.NODE_ENV === 'test' ){ + client = require('./pelias-mockclient'); +} else { + client = require('pelias-esclient')(); +} + +function getBackend( index, type ){ + var key = ( index + ':' + type ); + if( !backends[key] ){ + backends[key] = new Backend( client, index, type ); + } + return backends[key]; +} + +module.exports = getBackend; \ No newline at end of file diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 00000000..205313c0 --- /dev/null +++ b/src/logger.js @@ -0,0 +1,5 @@ +module.exports = { + log: console.log.bind( console ), + warn: console.warn.bind( console ), + error: console.error.bind( console ) +}; \ No newline at end of file diff --git a/src/responder.js b/src/responder.js new file mode 100644 index 00000000..80f8d880 --- /dev/null +++ b/src/responder.js @@ -0,0 +1,33 @@ + +// send a reply that is capable of JSON, CORS and JSONP +function cors( req, res, obj ){ + res.header('Charset','utf8'); + res.header('Cache-Control','public,max-age=60'); + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET'); + res.header('Access-Control-Allow-Headers', 'X-Requested-With,content-type'); + res.header('Access-Control-Allow-Credentials', true); + res.header('X-Powered-By', 'pelias'); + + // jsonp + if( req.query && req.query.callback ){ + res.header('Content-type','application/javascript'); + return res.send( req.query.callback + '('+ JSON.stringify( obj ) + ');' ); + } + + // regular json + res.header('Content-type','application/json'); + return res.json( obj ); +} + +// send an error +function error( req, res, next, err ){ + console.error( 'application error:', err ); + // mask error from user (contains paths) + return cors( req, res, { error: 'application error' } ); +} + +module.exports = { + cors: cors, + error: error +}; \ No newline at end of file