diff --git a/README.md b/README.md new file mode 100644 index 00000000..885c9b7b --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# API + +Pelias RESTful API + +## Documentation + +[API Documentation](https://github.com/pelias/api/tree/master/docs) + +## Install Dependencies + +```bash +$ npm install +``` + +## Contributing + +Please fork and pull request against upstream master on a feature branch. + +Pretty please; provide unit tests and script fixtures in the `test` directory. + +### Start Server + +```bash +$ npm start +``` + +### Running Unit Tests + +```bash +$ npm run unit +``` + +### Running Functional Tests + +```bash +$ npm run ciao +``` + +### Running All Tests + +```bash +$ npm test +``` + +### Generate API Documentation + +```bash +$ npm run docs +``` + +### Continuous Integration + +Travis tests every release against node version `0.10` + +[![Build Status](https://travis-ci.org/pelias/api.png?branch=master)](https://travis-ci.org/pelias/api) \ No newline at end of file diff --git a/app.js b/app.js new file mode 100644 index 00000000..d9659b17 --- /dev/null +++ b/app.js @@ -0,0 +1,35 @@ + +var app = require('express')(); + +/** ----------------------- middleware ----------------------- **/ + +app.use( require('./middleware/toobusy') ); // should be first +app.use( require('./middleware/headers') ); +app.use( require('./middleware/cors') ); +app.use( require('./middleware/jsonp') ); + +/** ----------------------- sanitisers ----------------------- **/ + +var sanitisers = {}; +sanitisers.suggest = require('./sanitiser/suggest'); + +/** ----------------------- controllers ----------------------- **/ + +var controllers = {}; +controllers.index = require('./controller/index'); +controllers.suggest = require('./controller/suggest'); + +/** ----------------------- routes ----------------------- **/ + +// api root +app.get( '/', controllers.index() ); + +// suggest API +app.get( '/suggest', sanitisers.suggest.middleware, controllers.suggest() ); + +/** ----------------------- error middleware ----------------------- **/ + +app.use( require('./middleware/404') ); +app.use( require('./middleware/500') ); + +module.exports = app; \ No newline at end of file diff --git a/controller/index.js b/controller/index.js index c3267192..10f2d9a4 100644 --- a/controller/index.js +++ b/controller/index.js @@ -1,13 +1,22 @@ var pkg = require('../package'); -function controller( req, res ){ - res.json({ - name: pkg.name, - version: { - number: pkg.version - } - }); +function setup(){ + + function controller( req, res, next ){ + + // stats + res.json({ + name: pkg.name, + version: { + number: pkg.version + } + }); + + } + + return controller; + } -module.exports = controller; \ No newline at end of file +module.exports = setup; \ No newline at end of file diff --git a/controller/suggest.js b/controller/suggest.js index f2a2230c..b62b2fe6 100644 --- a/controller/suggest.js +++ b/controller/suggest.js @@ -1,32 +1,41 @@ -var logger = require('../src/logger'), - responder = require('../src/responder'), - query = require('../query/suggest'), - backend = require('../src/backend'); +function setup( backend, query ){ -module.exports = function( req, res, next ){ + // allow overriding of dependencies + backend = backend || require('../src/backend'); + query = query || require('../query/suggest'); - var reply = { - date: new Date().getTime(), - body: [] - }; + function controller( req, res, next ){ - var cmd = { - index: 'pelias', - body: query( req.clean ) // generate query from clean params - }; + // backend command + var cmd = { + index: 'pelias', + body: query( req.clean ) + }; - // Proxy request to ES backend & map response to a valid FeatureCollection - backend().client.suggest( cmd, function( err, data ){ + // query backend + backend().client.suggest( cmd, function( err, data ){ - if( err ){ return responder.error( req, res, next, err ); } - if( data && data.pelias && data.pelias.length ){ + var docs = []; - // map options to reply body - reply.body = data['pelias'][0].options; - } + // handle backend errors + if( err ){ return next( err ); } - return responder.cors( req, res, reply ); - }); + // map response to a valid FeatureCollection + if( data && Array.isArray( data.pelias ) && data.pelias.length ){ + docs = data['pelias'][0].options || []; + } -}; \ No newline at end of file + // respond + return res.status(200).json({ + date: new Date().getTime(), + body: docs + }); + }); + + } + + return controller; +} + +module.exports = setup; \ No newline at end of file diff --git a/docs/404.md b/docs/404.md new file mode 100644 index 00000000..e683e480 --- /dev/null +++ b/docs/404.md @@ -0,0 +1,63 @@ +# invalid path + +*Generated: Fri Sep 12 2014 20:51:44 GMT+0100 (BST)* +## Request +```javascript +{ + "protocol": "http:", + "host": "localhost", + "method": "GET", + "port": 3100, + "path": "/notexist" +} +``` + +## Response +```javascript +Status: 404 +{ + "x-powered-by": "mapzen", + "charset": "utf8", + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET", + "access-control-allow-headers": "X-Requested-With,content-type", + "access-control-allow-credentials": "true", + "server": "Pelias/0.0.0", + "cache-control": "public,max-age=300", + "content-type": "application/json; charset=utf-8", + "content-length": "35", + "etag": "W/\"23-dfdfa185\"", + "date": "Fri, 12 Sep 2014 19:51:44 GMT", + "connection": "close" +} +``` +```javascript +{ + "error": "not found: invalid path" +} +``` + +## Tests + +### ✓ cache-control header correctly set +```javascript +response.should.have.header 'Cache-Control','public,max-age=300' +``` + +### ✓ content-type header correctly set +```javascript +response.should.have.header 'Content-Type','application/json; charset=utf-8' +``` + +### ✓ not found +```javascript +response.statusCode.should.equal 404 +``` + +### ✓ should respond in json with server info +```javascript +should.exist json +should.exist json.error +json.error.should.equal 'not found: invalid path' +``` + diff --git a/docs/cors.md b/docs/cors.md new file mode 100644 index 00000000..0df8dc00 --- /dev/null +++ b/docs/cors.md @@ -0,0 +1,52 @@ +# cross-origin resource sharing + +*Generated: Fri Sep 12 2014 20:51:44 GMT+0100 (BST)* +## Request +```javascript +{ + "protocol": "http:", + "host": "localhost", + "method": "GET", + "port": 3100, + "path": "/" +} +``` + +## Response +```javascript +Status: 200 +{ + "x-powered-by": "mapzen", + "charset": "utf8", + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET", + "access-control-allow-headers": "X-Requested-With,content-type", + "access-control-allow-credentials": "true", + "server": "Pelias/0.0.0", + "cache-control": "public,max-age=60", + "content-type": "application/json; charset=utf-8", + "content-length": "50", + "etag": "W/\"32-85536434\"", + "date": "Fri, 12 Sep 2014 19:51:44 GMT", + "connection": "close" +} +``` +```javascript +{ + "name": "pelias-api", + "version": { + "number": "0.0.0" + } +} +``` + +## Tests + +### ✓ access control headers correctly set +```javascript +response.should.have.header 'Access-Control-Allow-Origin','*' +response.should.have.header 'Access-Control-Allow-Methods','GET' +response.should.have.header 'Access-Control-Allow-Headers','X-Requested-With,content-type' +response.should.have.header 'Access-Control-Allow-Credentials','true' +``` + diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..963ab240 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,82 @@ +# api root + +*Generated: Fri Sep 12 2014 20:51:45 GMT+0100 (BST)* +## Request +```javascript +{ + "protocol": "http:", + "host": "localhost", + "method": "GET", + "port": 3100, + "path": "/" +} +``` + +## Response +```javascript +Status: 200 +{ + "x-powered-by": "mapzen", + "charset": "utf8", + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET", + "access-control-allow-headers": "X-Requested-With,content-type", + "access-control-allow-credentials": "true", + "server": "Pelias/0.0.0", + "cache-control": "public,max-age=60", + "content-type": "application/json; charset=utf-8", + "content-length": "50", + "etag": "W/\"32-85536434\"", + "date": "Fri, 12 Sep 2014 19:51:44 GMT", + "connection": "close" +} +``` +```javascript +{ + "name": "pelias-api", + "version": { + "number": "0.0.0" + } +} +``` + +## Tests + +### ✓ content-type header correctly set +```javascript +response.should.have.header 'Content-Type','application/json; charset=utf-8' +``` + +### ✓ endpoint available +```javascript +response.statusCode.should.equal 200 +``` + +### ✓ cache-control header correctly set +```javascript +response.should.have.header 'Cache-Control','public,max-age=60' +``` + +### ✓ charset header correctly set +```javascript +response.should.have.header 'Charset','utf8' +``` + +### ✓ server header correctly set +```javascript +response.should.have.header 'Server' +response.headers.server.should.match /Pelias\/\d{1,2}\.\d{1,2}\.\d{1,2}/ +``` + +### ✓ vanity header correctly set +```javascript +response.should.have.header 'X-Powered-By','mapzen' +``` + +### ✓ should respond in json with server info +```javascript +should.exist json +should.exist json.name +should.exist json.version +``` + diff --git a/docs/jsonp.md b/docs/jsonp.md new file mode 100644 index 00000000..a67e5c40 --- /dev/null +++ b/docs/jsonp.md @@ -0,0 +1,50 @@ +# jsonp + +*Generated: Fri Sep 12 2014 20:51:45 GMT+0100 (BST)* +## Request +```javascript +{ + "protocol": "http:", + "host": "localhost", + "method": "GET", + "port": 3100, + "path": "/?callback=test" +} +``` + +## Response +```javascript +Status: 200 +{ + "x-powered-by": "mapzen", + "charset": "utf8", + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET", + "access-control-allow-headers": "X-Requested-With,content-type", + "access-control-allow-credentials": "true", + "server": "Pelias/0.0.0", + "cache-control": "public,max-age=60", + "content-type": "application/javascript; charset=utf-8", + "content-length": "57", + "etag": "W/\"39-b8a2aba1\"", + "date": "Fri, 12 Sep 2014 19:51:44 GMT", + "connection": "close" +} +``` +```html +test({"name":"pelias-api","version":{"number":"0.0.0"}}); +``` + +## Tests + +### ✓ should respond with jsonp +```javascript +should.exist response.body +response.body.substr(0,5).should.equal 'test('; +``` + +### ✓ content-type header correctly set +```javascript +response.should.have.header 'Content-Type','application/javascript; charset=utf-8' +``` + diff --git a/docs/suggest/success.md b/docs/suggest/success.md new file mode 100644 index 00000000..abcc5db4 --- /dev/null +++ b/docs/suggest/success.md @@ -0,0 +1,139 @@ +# valid suggest query + +*Generated: Fri Sep 12 2014 20:51:45 GMT+0100 (BST)* +## Request +```javascript +{ + "protocol": "http:", + "host": "localhost", + "method": "GET", + "port": 3100, + "path": "/suggest?input=a&lat=0&lon=0" +} +``` + +## Response +```javascript +Status: 200 +{ + "x-powered-by": "mapzen", + "charset": "utf8", + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET", + "access-control-allow-headers": "X-Requested-With,content-type", + "access-control-allow-credentials": "true", + "server": "Pelias/0.0.0", + "cache-control": "public,max-age=60", + "content-type": "application/json; charset=utf-8", + "content-length": "1248", + "etag": "W/\"o9NALcf9i0O3JoLO7pfqog==\"", + "date": "Fri, 12 Sep 2014 19:51:44 GMT", + "connection": "close" +} +``` +```javascript +{ + "date": 1410551504928, + "body": [ + { + "text": "ACRELÂNDIA, Brazil", + "score": 1, + "payload": { + "id": "admin2/708:adm2:br:bra:acrel__ndia", + "geo": "-66.908143,-9.954353" + } + }, + { + "text": "ALTA FLORESTA, Brazil", + "score": 1, + "payload": { + "id": "admin2/2986:adm2:br:bra:alta_floresta", + "geo": "-56.404593,-10.042071" + } + }, + { + "text": "ALTO ALEGRE, Brazil", + "score": 1, + "payload": { + "id": "admin2/4611:adm2:br:bra:alto_alegre", + "geo": "-62.627879,3.103540" + } + }, + { + "text": "ALTO PARAÍSO, Brazil", + "score": 1, + "payload": { + "id": "admin2/4584:adm2:br:bra:alto_para__so", + "geo": "-63.418743,-9.697774" + } + }, + { + "text": "ALVARÃES, Brazil", + "score": 1, + "payload": { + "id": "admin2/832:adm2:br:bra:alvar__es", + "geo": "-65.296384,-3.674615" + } + }, + { + "text": "AMAJARI, Brazil", + "score": 1, + "payload": { + "id": "admin2/4610:adm2:br:bra:amajari", + "geo": "-62.710104,3.724864" + } + }, + { + "text": "AMAZONAS, Brazil", + "score": 1, + "payload": { + "id": "admin1/3232:adm1:br:bra:amazonas", + "geo": "-64.949558,-3.785708" + } + }, + { + "text": "ANAMÃ, Brazil", + "score": 1, + "payload": { + "id": "admin2/834:adm2:br:bra:anam__", + "geo": "-61.683670,-3.473836" + } + }, + { + "text": "ANORI, Brazil", + "score": 1, + "payload": { + "id": "admin2/835:adm2:br:bra:anori", + "geo": "-62.182138,-4.154809" + } + }, + { + "text": "APIACÁS, Brazil", + "score": 1, + "payload": { + "id": "admin2/2992:adm2:br:bra:apiac__s", + "geo": "-57.803447,-8.583036" + } + } + ] +} +``` + +## Tests + +### ✓ valid response +```javascript +now = new Date().getTime() +should.exist json +should.not.exist json.error +should.exist json.date +json.date.should.be.within now-1000, now+1000 +should.exist json.body +json.body.should.be.instanceof Array +``` + +### ✓ 200 ok +```javascript +response.statusCode.should.equal 200 +``` + diff --git a/express.js b/express.js deleted file mode 100644 index b48e0560..00000000 --- a/express.js +++ /dev/null @@ -1,14 +0,0 @@ - -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){ - res.header('Cache-Control','public,max-age=60'); - next(); -}); - -module.exports = app; \ No newline at end of file diff --git a/index.js b/index.js index 15543369..3003c068 100644 --- a/index.js +++ b/index.js @@ -1,10 +1,17 @@ -var app = require('./express'); +var cluster = require('cluster'), + app = require('./app'), + multicore = false, + port = ( process.env.PORT || 3100 ); -// api root -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 +/** cluster webserver across all cores **/ +if( multicore ){ + // @todo: not finished yet + // cluster(app) + // .use(cluster.stats()) + // .listen( process.env.PORT || 3100 ); +} +else { + console.log( 'listening on ' + port ); + app.listen( process.env.PORT || 3100 ); +} \ No newline at end of file diff --git a/middleware/404.js b/middleware/404.js new file mode 100644 index 00000000..e90ed3c3 --- /dev/null +++ b/middleware/404.js @@ -0,0 +1,8 @@ + +// handle not found errors +function middleware(req, res) { + res.header('Cache-Control','public,max-age=300'); // 5 minute cache + res.status(404).json({ error: 'not found: invalid path' }); +} + +module.exports = middleware; \ No newline at end of file diff --git a/middleware/500.js b/middleware/500.js new file mode 100644 index 00000000..d73c5684 --- /dev/null +++ b/middleware/500.js @@ -0,0 +1,9 @@ + +// handle application errors +function middleware(err, req, res, next) { + res.header('Cache-Control','no-cache'); + if( res.statusCode < 400 ){ res.status(500); } + res.json({ error: err }); +} + +module.exports = middleware; \ No newline at end of file diff --git a/middleware/cors.js b/middleware/cors.js new file mode 100644 index 00000000..257bef4b --- /dev/null +++ b/middleware/cors.js @@ -0,0 +1,10 @@ + +function middleware(req, res, next){ + 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); + next(); +} + +module.exports = middleware; \ No newline at end of file diff --git a/middleware/headers.js b/middleware/headers.js new file mode 100644 index 00000000..4b40dd21 --- /dev/null +++ b/middleware/headers.js @@ -0,0 +1,12 @@ + +var pkg = require('../package'); + +function middleware(req, res, next){ + res.header('Charset','utf8'); + res.header('Cache-Control','public,max-age=60'); + res.header('Server', 'Pelias/'+pkg.version); + res.header('X-Powered-By', 'mapzen'); + next(); +} + +module.exports = middleware; \ No newline at end of file diff --git a/middleware/jsonp.js b/middleware/jsonp.js new file mode 100644 index 00000000..1a54716e --- /dev/null +++ b/middleware/jsonp.js @@ -0,0 +1,24 @@ + +function middleware(req, res, next){ + + // store old json function + var json = res.json.bind(res); + + // replace with jsonp aware function + res.json = function( data ){ + + // jsonp + if( req.query && req.query.callback ){ + res.header('Content-type','application/javascript'); + return res.send( req.query.callback + '('+ JSON.stringify( data ) + ');' ); + } + + // regular json + res.header('Content-type','application/json'); + return json( data ); + }; + + next(); +} + +module.exports = middleware; \ No newline at end of file diff --git a/middleware/toobusy.js b/middleware/toobusy.js new file mode 100644 index 00000000..bbe6784f --- /dev/null +++ b/middleware/toobusy.js @@ -0,0 +1,19 @@ + +// middleware which blocks requests when the eventloop is too busy +var toobusy = require('toobusy'); + +function middleware(req, res, next){ + if( toobusy() ){ + res.status(503); // Service Unavailable + return next('Server Overwhelmed'); + } + return next(); +} + +// calling .shutdown allows your process to exit normally +process.on('SIGINT', function() { + toobusy.shutdown(); + process.exit(); +}); + +module.exports = middleware; \ No newline at end of file diff --git a/package.json b/package.json index 308a1fd5..76962055 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "test": "npm run unit && npm run ciao", "unit": "node test/unit/run.js | tap-spec", "ciao": "node node_modules/ciao/bin/ciao -c test/ciao.json test/ciao", - "audit": "npm shrinkwrap; node node_modules/nsp/bin/nspCLI.js audit-shrinkwrap; rm npm-shrinkwrap.json;" + "audit": "npm shrinkwrap; node node_modules/nsp/bin/nspCLI.js audit-shrinkwrap; rm npm-shrinkwrap.json;", + "docs": "rm -r docs; cd test/ciao; node ../../node_modules/ciao/bin/ciao -c ../ciao.json . -d ../../docs" }, "repository": { "type": "git", @@ -33,7 +34,8 @@ "dependencies": { "express": "^4.8.8", "geopipes-elasticsearch-backend": "0.0.7", - "pelias-esclient": "0.0.25" + "pelias-esclient": "0.0.25", + "toobusy": "^0.2.4" }, "devDependencies": { "ciao": "^0.3.4", @@ -41,4 +43,4 @@ "tap-spec": "^0.2.0", "nsp": "^0.3.0" } -} \ No newline at end of file +} diff --git a/query/suggest.js b/query/suggest.js index b54e2518..9ba2e5c8 100644 --- a/query/suggest.js +++ b/query/suggest.js @@ -3,7 +3,7 @@ var logger = require('../src/logger'); // Build pelias suggest query function generate( params ){ - + var cmd = { 'pelias' : { 'text' : params.input, @@ -23,7 +23,7 @@ function generate( params ){ } }; - logger.log( 'cmd', JSON.stringify( cmd, null, 2 ) ); + // logger.log( 'cmd', JSON.stringify( cmd, null, 2 ) ); return cmd; } diff --git a/sanitiser/suggest.js b/sanitiser/suggest.js index ea5296db..85f972f6 100644 --- a/sanitiser/suggest.js +++ b/sanitiser/suggest.js @@ -14,14 +14,14 @@ function sanitize( params, cb ){ // input text if('string' !== typeof params.input || !params.input.length){ - return cb( 'invalid input text length, must be >0' ); + return cb( 'invalid param \'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 + clean.size = Math.min( Math.max( size, 1 ), 40 ); // max } else { clean.size = 10; // default } @@ -33,7 +33,7 @@ function sanitize( params, cb ){ }); for( var x=0; x 90 ){ - return cb( 'invalid lat, must be >0 and <90' ); + return cb( 'invalid param \'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' ); + return cb( 'invalid param \'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 + clean.zoom = Math.min( Math.max( zoom, 1 ), 18 ); // max } else { clean.zoom = 10; // default } @@ -68,9 +68,16 @@ function sanitize( params, cb ){ } -module.exports = function( req, res, next ){ +// export function +module.exports = sanitize; + +// middleware +module.exports.middleware = function( req, res, next ){ sanitize( req.query, function( err, clean ){ - if( err ){ next( err ); } + if( err ){ + res.status(400); // 400 Bad Request + return next(err); + } req.clean = clean; next(); }); diff --git a/src/backend.js b/src/backend.js index 2a9d60ae..d0388966 100644 --- a/src/backend.js +++ b/src/backend.js @@ -1,14 +1,7 @@ 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')(); -} + client = require('pelias-esclient')(), + backends = {}; function getBackend( index, type ){ var key = ( index + ':' + type ); diff --git a/src/responder.js b/src/responder.js deleted file mode 100644 index 80f8d880..00000000 --- a/src/responder.js +++ /dev/null @@ -1,33 +0,0 @@ - -// 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 diff --git a/test/ciao.json b/test/ciao.json index 11b7e0c9..ddd811ef 100644 --- a/test/ciao.json +++ b/test/ciao.json @@ -2,10 +2,7 @@ "defaults": { "protocol": "http", "host": "localhost", - "port": 3100, - "headers": { - "User-Agent": "Ciao/Client 1.0" - } + "port": 3100 }, "config": {} } \ No newline at end of file diff --git a/test/ciao/404.coffee b/test/ciao/404.coffee new file mode 100644 index 00000000..764bc8d5 --- /dev/null +++ b/test/ciao/404.coffee @@ -0,0 +1,17 @@ + +#> invalid path +path: '/notexist' + +#? not found +response.statusCode.should.equal 404 + +#? content-type header correctly set +response.should.have.header 'Content-Type','application/json; charset=utf-8' + +#? cache-control header correctly set +response.should.have.header 'Cache-Control','public,max-age=300' + +#? should respond in json with server info +should.exist json +should.exist json.error +json.error.should.equal 'not found: invalid path' \ No newline at end of file diff --git a/test/ciao/cors.coffee b/test/ciao/cors.coffee new file mode 100644 index 00000000..bd97c5de --- /dev/null +++ b/test/ciao/cors.coffee @@ -0,0 +1,9 @@ + +#> cross-origin resource sharing +path: '/' + +#? access control headers correctly set +response.should.have.header 'Access-Control-Allow-Origin','*' +response.should.have.header 'Access-Control-Allow-Methods','GET' +response.should.have.header 'Access-Control-Allow-Headers','X-Requested-With,content-type' +response.should.have.header 'Access-Control-Allow-Credentials','true' \ No newline at end of file diff --git a/test/ciao/index.coffee b/test/ciao/index.coffee index 960f6551..76797848 100644 --- a/test/ciao/index.coffee +++ b/test/ciao/index.coffee @@ -8,9 +8,19 @@ response.statusCode.should.equal 200 #? content-type header correctly set response.should.have.header 'Content-Type','application/json; charset=utf-8' +#? charset header correctly set +response.should.have.header 'Charset','utf8' + #? cache-control header correctly set response.should.have.header 'Cache-Control','public,max-age=60' +#? server header correctly set +response.should.have.header 'Server' +response.headers.server.should.match /Pelias\/\d{1,2}\.\d{1,2}\.\d{1,2}/ + +#? vanity header correctly set +response.should.have.header 'X-Powered-By','mapzen' + #? should respond in json with server info should.exist json should.exist json.name diff --git a/test/ciao/jsonp.coffee b/test/ciao/jsonp.coffee new file mode 100644 index 00000000..d83f0eaa --- /dev/null +++ b/test/ciao/jsonp.coffee @@ -0,0 +1,10 @@ + +#> jsonp +path: '/?callback=test' + +#? content-type header correctly set +response.should.have.header 'Content-Type','application/javascript; charset=utf-8' + +#? should respond with jsonp +should.exist response.body +response.body.substr(0,5).should.equal 'test('; \ No newline at end of file diff --git a/test/ciao/suggest/success.coffee b/test/ciao/suggest/success.coffee new file mode 100644 index 00000000..b5a58c44 --- /dev/null +++ b/test/ciao/suggest/success.coffee @@ -0,0 +1,15 @@ + +#> valid suggest query +path: '/suggest?input=a&lat=0&lon=0' + +#? 200 ok +response.statusCode.should.equal 200 + +#? valid response +now = new Date().getTime() +should.exist json +should.not.exist json.error +should.exist json.date +json.date.should.be.within now-1000, now+1000 +should.exist json.body +json.body.should.be.instanceof Array \ No newline at end of file diff --git a/test/unit/controller/index.js b/test/unit/controller/index.js index ef1d139d..e9752511 100644 --- a/test/unit/controller/index.js +++ b/test/unit/controller/index.js @@ -1,17 +1,19 @@ -var controller = require('../../../controller/index'); +var setup = require('../../../controller/index'); module.exports.tests = {}; module.exports.tests.interface = function(test, common) { test('valid interface', function(t) { - t.equal(typeof controller, 'function', 'controller is a function'); + t.equal(typeof setup, 'function', 'setup is a function'); + t.equal(typeof setup(), 'function', 'setup returns a controller'); t.end(); }); }; module.exports.tests.info = function(test, common) { test('returns server info', function(t) { + var controller = setup(); var res = { json: function( json ){ t.equal(typeof json, 'object', 'returns json'); t.equal(typeof json.name, 'string', 'name'); diff --git a/test/unit/controller/suggest.js b/test/unit/controller/suggest.js new file mode 100644 index 00000000..02c40b43 --- /dev/null +++ b/test/unit/controller/suggest.js @@ -0,0 +1,64 @@ + +var setup = require('../../../controller/suggest'), + mockBackend = require('../mock/backend'), + mockQuery = require('../mock/query'); + +module.exports.tests = {}; + +module.exports.tests.interface = function(test, common) { + test('valid interface', function(t) { + t.equal(typeof setup, 'function', 'setup is a function'); + t.equal(typeof setup(), 'function', 'setup returns a controller'); + t.end(); + }); +}; + +// functionally test controller (backend success) +module.exports.tests.functional_success = function(test, common) { + test('functional test', function(t) { + var backend = mockBackend( 'client/suggest/ok/1', function( cmd ){ + t.deepEqual(cmd, { body: { a: 'b' }, index: 'pelias' }, 'correct backend command'); + }); + var controller = setup( backend, mockQuery() ); + var res = { + status: function( code ){ + t.equal(code, 200, 'status set'); + return res; + }, + json: function( json ){ + t.equal(typeof json, 'object', 'returns json'); + t.equal(typeof json.date, 'number', 'date set'); + t.true(Array.isArray(json.body), 'body is array'); + t.deepEqual(json.body, [ { value: 1 }, { value: 2 } ], 'values correctly mapped'); + t.end(); + } + }; + controller( { clean: { a: 'b' } }, res ); + }); +}; + +// functionally test controller (backend failure) +module.exports.tests.functional_failure = function(test, common) { + test('functional test', function(t) { + var backend = mockBackend( 'client/suggest/fail/1', function( cmd ){ + t.deepEqual(cmd, { body: { a: 'b' }, index: 'pelias' }, 'correct backend command'); + }); + var controller = setup( backend, mockQuery() ); + var next = function( message ){ + t.equal(message,'a backend error occurred','error passed to errorHandler'); + t.end(); + }; + controller( { clean: { a: 'b' } }, undefined, next ); + }); +}; + +module.exports.all = function (tape, common) { + + function test(name, testFunction) { + return tape('GET /suggest ' + name, testFunction); + } + + for( var testCase in module.exports.tests ){ + module.exports.tests[testCase](test, common); + } +}; \ No newline at end of file diff --git a/test/unit/mock/backend.js b/test/unit/mock/backend.js new file mode 100644 index 00000000..8d87fedc --- /dev/null +++ b/test/unit/mock/backend.js @@ -0,0 +1,28 @@ + +var responses = {}; +responses['client/suggest/ok/1'] = function( cmd, cb ){ + return cb( undefined, suggestEnvelope([ { value: 1 }, { value: 2 } ]) ); +}; +responses['client/suggest/fail/1'] = function( cmd, cb ){ + return cb( 'a backend error occurred' ); +}; + +function setup( key, cmdCb ){ + function backend( a, b ){ + return { + client: { + suggest: function( cmd, cb ){ + if( 'function' === typeof cmdCb ){ cmdCb( cmd ); } + return responses[key].apply( this, arguments ); + } + } + }; + } + return backend; +} + +function suggestEnvelope( options ){ + return { pelias: [{ options: options }]}; +} + +module.exports = setup; \ No newline at end of file diff --git a/test/unit/mock/query.js b/test/unit/mock/query.js new file mode 100644 index 00000000..2a5f21fd --- /dev/null +++ b/test/unit/mock/query.js @@ -0,0 +1,10 @@ + +function setup(){ + return query; +} + +function query( clean ){ + return clean; +} + +module.exports = setup; \ No newline at end of file diff --git a/test/unit/query/indeces.js b/test/unit/query/indeces.js new file mode 100644 index 00000000..d074f620 --- /dev/null +++ b/test/unit/query/indeces.js @@ -0,0 +1,23 @@ + +var indeces = require('../../../query/indeces'); + +module.exports.tests = {}; + +module.exports.tests.interface = function(test, common) { + test('valid interface', function(t) { + t.true(Array.isArray(indeces), 'valid array'); + t.equal(indeces.length, 7, 'valid array'); + t.end(); + }); +}; + +module.exports.all = function (tape, common) { + + function test(name, testFunction) { + return tape('indeces ' + name, testFunction); + } + + for( var testCase in module.exports.tests ){ + module.exports.tests[testCase](test, common); + } +}; \ No newline at end of file diff --git a/test/unit/query/suggest.js b/test/unit/query/suggest.js new file mode 100644 index 00000000..ce7f8861 --- /dev/null +++ b/test/unit/query/suggest.js @@ -0,0 +1,29 @@ + +var query = require('../../../query/suggest'); + +module.exports.tests = {}; + +module.exports.tests.interface = function(test, common) { + test('valid interface', function(t) { + t.equal(typeof query, 'function', 'valid function'); + t.end(); + }); +}; + +module.exports.tests.interface = function(test, common) { + test('valid interface', function(t) { + t.equal(typeof query, 'function', 'valid function'); + t.end(); + }); +}; + +module.exports.all = function (tape, common) { + + function test(name, testFunction) { + return tape('suggest query ' + name, testFunction); + } + + for( var testCase in module.exports.tests ){ + module.exports.tests[testCase](test, common); + } +}; \ No newline at end of file diff --git a/test/unit/run.js b/test/unit/run.js index 36045ca9..d9b2041e 100644 --- a/test/unit/run.js +++ b/test/unit/run.js @@ -3,7 +3,11 @@ var tape = require('tape'); var common = {}; var tests = [ - require('./controller/index') + require('./controller/index'), + require('./controller/suggest'), + require('./sanitiser/suggest'), + require('./query/indeces'), + require('./query/suggest') ]; tests.map(function(t) { diff --git a/test/unit/sanitiser/suggest.js b/test/unit/sanitiser/suggest.js new file mode 100644 index 00000000..d8e0b3dd --- /dev/null +++ b/test/unit/sanitiser/suggest.js @@ -0,0 +1,203 @@ + +var sanitize = require('../../../sanitiser/suggest'), + defaultError = 'invalid param \'input\': text length, must be >0', + defaultClean = { input: 'test', lat: 0, layers: [ 'geoname', 'osmnode', 'osmway', 'admin0', 'admin1', 'admin2', 'neighborhood' ], lon: 0, size: 10, zoom: 10 }; + +module.exports.tests = {}; + +module.exports.tests.interface = function(test, common) { + test('sanitize interface', function(t) { + t.equal(typeof sanitize, 'function', 'sanitize is a function'); + t.equal(sanitize.length, 2, 'sanitize interface'); + t.end(); + }); + test('middleware interface', function(t) { + t.equal(typeof sanitize.middleware, 'function', 'middleware is a function'); + t.equal(sanitize.middleware.length, 3, 'sanitize is valid middleware'); + t.end(); + }); +}; + +module.exports.tests.sanitize_input = function(test, common) { + var inputs = { + invalid: [ '', 100, null, undefined, new Date() ], + valid: [ 'a', 'aa', 'aaaaaaaa' ] + }; + inputs.invalid.forEach( function( input ){ + test('invalid input', function(t) { + sanitize({ input: input, lat: 0, lon: 0 }, function( err, clean ){ + t.equal(err, 'invalid param \'input\': text length, must be >0', 'invalid input'); + t.equal(clean, undefined, 'clean not set'); + t.end(); + }); + }); + }); + inputs.valid.forEach( function( input ){ + test('valid input', function(t) { + sanitize({ input: input, lat: 0, lon: 0 }, function( err, clean ){ + var expected = JSON.parse(JSON.stringify( defaultClean )); + expected.input = input; + t.equal(err, undefined, 'no error'); + t.deepEqual(clean, expected, 'clean set correctly'); + t.end(); + }); + }); + }); +}; + +module.exports.tests.sanitize_lat = function(test, common) { + var lats = { + invalid: [ -1, -45, -90, 91, 120, 181 ], + valid: [ 0, 45, 90, -0, '0', '45', '90' ] + }; + lats.invalid.forEach( function( lat ){ + test('invalid lat', function(t) { + sanitize({ input: 'test', lat: lat, lon: 0 }, function( err, clean ){ + t.equal(err, 'invalid param \'lat\': must be >0 and <90', 'invalid latitude'); + t.equal(clean, undefined, 'clean not set'); + t.end(); + }); + }); + }); + lats.valid.forEach( function( lat ){ + test('valid lat', function(t) { + sanitize({ input: 'test', lat: lat, lon: 0 }, function( err, clean ){ + var expected = JSON.parse(JSON.stringify( defaultClean )); + expected.lat = parseFloat( lat ); + t.equal(err, undefined, 'no error'); + t.deepEqual(clean, expected, 'clean set correctly'); + t.end(); + }); + }); + }); +}; + +module.exports.tests.sanitize_lon = function(test, common) { + var lons = { + invalid: [ -360, -181, 181, 360 ], + valid: [ -180, -1, -0, 0, 45, 90, '-180', '0', '180' ] + }; + lons.invalid.forEach( function( lon ){ + test('invalid lon', function(t) { + sanitize({ input: 'test', lat: 0, lon: lon }, function( err, clean ){ + t.equal(err, 'invalid param \'lon\': must be >-180 and <180', 'invalid longitude'); + t.equal(clean, undefined, 'clean not set'); + t.end(); + }); + }); + }); + lons.valid.forEach( function( lon ){ + test('valid lon', function(t) { + sanitize({ input: 'test', lat: 0, lon: lon }, function( err, clean ){ + var expected = JSON.parse(JSON.stringify( defaultClean )); + expected.lon = parseFloat( lon ); + t.equal(err, undefined, 'no error'); + t.deepEqual(clean, expected, 'clean set correctly'); + t.end(); + }); + }); + }); +}; + +module.exports.tests.sanitize_zoom = function(test, common) { + test('invalid zoom value', function(t) { + sanitize({ zoom: 'a', input: 'test', lat: 0, lon: 0 }, function( err, clean ){ + t.equal(clean.zoom, 10, 'default zoom set'); + t.end(); + }); + }); + test('below min zoom value', function(t) { + sanitize({ zoom: -100, input: 'test', lat: 0, lon: 0 }, function( err, clean ){ + t.equal(clean.zoom, 1, 'min zoom set'); + t.end(); + }); + }); + test('above max zoom value', function(t) { + sanitize({ zoom: 9999, input: 'test', lat: 0, lon: 0 }, function( err, clean ){ + t.equal(clean.zoom, 18, 'max zoom set'); + t.end(); + }); + }); +}; + +module.exports.tests.sanitize_size = function(test, common) { + test('invalid size value', function(t) { + sanitize({ size: 'a', input: 'test', lat: 0, lon: 0 }, function( err, clean ){ + t.equal(clean.size, 10, 'default size set'); + t.end(); + }); + }); + test('below min size value', function(t) { + sanitize({ size: -100, input: 'test', lat: 0, lon: 0 }, function( err, clean ){ + t.equal(clean.size, 1, 'min size set'); + t.end(); + }); + }); + test('above max size value', function(t) { + sanitize({ size: 9999, input: 'test', lat: 0, lon: 0 }, function( err, clean ){ + t.equal(clean.size, 40, 'max size set'); + t.end(); + }); + }); +}; + +module.exports.tests.sanitize_layers = function(test, common) { + test('unspecified', function(t) { + sanitize({ layers: undefined, input: 'test', lat: 0, lon: 0 }, function( err, clean ){ + t.deepEqual(clean.layers, defaultClean.layers, 'default layers set'); + t.end(); + }); + }); + test('invalid layer', function(t) { + sanitize({ layers: 'test_layer', input: 'test', lat: 0, lon: 0 }, function( err, clean ){ + var msg = 'invalid param \'layer\': must be one or more of geoname,osmnode,osmway,admin0,admin1,admin2,neighborhood'; + t.equal(err, msg, 'invalid layer requested'); + t.end(); + }); + }); +}; + +module.exports.tests.invalid_params = function(test, common) { + test('invalid input params', function(t) { + sanitize( undefined, function( err, clean ){ + t.equal(err, defaultError, 'handle invalid params gracefully'); + t.end(); + }); + }); +}; + +module.exports.tests.middleware_failure = function(test, common) { + test('middleware failure', function(t) { + var res = { status: function( code ){ + t.equal(code, 400, 'status set'); + }}; + var next = function( message ){ + t.equal(message, defaultError); + t.end(); + }; + sanitize.middleware( {}, res, next ); + }); +}; + +module.exports.tests.middleware_success = function(test, common) { + test('middleware success', function(t) { + var req = { query: { input: 'test', lat: 0, lon: 0 }}; + var next = function( message ){ + t.equal(message, undefined, 'no error message set'); + t.deepEqual(req.clean, defaultClean); + t.end(); + }; + sanitize.middleware( req, undefined, next ); + }); +}; + +module.exports.all = function (tape, common) { + + function test(name, testFunction) { + return tape('SANTIZE /suggest ' + name, testFunction); + } + + for( var testCase in module.exports.tests ){ + module.exports.tests[testCase](test, common); + } +}; \ No newline at end of file