From a7716d31f002a298862b090683447b0caaea82ff Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Mon, 13 Jun 2016 00:15:08 +0200 Subject: [PATCH 01/13] feat(routing): support for new path and point buffer params BRRAKING CHANGE: Support for the old `sensitivity` parameter has been dropped. Use `path_buffer` and `point_buffer` instead. --- controllers/api.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/controllers/api.js b/controllers/api.js index d6bc139..749d954 100644 --- a/controllers/api.js +++ b/controllers/api.js @@ -14,11 +14,16 @@ app.get('/routing', (req, res, next) => { // Make sure all the coords are float values const coords = req.query.coords.split(',').map(c => parseFloat(c, 10)); - const sensitivity = Math.min(parseInt(req.query.sensitivity || 2000, 10), 4000); + const path_buffer = Math.min(parseInt(req.query.path_buffer || 2000, 10), 4000); + const point_buffer = Math.min(parseInt(req.query.point_buffer || 10, 10), 100); const sql = ` SELECT ST_AsGeoJSON(ST_Transform(geom, 4326)) as geojson, cost - FROM path(${coords.join(',')}, ${sensitivity}) + FROM path( + ${coords.join(',')}, + path_buffer:=${path_buffer}, + point_buffer:=${point_buffer} + ) LIMIT 1; `; From aca514b52b203af125305d3ec15996e49f18111f Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Mon, 13 Jun 2016 00:35:07 +0200 Subject: [PATCH 02/13] feat(routing): separate coords param into source and target BRRAKING CHANGE: Support for the old `coords` parameter has been dropped. Use `source` and `target` parameters instead. --- controllers/api.js | 17 +++++++++-------- test/integration/api.js | 12 ++++++------ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/controllers/api.js b/controllers/api.js index 749d954..6bd6a98 100644 --- a/controllers/api.js +++ b/controllers/api.js @@ -7,12 +7,15 @@ const app = express(); const HttpError = require('@starefossen/http-error'); app.get('/routing', (req, res, next) => { - if (!req.query.coords || req.query.coords.split(',').length !== 4) { - return next(new HttpError('Missing or invalid "coords" query', 400)); + if (!req.query.source || req.query.source.split(',').length !== 2 + || !req.query.target || req.query.target.split(',').length !== 2 + ) { + return next(new HttpError('Missing or invalid coordinates', 400)); } // Make sure all the coords are float values - const coords = req.query.coords.split(',').map(c => parseFloat(c, 10)); + const source = req.query.source.split(',').map(c => parseFloat(c, 10)); + const target = req.query.target.split(',').map(c => parseFloat(c, 10)); const path_buffer = Math.min(parseInt(req.query.path_buffer || 2000, 10), 4000); const point_buffer = Math.min(parseInt(req.query.point_buffer || 10, 10), 100); @@ -20,7 +23,8 @@ app.get('/routing', (req, res, next) => { const sql = ` SELECT ST_AsGeoJSON(ST_Transform(geom, 4326)) as geojson, cost FROM path( - ${coords.join(',')}, + ${source.join(',')}, + ${target.join(',')}, path_buffer:=${path_buffer}, point_buffer:=${point_buffer} ) @@ -31,10 +35,7 @@ app.get('/routing', (req, res, next) => { if (err) { return next(new HttpError('Database Query Failed', 500, err)); } if (!result.rows.length || !result.rows[0].geojson) { - return res.json({ type: 'LineString', coordinates: [ - [coords[0], coords[1]], - [coords[2], coords[3]], - ] }); + return res.json({ type: 'LineString', coordinates: [source, target]}); } const geojson = { diff --git a/test/integration/api.js b/test/integration/api.js index aa8abfb..f785d35 100644 --- a/test/integration/api.js +++ b/test/integration/api.js @@ -23,7 +23,7 @@ describe('GET /routing', () => { it('returns route from Selhamar to Åsedalen', function it(done) { this.timeout(60000); - app.get(`/routing?coords=${selhamar},${åsedalen}`) + app.get(`/routing?source=${selhamar}&target=${åsedalen}`) .set('Origin', 'https://example1.com') .expect(200) .expect(nonEmptyGeometryCollection) @@ -36,7 +36,7 @@ describe('GET /routing', () => { it('returns route from Selhamar to Solrenningen', function it(done) { this.timeout(60000); - app.get(`/routing?coords=${selhamar},${solrenningen}`) + app.get(`/routing?source=${selhamar}&target=${solrenningen}`) .set('Origin', 'https://example1.com') .expect(200) .expect(nonEmptyGeometryCollection) @@ -49,7 +49,7 @@ describe('GET /routing', () => { it('returns route from Selhamar to Norddalen', function it(done) { this.timeout(60000); - app.get(`/routing?coords=${selhamar},${norddalen}`) + app.get(`/routing?source=${selhamar}&target=${norddalen}`) .set('Origin', 'https://example1.com') .expect(200) .expect(nonEmptyGeometryCollection) @@ -65,7 +65,7 @@ describe('GET /routing', () => { const start = '2.87842,60.79134'; const stop = '0.08789,61.83541'; - app.get(`/routing?coords=${start},${stop}`) + app.get(`/routing?source=${start}&target=${stop}`) .set('Origin', 'https://example1.com') .expect(200) .expect(emptyGeometryCollection) @@ -86,7 +86,7 @@ describe('GET /routing', () => { const sensitivity = '50'; - app.get(`/routing?coords=${start},${stop}&sensitivity=${sensitivity}`) + app.get(`/routing?source=${start}&target=${stop}&sensitivity=${sensitivity}`) .set('Origin', 'https://example1.com') .expect(200) .expect(emptyGeometryCollection) @@ -105,7 +105,7 @@ describe('GET /routing', () => { const start = '10.144715309143066,59.82439292924618'; const stop = '10.170164108276367,59.82230042984233'; - app.get(`/routing?coords=${start},${stop}`) + app.get(`/routing?source=${start}&target=${stop}`) .set('Origin', 'https://example1.com') .expect(200) .expect(nonEmptyGeometryCollection) From dfa4d56bd700c88189ba9c24c043bff64bf23fa8 Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Mon, 13 Jun 2016 10:20:46 +0200 Subject: [PATCH 03/13] feat(routing): improve validation of input coordinates --- controllers/api.js | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/controllers/api.js b/controllers/api.js index 6bd6a98..e190ad0 100644 --- a/controllers/api.js +++ b/controllers/api.js @@ -7,16 +7,22 @@ const app = express(); const HttpError = require('@starefossen/http-error'); app.get('/routing', (req, res, next) => { - if (!req.query.source || req.query.source.split(',').length !== 2 - || !req.query.target || req.query.target.split(',').length !== 2 - ) { + // Convert all coordinates to propper float values + const source = (req.query.source || '') + .split(',') + .map(coordinate => parseFloat(coordinate, 10)) + .filter(coordinate => !isNaN(coordinate)); + + const target = (req.query.target || '') + .split(',') + .map(coordinate => parseFloat(coordinate, 10)) + .filter(coordinate => !isNaN(coordinate)); + + // Validate required source and target parameters + if (source.length !== 2 || target.length !== 2) { return next(new HttpError('Missing or invalid coordinates', 400)); } - // Make sure all the coords are float values - const source = req.query.source.split(',').map(c => parseFloat(c, 10)); - const target = req.query.target.split(',').map(c => parseFloat(c, 10)); - const path_buffer = Math.min(parseInt(req.query.path_buffer || 2000, 10), 4000); const point_buffer = Math.min(parseInt(req.query.point_buffer || 10, 10), 100); From e1a99b074c7ee37d3329dfcd4a1c0e55dbb969e3 Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Mon, 13 Jun 2016 10:55:14 +0200 Subject: [PATCH 04/13] feat(routing): use prepared statement for improved security and efficiency --- controllers/api.js | 10 ++++++---- lib/pg.js | 7 +++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/controllers/api.js b/controllers/api.js index e190ad0..78b1bb8 100644 --- a/controllers/api.js +++ b/controllers/api.js @@ -26,11 +26,13 @@ app.get('/routing', (req, res, next) => { const path_buffer = Math.min(parseInt(req.query.path_buffer || 2000, 10), 4000); const point_buffer = Math.min(parseInt(req.query.point_buffer || 10, 10), 100); - const sql = ` - SELECT ST_AsGeoJSON(ST_Transform(geom, 4326)) as geojson, cost + const sql = pg.SQL` + SELECT + cost, + ST_AsGeoJSON(ST_Transform(geom, 4326)) as geometry FROM path( - ${source.join(',')}, - ${target.join(',')}, + ${source[0]}, ${source[1]}, + ${target[0]}, ${target[1]}, path_buffer:=${path_buffer}, point_buffer:=${point_buffer} ) diff --git a/lib/pg.js b/lib/pg.js index 25f699a..3f6d3d8 100644 --- a/lib/pg.js +++ b/lib/pg.js @@ -3,3 +3,10 @@ const pg = require('pg'); module.exports = new pg.Client('postgres://postgres:@postgres/postgres'); + +module.exports.SQL = function SQL(parts, ...values) { + return { + text: parts.reduce((prev, curr, i) => prev+"$"+i+curr), + values + }; +} From bed12b24b6e2fe4522533a30b81670e202237df5 Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Mon, 13 Jun 2016 10:56:04 +0200 Subject: [PATCH 05/13] chore(package): require Node.js v6 or greater --- Dockerfile | 2 +- docker-compose.yml | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5113931..6f73c44 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:argon-slim +FROM node:6-slim # Add our user and group first to make sure their IDs get assigned consistently RUN groupadd -r app && useradd -r -g app app diff --git a/docker-compose.yml b/docker-compose.yml index 6239309..c93c7b8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '2' services: node: - image: node:argon + image: node:6 network_mode: 'bridge' working_dir: /usr/src/app volumes: diff --git a/package.json b/package.json index 4616876..d8b62cf 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,6 @@ "supervisor": "^0.11.0" }, "engines": { - "node": ">=4.0.0" + "node": ">=6.0.0" } } From f3e6189d5b6fd3a92ae46b3983c4700831666dcb Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Mon, 13 Jun 2016 21:57:58 +0200 Subject: [PATCH 06/13] feat(routing): return GeometryCollection consistently BREAKING CHANGE: Always return a `GeometryCollection`. Empty route will return an empty `GeometryCollection` instead of a `LineString`. --- controllers/api.js | 25 ++--- test/integration/api.js | 200 ++++++++++++++++++++++++++----------- test/integration/server.js | 2 +- test/support/env.js | 1 - 4 files changed, 153 insertions(+), 75 deletions(-) diff --git a/controllers/api.js b/controllers/api.js index 78b1bb8..ab3ebcb 100644 --- a/controllers/api.js +++ b/controllers/api.js @@ -23,8 +23,8 @@ app.get('/routing', (req, res, next) => { return next(new HttpError('Missing or invalid coordinates', 400)); } - const path_buffer = Math.min(parseInt(req.query.path_buffer || 2000, 10), 4000); - const point_buffer = Math.min(parseInt(req.query.point_buffer || 10, 10), 100); + const path_buffer = Math.min(parseInt(req.query.path_buffer || 1000, 10), 4000); + const point_buffer = Math.min(parseInt(req.query.point_buffer || 10, 10), 1000); const sql = pg.SQL` SELECT @@ -42,23 +42,24 @@ app.get('/routing', (req, res, next) => { return pg.query(sql, (err, result) => { if (err) { return next(new HttpError('Database Query Failed', 500, err)); } - if (!result.rows.length || !result.rows[0].geojson) { - return res.json({ type: 'LineString', coordinates: [source, target]}); - } - - const geojson = { + const collection = { type: 'GeometryCollection', geometries: [], }; - result.rows.forEach((row) => { - const geometry = JSON.parse(row.geojson); - geometry.properties = { cost: row.cost }; + if (!result.rows.length || !result.rows[0].geometry) { + return res.json(collection); + } - geojson.geometries.push(geometry); + result.rows.forEach((row) => { + collection.geometries.push({ + type: 'Feature', + geometry: JSON.parse(row.geometry), + properties: { cost: row.cost }, + }); }); - return res.json(geojson); + return res.json(collection); }); }); diff --git a/test/integration/api.js b/test/integration/api.js index f785d35..f958631 100644 --- a/test/integration/api.js +++ b/test/integration/api.js @@ -10,111 +10,189 @@ function nonEmptyGeometryCollection(res) { } function emptyGeometryCollection(res) { - assert.equal(res.body.type, 'LineString'); - assert.equal(res.body.coordinates.length, 2); + assert.equal(res.body.type, 'GeometryCollection'); + assert.equal(res.body.geometries.length, 0); +} + +function missingOrInvalidCoorinates(res) { + assert.deepEqual(res.body, { + code: 400, + message: 'Missing or invalid coordinates', + }); } +function routeApproxCost(cost, margin) { + return res => { + assert(res.body.geometries[0].properties.cost > cost * (1 - margin)); + assert(res.body.geometries[0].properties.cost < cost * (1 + margin)); + }; +} + +const points = { + cabin: { + selhamar: '6.26297,60.91346', + åsedalen: '6.22052,60.96570', + solrenningen: '6.13070,61.00885', + norddalen: '5.99652,61.01511', + }, + point: { + '200m-off-trail': '6.28474,60.93403', + 'north-of-vardadalsbu': '5.86807,60.95240', + 'south-of-vardadalsbu': '5.86773,60.92030', + }, +}; + describe('GET /routing', () => { - const selhamar = '6.26297,60.91346'; - const åsedalen = '6.22052,60.96570'; - const solrenningen = '6.13070,61.00885'; - const norddalen = '5.99652,61.01511'; + const url = '/routing'; + + [ + undefined, + null, + '', + '1.1', + '1.1,abc', + 'abc,2.2', + '1.1,2.2,3.3', + ].forEach(param => { + const valid = points.cabin.selhamar; + + it(`returns 400 error for source="${param}"`, function it(done) { + app.get(`/routing?source=${param}&target=${valid}`) + .expect(400) + .expect(missingOrInvalidCoorinates) + .end(done); + }); + + it(`returns 400 error for target="${param}"`, function it(done) { + app.get(`/routing?source=${valid}&target=${param}`) + .expect(400) + .expect(missingOrInvalidCoorinates) + .end(done); + }); + }); - it('returns route from Selhamar to Åsedalen', function it(done) { + [{ + source: 'Selhamar', + target: 'Åsedalen', + cost: 13000, + }, { + source: 'Selhamar', + target: 'Solrenningen', + cost: 26000, + }, { + source: 'Selhamar', + target: 'Norddalen', + cost: 35000, + }].forEach(({source, target, cost}) => { + it(`returns route from ${source} to ${target}`, function it(done) { + this.timeout(60000); + + source = points.cabin[source.toLowerCase()]; + target = points.cabin[target.toLowerCase()]; + + app.get(`/routing?source=${source}&target=${target}`) + .expect(200) + .expect(nonEmptyGeometryCollection) + .expect(routeApproxCost(cost, 0.1)) + .end(done); + }); + }); + + it('returns empty GeometryCollection for no route', function it(done) { this.timeout(60000); - app.get(`/routing?source=${selhamar}&target=${åsedalen}`) - .set('Origin', 'https://example1.com') + const source = points.point['north-of-vardadalsbu']; + const target = points.point['south-of-vardadalsbu']; + + app.get(`${url}?source=${source}&target=${target}`) .expect(200) - .expect(nonEmptyGeometryCollection) - .expect(res => { - assert(res.body.geometries[0].properties.cost > 10000); - }) + .expect(emptyGeometryCollection) .end(done); }); - it('returns route from Selhamar to Solrenningen', function it(done) { + it('returns route when path buffer is high enough', function it(done) { this.timeout(60000); - app.get(`/routing?source=${selhamar}&target=${solrenningen}`) - .set('Origin', 'https://example1.com') + const source = points.point['north-of-vardadalsbu']; + const target = points.point['south-of-vardadalsbu']; + + app.get(`${url}?source=${source}&target=${target}&path_buffer=4000`) .expect(200) .expect(nonEmptyGeometryCollection) - .expect(res => { - assert(res.body.geometries[0].properties.cost > 20000); - }) .end(done); }); - it('returns route from Selhamar to Norddalen', function it(done) { + it('returns empty GeometryCollection for no source', function it(done) { + this.timeout(60000); + + const source = points.point['200m-off-trail']; + const target = points.cabin.selhamar; + + app.get(`${url}?source=${source}&target=${target}`) + .expect(200) + .expect(emptyGeometryCollection) + .end(done); + }); + + it('finds source with higher point buffer', function it(done) { this.timeout(60000); - app.get(`/routing?source=${selhamar}&target=${norddalen}`) - .set('Origin', 'https://example1.com') + const source = points.point['200m-off-trail']; + const target = points.cabin.selhamar; + + app.get(`${url}?source=${source}&target=${target}&point_buffer=1000`) .expect(200) .expect(nonEmptyGeometryCollection) - .expect(res => { - assert(res.body.geometries[0].properties.cost > 30000); - }) .end(done); }); - it('returns line whene no route endpoint is found', function it(done) { + it('returns empty GeometryCollection for no target', function it(done) { this.timeout(60000); - const start = '2.87842,60.79134'; - const stop = '0.08789,61.83541'; + const source = points.cabin.selhamar; + const target = points.point['200m-off-trail']; - app.get(`/routing?source=${start}&target=${stop}`) - .set('Origin', 'https://example1.com') + app.get(`${url}?source=${source}&target=${target}`) .expect(200) .expect(emptyGeometryCollection) - .expect(res => { - assert.deepEqual(res.body.coordinates, [ - [2.87842, 60.79134], - [0.08789, 61.83541], - ]); - }) .end(done); }); - it('applies custom snapping sensitivity', function it(done) { + it('finds target with higher point buffer', function it(done) { this.timeout(60000); - const start = '8.922786712646484,61.5062387475475'; - const stop = '8.97857666015625,61.50984184413987'; + const source = points.cabin.selhamar; + const target = points.point['200m-off-trail']; - const sensitivity = '50'; - - app.get(`/routing?source=${start}&target=${stop}&sensitivity=${sensitivity}`) - .set('Origin', 'https://example1.com') + app.get(`${url}?source=${source}&target=${target}&point_buffer=1000`) .expect(200) - .expect(emptyGeometryCollection) - .expect(res => { - assert.deepEqual(res.body.coordinates, [ - [8.922786712646484, 61.5062387475475], - [8.97857666015625, 61.50984184413987], - ]); - }) + .expect(nonEmptyGeometryCollection) .end(done); }); it('returns route in correct direction', function it(done) { this.timeout(60000); - const start = '10.144715309143066,59.82439292924618'; - const stop = '10.170164108276367,59.82230042984233'; + const source = points.cabin.selhamar; + const target = points.cabin.norddalen; - app.get(`/routing?source=${start}&target=${stop}`) - .set('Origin', 'https://example1.com') + app.get(`/routing?source=${source}&target=${target}`) .expect(200) .expect(nonEmptyGeometryCollection) - .expect(res => { - assert.deepEqual(res.body.geometries[0].coordinates[0], [ - 10.1446959429242, - 59.8243680000267, - ]); - }) - .end(done); + .end((err1, res1) => { + assert.ifError(err1); + + const line1 = res1.body.geometries[0].geometry.coordinates; + + app.get(`/routing?source=${target}&target=${source}`) + .expect(200) + .expect(nonEmptyGeometryCollection) + .expect(res2 => { + const line2 = res2.body.geometries[0].geometry.coordinates.reverse(); + + assert.deepEqual(line1, line2); + }) + .end(done); + }); }); }); diff --git a/test/integration/server.js b/test/integration/server.js index b3e8414..36f79a8 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -5,7 +5,7 @@ const app = request(require('../../')); describe('GET /', () => { it('returns 404 for index', done => { - app.get('/').set('Origin', 'https://example1.com').expect(404, done); + app.get('/').expect(404, done); }); it('reject invalid cors domain', done => { diff --git a/test/support/env.js b/test/support/env.js index 4030152..eabd6a9 100644 --- a/test/support/env.js +++ b/test/support/env.js @@ -3,4 +3,3 @@ process.env.NODE_ENV = 'test'; process.env.CORS_ALLOW_ORIGINS = 'example1.com,example2.com'; process.env.CORS_EXPOSE_HEADERS = 'x-response-time'; -process.env.CORS_REQUIRE_ORIGIN = 'true'; From d41bf0ca1b83a27b2b4307a0afcb11158bec8a4e Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Mon, 13 Jun 2016 22:14:15 +0200 Subject: [PATCH 07/13] chore(eslint): resolve ESLint linting errors --- controllers/api.js | 8 ++++---- lib/pg.js | 6 +++--- test/integration/api.js | 12 ++++++++---- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/controllers/api.js b/controllers/api.js index ab3ebcb..f9f804d 100644 --- a/controllers/api.js +++ b/controllers/api.js @@ -23,8 +23,8 @@ app.get('/routing', (req, res, next) => { return next(new HttpError('Missing or invalid coordinates', 400)); } - const path_buffer = Math.min(parseInt(req.query.path_buffer || 1000, 10), 4000); - const point_buffer = Math.min(parseInt(req.query.point_buffer || 10, 10), 1000); + const pathBuffer = Math.min(parseInt(req.query.path_buffer || 1000, 10), 4000); + const pointBuffer = Math.min(parseInt(req.query.point_buffer || 10, 10), 1000); const sql = pg.SQL` SELECT @@ -33,8 +33,8 @@ app.get('/routing', (req, res, next) => { FROM path( ${source[0]}, ${source[1]}, ${target[0]}, ${target[1]}, - path_buffer:=${path_buffer}, - point_buffer:=${point_buffer} + path_buffer:=${pathBuffer}, + point_buffer:=${pointBuffer} ) LIMIT 1; `; diff --git a/lib/pg.js b/lib/pg.js index 3f6d3d8..610fadb 100644 --- a/lib/pg.js +++ b/lib/pg.js @@ -6,7 +6,7 @@ module.exports = new pg.Client('postgres://postgres:@postgres/postgres'); module.exports.SQL = function SQL(parts, ...values) { return { - text: parts.reduce((prev, curr, i) => prev+"$"+i+curr), - values + text: parts.reduce((prev, curr, i) => `${prev}\$${i}${curr}`), + values, }; -} +}; diff --git a/test/integration/api.js b/test/integration/api.js index f958631..ccecc6d 100644 --- a/test/integration/api.js +++ b/test/integration/api.js @@ -57,6 +57,8 @@ describe('GET /routing', () => { const valid = points.cabin.selhamar; it(`returns 400 error for source="${param}"`, function it(done) { + this.timeout(60000); + app.get(`/routing?source=${param}&target=${valid}`) .expect(400) .expect(missingOrInvalidCoorinates) @@ -64,6 +66,8 @@ describe('GET /routing', () => { }); it(`returns 400 error for target="${param}"`, function it(done) { + this.timeout(60000); + app.get(`/routing?source=${valid}&target=${param}`) .expect(400) .expect(missingOrInvalidCoorinates) @@ -83,12 +87,12 @@ describe('GET /routing', () => { source: 'Selhamar', target: 'Norddalen', cost: 35000, - }].forEach(({source, target, cost}) => { - it(`returns route from ${source} to ${target}`, function it(done) { + }].forEach(({ source: sourceName, target: targetName, cost }) => { + it(`returns route from ${sourceName} to ${targetName}`, function it(done) { this.timeout(60000); - source = points.cabin[source.toLowerCase()]; - target = points.cabin[target.toLowerCase()]; + const source = points.cabin[sourceName.toLowerCase()]; + const target = points.cabin[targetName.toLowerCase()]; app.get(`/routing?source=${source}&target=${target}`) .expect(200) From 5f2b628cd3a5942b313176062331dd99603d5664 Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Mon, 13 Jun 2016 23:15:30 +0200 Subject: [PATCH 08/13] chore(wercker): use Node.js v6 Docker Image --- wercker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wercker.yml b/wercker.yml index 36d3e4e..2026b4a 100644 --- a/wercker.yml +++ b/wercker.yml @@ -1,4 +1,4 @@ -box: node:argon +box: node:6 build: services: From 3b417b4f4f2978d0e62058cea1450e24c2df05e6 Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Thu, 16 Jun 2016 22:48:36 +0200 Subject: [PATCH 09/13] chore: revert default path buffer to `2000` --- controllers/api.js | 2 +- test/integration/api.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/controllers/api.js b/controllers/api.js index f9f804d..82f632c 100644 --- a/controllers/api.js +++ b/controllers/api.js @@ -23,7 +23,7 @@ app.get('/routing', (req, res, next) => { return next(new HttpError('Missing or invalid coordinates', 400)); } - const pathBuffer = Math.min(parseInt(req.query.path_buffer || 1000, 10), 4000); + const pathBuffer = Math.min(parseInt(req.query.path_buffer || 2000, 10), 4000); const pointBuffer = Math.min(parseInt(req.query.point_buffer || 10, 10), 1000); const sql = pg.SQL` diff --git a/test/integration/api.js b/test/integration/api.js index ccecc6d..9b2acd5 100644 --- a/test/integration/api.js +++ b/test/integration/api.js @@ -108,7 +108,7 @@ describe('GET /routing', () => { const source = points.point['north-of-vardadalsbu']; const target = points.point['south-of-vardadalsbu']; - app.get(`${url}?source=${source}&target=${target}`) + app.get(`${url}?source=${source}&target=${target}&path_buffer=1000`) .expect(200) .expect(emptyGeometryCollection) .end(done); From 33bf82ae1f9f8b653fa4d17bf7622c779fcc9d52 Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Thu, 16 Jun 2016 23:55:41 +0200 Subject: [PATCH 10/13] feat(routing): type cast prepared statement input parameters --- controllers/api.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/controllers/api.js b/controllers/api.js index 82f632c..6afc8fc 100644 --- a/controllers/api.js +++ b/controllers/api.js @@ -31,10 +31,12 @@ app.get('/routing', (req, res, next) => { cost, ST_AsGeoJSON(ST_Transform(geom, 4326)) as geometry FROM path( - ${source[0]}, ${source[1]}, - ${target[0]}, ${target[1]}, - path_buffer:=${pathBuffer}, - point_buffer:=${pointBuffer} + ${source[0]}::double precision, + ${source[1]}::double precision, + ${target[0]}::double precision, + ${target[1]}::double precision, + path_buffer:=${pathBuffer}::integer, + point_buffer:=${pointBuffer}::integer ) LIMIT 1; `; From e8929f7d8d7185c33596334ef2581cfda321114c Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Fri, 17 Jun 2016 00:02:04 +0200 Subject: [PATCH 11/13] feat(routing): add support for `?bbox` bounding buffer --- controllers/api.js | 16 +++++++++++++++- test/integration/api.js | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/controllers/api.js b/controllers/api.js index 6afc8fc..f93f8c0 100644 --- a/controllers/api.js +++ b/controllers/api.js @@ -23,9 +23,22 @@ app.get('/routing', (req, res, next) => { return next(new HttpError('Missing or invalid coordinates', 400)); } + const bbox = (req.query.bbox || '') + .split(',') + .map(coordinate => parseFloat(coordinate, 10)) + .filter(coordinate => !isNaN(coordinate)); + + // Validate bbox parameter + if (!(bbox.length === 4 || bbox.length === 0)) { + return next(new HttpError('Missing or invalid bbox coordinates', 400)); + } + const pathBuffer = Math.min(parseInt(req.query.path_buffer || 2000, 10), 4000); const pointBuffer = Math.min(parseInt(req.query.point_buffer || 10, 10), 1000); + // Format bbox to propper PostgreSQL array + const bboxPgArr = `{${bbox.join(',')}}`; + const sql = pg.SQL` SELECT cost, @@ -36,7 +49,8 @@ app.get('/routing', (req, res, next) => { ${target[0]}::double precision, ${target[1]}::double precision, path_buffer:=${pathBuffer}::integer, - point_buffer:=${pointBuffer}::integer + point_buffer:=${pointBuffer}::integer, + bbox:=${bboxPgArr}::double precision[] ) LIMIT 1; `; diff --git a/test/integration/api.js b/test/integration/api.js index 9b2acd5..17ed323 100644 --- a/test/integration/api.js +++ b/test/integration/api.js @@ -199,4 +199,18 @@ describe('GET /routing', () => { .end(done); }); }); + + it('returns route for bbox bounding box buffer', function it(done) { + this.timeout(60000); + + const source = points.cabin.selhamar; + const target = points.cabin.norddalen; + const bbox = [5.41213, 60.87099, 6.59591, 61.07090].join(','); + + app.get(`/routing?source=${source}&target=${target}&bbox=${bbox}&path_buffer=0`) + .expect(200) + .expect(nonEmptyGeometryCollection) + .expect(routeApproxCost(35000, 0.1)) + .end(done); + }); }); From a02db1bdb7b259390e7f52952e6cf9be6f744940 Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Fri, 17 Jun 2016 00:18:35 +0200 Subject: [PATCH 12/13] feat(routing): support for multiple shortest paths --- controllers/api.js | 6 +++-- test/integration/api.js | 52 +++++++++++++++++++++++++---------------- 2 files changed, 36 insertions(+), 22 deletions(-) diff --git a/controllers/api.js b/controllers/api.js index f93f8c0..a5b25ad 100644 --- a/controllers/api.js +++ b/controllers/api.js @@ -35,6 +35,7 @@ app.get('/routing', (req, res, next) => { const pathBuffer = Math.min(parseInt(req.query.path_buffer || 2000, 10), 4000); const pointBuffer = Math.min(parseInt(req.query.point_buffer || 10, 10), 1000); + const limit = Math.min(parseInt(req.query.limit || 1, 10), 3); // Format bbox to propper PostgreSQL array const bboxPgArr = `{${bbox.join(',')}}`; @@ -50,9 +51,10 @@ app.get('/routing', (req, res, next) => { ${target[1]}::double precision, path_buffer:=${pathBuffer}::integer, point_buffer:=${pointBuffer}::integer, - bbox:=${bboxPgArr}::double precision[] + bbox:=${bboxPgArr}::double precision[], + targets:=${limit}::integer ) - LIMIT 1; + LIMIT ${limit}; `; return pg.query(sql, (err, result) => { diff --git a/test/integration/api.js b/test/integration/api.js index 17ed323..d04430b 100644 --- a/test/integration/api.js +++ b/test/integration/api.js @@ -4,14 +4,11 @@ const assert = require('assert'); const request = require('supertest'); const app = request(require('../../')); -function nonEmptyGeometryCollection(res) { - assert.equal(res.body.type, 'GeometryCollection'); - assert.equal(res.body.geometries.length, 1); -} - -function emptyGeometryCollection(res) { - assert.equal(res.body.type, 'GeometryCollection'); - assert.equal(res.body.geometries.length, 0); +function geometryCollections(n) { + return res => { + assert.equal(res.body.type, 'GeometryCollection'); + assert.equal(res.body.geometries.length, n); + }; } function missingOrInvalidCoorinates(res) { @@ -23,8 +20,10 @@ function missingOrInvalidCoorinates(res) { function routeApproxCost(cost, margin) { return res => { - assert(res.body.geometries[0].properties.cost > cost * (1 - margin)); - assert(res.body.geometries[0].properties.cost < cost * (1 + margin)); + res.body.geometries.forEach(geometry => { + assert(geometry.properties.cost > cost * (1 - margin)); + assert(geometry.properties.cost < cost * (1 + margin)); + }); }; } @@ -96,7 +95,7 @@ describe('GET /routing', () => { app.get(`/routing?source=${source}&target=${target}`) .expect(200) - .expect(nonEmptyGeometryCollection) + .expect(geometryCollections(1)) .expect(routeApproxCost(cost, 0.1)) .end(done); }); @@ -110,7 +109,7 @@ describe('GET /routing', () => { app.get(`${url}?source=${source}&target=${target}&path_buffer=1000`) .expect(200) - .expect(emptyGeometryCollection) + .expect(geometryCollections(0)) .end(done); }); @@ -122,7 +121,7 @@ describe('GET /routing', () => { app.get(`${url}?source=${source}&target=${target}&path_buffer=4000`) .expect(200) - .expect(nonEmptyGeometryCollection) + .expect(geometryCollections(1)) .end(done); }); @@ -134,7 +133,7 @@ describe('GET /routing', () => { app.get(`${url}?source=${source}&target=${target}`) .expect(200) - .expect(emptyGeometryCollection) + .expect(geometryCollections(0)) .end(done); }); @@ -146,7 +145,7 @@ describe('GET /routing', () => { app.get(`${url}?source=${source}&target=${target}&point_buffer=1000`) .expect(200) - .expect(nonEmptyGeometryCollection) + .expect(geometryCollections(1)) .end(done); }); @@ -158,7 +157,7 @@ describe('GET /routing', () => { app.get(`${url}?source=${source}&target=${target}`) .expect(200) - .expect(emptyGeometryCollection) + .expect(geometryCollections(0)) .end(done); }); @@ -170,7 +169,7 @@ describe('GET /routing', () => { app.get(`${url}?source=${source}&target=${target}&point_buffer=1000`) .expect(200) - .expect(nonEmptyGeometryCollection) + .expect(geometryCollections(1)) .end(done); }); @@ -182,7 +181,7 @@ describe('GET /routing', () => { app.get(`/routing?source=${source}&target=${target}`) .expect(200) - .expect(nonEmptyGeometryCollection) + .expect(geometryCollections(1)) .end((err1, res1) => { assert.ifError(err1); @@ -190,7 +189,7 @@ describe('GET /routing', () => { app.get(`/routing?source=${target}&target=${source}`) .expect(200) - .expect(nonEmptyGeometryCollection) + .expect(geometryCollections(1)) .expect(res2 => { const line2 = res2.body.geometries[0].geometry.coordinates.reverse(); @@ -209,8 +208,21 @@ describe('GET /routing', () => { app.get(`/routing?source=${source}&target=${target}&bbox=${bbox}&path_buffer=0`) .expect(200) - .expect(nonEmptyGeometryCollection) + .expect(geometryCollections(1)) .expect(routeApproxCost(35000, 0.1)) .end(done); }); + + it('returns multiple shortes path routes', function it(done) { + this.timeout(60000); + + const source = points.cabin.selhamar; + const target = points.cabin.åsedalen; + + app.get(`/routing?source=${source}&target=${target}&limit=3`) + .expect(200) + .expect(geometryCollections(2)) + .expect(routeApproxCost(13000, 0.1)) + .end(done); + }); }); From 725125738f386fae500b4885b00aa6cfe42fbbd7 Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Fri, 17 Jun 2016 08:45:08 +0200 Subject: [PATCH 13/13] docs(routing): update routing endpoint documentation --- README.md | 49 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 103fc22..39b5897 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,17 @@ Anglo-Saxon Rad. ### GET /v1/routing -* **string** `cords` - A to B coordinates on the format `x1,y1,x2,y2` -* **number** `sensitivity` - routing sensitivity / buffer (**default** `2000`) +* **string** `source` - start point coordinate on the format `x,y` +* **string** `target` - end point coordinate on the format `x,y` +* **number** `path_buffer` - route sensitivity / buffer (**default** `2000`) +* **number** `point_buffer` - point sensitivity / buffer (**default** `10`) +* **string** `bbox` - bbox bounding bounds on the format `x1,y1,x2,y2` +* **number** `limit` - max number of shortest path to return (**default** `1`) -Return the shortest path from coordinate A to coordinate B. Will return a -`GeometryCollection` if a route is found. +Return shortest path from `source` to `target`. Returns a `GeometryCollection` +if a route is found. -**Return** +**Returned route** ```json { @@ -46,18 +50,39 @@ Return the shortest path from coordinate A to coordinate B. Will return a } ``` +**Mutliple routes** + +If you want multiple shortest path you can use the `limit` query parameter to +control the number of routes returned. By default only the shortest route will +be returned. + +```json +{ + "type": "GeometryCollection", + "geometries": [{ + "type": "LineString", + "coordinates": [...], + "properties": { + cost: 1510.05825002283 + } + },{ + "type": "LineString", + "coordinates": [...], + "properties": { + cost: 1610.06825002284 + } + }] +} + **Route not found** -If the point A or B can not be found or a route between them could not be -found the routing will return a `LineString` between the two points. +If the `source` or `target` points can not be found or a route between them +could not be found the routing will return an empty `GeometryCollection`. ```json { - "type": "LineString", - "coordinates": [ - [ 8.922786712646484, 61.5062387475475 ], - [ 8.97857666015625, 61.50984184413987 ] - ] + "type": "GeometryCollection", + "geometries": [] } ```