Add support for specifying a filename for exports. Closes #64
Sets release target to 1.3.0, due to parameter addition
This commit is contained in:
parent
755fe738ca
commit
46cec7a0e5
3
NEWS.md
3
NEWS.md
@ -1,5 +1,6 @@
|
||||
1.2.2 (DD/MM/YY)
|
||||
1.3.0 (DD/MM/YY)
|
||||
-----
|
||||
* Support for specifying a filename for exports (#64)
|
||||
|
||||
1.2.1 (DD/MM/YY)
|
||||
-----
|
||||
|
@ -14,6 +14,9 @@
|
||||
// eg. vizzuality.cartodb.com/api/v1/?sql=SELECT * from my_table
|
||||
//
|
||||
//
|
||||
|
||||
var path = require('path');
|
||||
|
||||
var express = require('express')
|
||||
, app = express.createServer(
|
||||
express.logger({
|
||||
@ -65,6 +68,12 @@ function userid_to_dbuser(user_id) {
|
||||
return "publicuser" // FIXME: make configurable
|
||||
};
|
||||
|
||||
function sanitize_filename(filename) {
|
||||
filename = path.basename(filename, path.extname(filename));
|
||||
filename = filename.replace(/[;()\[\]<>'"\s]/g, '_');
|
||||
//console.log("Sanitized: " + filename);
|
||||
return filename;
|
||||
}
|
||||
|
||||
// request handlers
|
||||
function handleQuery(req, res) {
|
||||
@ -81,6 +90,7 @@ function handleQuery(req, res) {
|
||||
var limit = parseInt(req.query.rows_per_page);
|
||||
var offset = parseInt(req.query.page);
|
||||
var format = _.isArray(req.query.format) ? _.last(req.query.format) : req.query.format;
|
||||
var filename = req.query.filename;
|
||||
var dp = req.query.dp; // decimal point digits (defaults to 6)
|
||||
var gn = "the_geom"; // TODO: read from configuration file
|
||||
var user_id;
|
||||
@ -88,12 +98,12 @@ function handleQuery(req, res) {
|
||||
// sanitize and apply defaults to input
|
||||
dp = (dp === "" || _.isUndefined(dp)) ? '6' : dp;
|
||||
format = (format === "" || _.isUndefined(format)) ? 'json' : format.toLowerCase();
|
||||
filename = (filename === "" || _.isUndefined(filename)) ? 'cartodb-query' : sanitize_filename(filename);
|
||||
sql = (sql === "" || _.isUndefined(sql)) ? null : sql;
|
||||
database = (database === "" || _.isUndefined(database)) ? null : database;
|
||||
limit = (_.isNumber(limit)) ? limit : null;
|
||||
offset = (_.isNumber(offset)) ? offset * limit : null;
|
||||
|
||||
|
||||
// setup step run
|
||||
var start = new Date().getTime();
|
||||
|
||||
@ -205,7 +215,7 @@ function handleQuery(req, res) {
|
||||
if (err) throw err;
|
||||
|
||||
// configure headers for given format
|
||||
res.header("Content-Disposition", getContentDisposition(format));
|
||||
res.header("Content-Disposition", getContentDisposition(format, filename));
|
||||
res.header("Content-Type", getContentType(format));
|
||||
|
||||
// allow cross site post
|
||||
@ -229,7 +239,7 @@ function handleQuery(req, res) {
|
||||
} else if (format === 'csv'){
|
||||
toCSV(result, res, this);
|
||||
} else if ( format === 'shp'){
|
||||
toSHP(database, user_id, gn, sql, res, this);
|
||||
toSHP(database, user_id, gn, sql, filename, res, this);
|
||||
} else if ( format === 'kml'){
|
||||
toKML(database, user_id, gn, sql, res, this);
|
||||
} else if ( format === 'json'){
|
||||
@ -489,11 +499,11 @@ console.log(['ogr2ogr',
|
||||
);
|
||||
}
|
||||
|
||||
function toSHP(dbname, user_id, gcol, sql, res, callback) {
|
||||
function toSHP(dbname, user_id, gcol, sql, filename, res, callback) {
|
||||
var zip = 'zip'; // FIXME: make configurable
|
||||
var tmpdir = '/tmp'; // FIXME: make configurable
|
||||
var outdirpath = tmpdir + '/sqlapi-shapefile-' + generateMD5(sql);
|
||||
var shapefile = outdirpath + '/cartodb-query.shp';
|
||||
var shapefile = outdirpath + '/' + filename + '.shp';
|
||||
|
||||
// TODO: following tests:
|
||||
// - fetch with no auth [done]
|
||||
@ -683,7 +693,7 @@ function toKML(dbname, user_id, gcol, sql, res, callback) {
|
||||
);
|
||||
}
|
||||
|
||||
function getContentDisposition(format){
|
||||
function getContentDisposition(format, filename) {
|
||||
var ext = 'json';
|
||||
if (format === 'geojson'){
|
||||
ext = 'geojson';
|
||||
@ -701,7 +711,7 @@ function getContentDisposition(format){
|
||||
ext = 'kml';
|
||||
}
|
||||
var time = new Date().toUTCString();
|
||||
return 'attachment; filename=cartodb-query.' + ext + '; modification-date="' + time + '";';
|
||||
return 'attachment; filename=' + filename + '.' + ext + '; modification-date="' + time + '";';
|
||||
}
|
||||
|
||||
function getContentType(format){
|
||||
|
7
npm-shrinkwrap.json
generated
7
npm-shrinkwrap.json
generated
@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "cartodb_api",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0-dev",
|
||||
"dependencies": {
|
||||
"cluster2": {
|
||||
"version": "0.3.5-cdb01",
|
||||
"version": "0.3.5-cdb02",
|
||||
"from": "git://github.com/CartoDB/cluster2.git#cdb_production",
|
||||
"dependencies": {
|
||||
"ejs": {
|
||||
@ -218,6 +218,9 @@
|
||||
"csv": {
|
||||
"version": "0.0.13"
|
||||
},
|
||||
"zipfile": {
|
||||
"version": "0.3.2"
|
||||
},
|
||||
"mocha": {
|
||||
"version": "1.2.1",
|
||||
"dependencies": {
|
||||
|
@ -2,7 +2,7 @@
|
||||
"private": true,
|
||||
"name": "cartodb_api",
|
||||
"description": "high speed SQL api for cartodb",
|
||||
"version": "1.2.2",
|
||||
"version": "1.3.0-dev",
|
||||
"author": {
|
||||
"name": "Simon Tokumine, Sandro Santilli, Vizzuality",
|
||||
"url": "http://vizzuality.com",
|
||||
@ -23,7 +23,8 @@
|
||||
"csv":"0.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"mocha": "1.2.1"
|
||||
"mocha": "1.2.1",
|
||||
"zipfile": "~0.3.2"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "test/run_tests.sh"
|
||||
|
@ -14,10 +14,14 @@
|
||||
require('../helper');
|
||||
require('../support/assert');
|
||||
|
||||
|
||||
var app = require(global.settings.app_root + '/app/controllers/app')
|
||||
, assert = require('assert')
|
||||
, querystring = require('querystring')
|
||||
, _ = require('underscore');
|
||||
, _ = require('underscore')
|
||||
, zipfile = require('zipfile')
|
||||
, fs = require('fs')
|
||||
;
|
||||
|
||||
// allow lots of emitters to be set to silence warning
|
||||
app.setMaxListeners(0);
|
||||
@ -422,7 +426,7 @@ test('GET /api/v1/sql with SQL parameter and no format, ensuring content-disposi
|
||||
assert.equal(res.statusCode, 200, res.body);
|
||||
var cd = res.header('Content-Disposition');
|
||||
assert.equal(true, /^attachment/.test(cd), 'JSON is not disposed as attachment: ' + cd);
|
||||
assert.equal(true, /filename=cartodb-query.json/gi.test(cd));
|
||||
assert.equal(true, /filename=cartodb-query.json/gi.test(cd), 'Unexpected JSON filename: ' + cd);
|
||||
done();
|
||||
});
|
||||
});
|
||||
@ -510,6 +514,19 @@ test('uses the last format parameter when multiple are used', function(done){
|
||||
});
|
||||
});
|
||||
|
||||
test('uses custom filename', function(done){
|
||||
assert.response(app, {
|
||||
url: '/api/v1/sql?q=SELECT%20*%20FROM%20untitle_table_4&format=geojson&filename=x',
|
||||
headers: {host: 'vizzuality.cartodb.com'},
|
||||
method: 'GET'
|
||||
},{ }, function(res){
|
||||
assert.equal(res.statusCode, 200, res.body);
|
||||
var cd = res.header('Content-Disposition');
|
||||
assert.equal(true, /filename=x.geojson/gi.test(cd), cd);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test('GET /api/v1/sql as geojson limiting decimal places', function(done){
|
||||
assert.response(app, {
|
||||
@ -561,6 +578,22 @@ test('CSV format', function(done){
|
||||
});
|
||||
});
|
||||
|
||||
test('CSV format, custom filename', function(done){
|
||||
assert.response(app, {
|
||||
url: '/api/v1/sql?q=SELECT%20*%20FROM%20untitle_table_4%20LIMIT%201&format=csv&filename=mycsv.csv',
|
||||
headers: {host: 'vizzuality.cartodb.com'},
|
||||
method: 'GET'
|
||||
},{ }, function(res){
|
||||
assert.equal(res.statusCode, 200, res.body);
|
||||
var cd = res.header('Content-Disposition');
|
||||
assert.equal(true, /^attachment/.test(cd), 'CSV is not disposed as attachment: ' + cd);
|
||||
assert.equal(true, /filename=mycsv.csv/gi.test(cd), cd);
|
||||
var ct = res.header('Content-Type');
|
||||
assert.equal(true, /header=present/.test(ct), "CSV doesn't advertise header presence: " + ct);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
test('GET /api/v1/sql as csv', function(done){
|
||||
assert.response(app, {
|
||||
url: '/api/v1/sql?q=SELECT%20cartodb_id,ST_AsEWKT(the_geom)%20as%20geom%20FROM%20untitle_table_4%20LIMIT%201&format=csv',
|
||||
@ -623,6 +656,27 @@ test('GET /api/v1/sql with SVG format', function(done){
|
||||
});
|
||||
});
|
||||
|
||||
test('GET /api/v1/sql with SVG format and custom filename', function(done){
|
||||
var query = querystring.stringify({
|
||||
q: "SELECT 1 as cartodb_id, ST_MakeLine(ST_MakePoint(10, 10), ST_MakePoint(1034, 778)) AS the_geom ",
|
||||
format: "svg",
|
||||
filename: 'mysvg'
|
||||
});
|
||||
assert.response(app, {
|
||||
url: '/api/v1/sql?' + query,
|
||||
headers: {host: 'vizzuality.cartodb.com'},
|
||||
method: 'GET'
|
||||
},{ }, function(res){
|
||||
assert.equal(res.statusCode, 200, res.body);
|
||||
var cd = res.header('Content-Disposition');
|
||||
assert.ok(/filename=mysvg.svg/gi.test(cd), cd);
|
||||
assert.equal(res.header('Content-Type'), 'image/svg+xml; charset=utf-8');
|
||||
assert.ok( res.body.indexOf('<path d="M 0 768 L 1024 0" />') > 0, res.body );
|
||||
// TODO: test viewBox
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
test('GET /api/v1/sql with SVG format and centered point', function(done){
|
||||
var query = querystring.stringify({
|
||||
q: "SELECT 1 as cartodb_id, ST_MakePoint(5000, -54) AS the_geom ",
|
||||
@ -687,13 +741,74 @@ test('SHP format, unauthenticated', function(done){
|
||||
assert.response(app, {
|
||||
url: '/api/v1/sql?q=SELECT%20*%20FROM%20untitle_table_4%20LIMIT%201&format=shp',
|
||||
headers: {host: 'vizzuality.cartodb.com'},
|
||||
encoding: 'binary',
|
||||
method: 'GET'
|
||||
},{ }, function(res){
|
||||
assert.equal(res.statusCode, 200, res.body);
|
||||
var cd = res.header('Content-Disposition');
|
||||
assert.equal(true, /^attachment/.test(cd), 'SHP is not disposed as attachment: ' + cd);
|
||||
assert.equal(true, /filename=cartodb-query.zip/gi.test(cd));
|
||||
// TODO: check for actual content, at least try to uncompress..
|
||||
var tmpfile = '/tmp/myshape.zip';
|
||||
var err = fs.writeFileSync(tmpfile, res.body, 'binary');
|
||||
if (err) { done(err); return }
|
||||
var zf = new zipfile.ZipFile(tmpfile);
|
||||
assert.ok(_.contains(zf.names, 'cartodb-query.shp'), 'SHP zipfile does not contain .shp: ' + zf.names);
|
||||
assert.ok(_.contains(zf.names, 'cartodb-query.shx'), 'SHP zipfile does not contain .shx: ' + zf.names);
|
||||
assert.ok(_.contains(zf.names, 'cartodb-query.dbf'), 'SHP zipfile does not contain .dbf: ' + zf.names);
|
||||
// missing SRID, so no PRJ (TODO: add ?)
|
||||
//assert.ok(_.contains(zf.names, 'cartodb-query.prj'), 'SHP zipfile does not contain .prj: ' + zf.names);
|
||||
fs.unlinkSync(tmpfile);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
test('SHP format, unauthenticated, with custom filename', function(done){
|
||||
assert.response(app, {
|
||||
url: '/api/v1/sql?q=SELECT%20*%20FROM%20untitle_table_4%20LIMIT%201&format=shp&filename=myshape',
|
||||
headers: {host: 'vizzuality.cartodb.com'},
|
||||
encoding: 'binary',
|
||||
method: 'GET'
|
||||
},{ }, function(res){
|
||||
assert.equal(res.statusCode, 200, res.body);
|
||||
var cd = res.header('Content-Disposition');
|
||||
assert.equal(true, /^attachment/.test(cd), 'SHP is not disposed as attachment: ' + cd);
|
||||
assert.equal(true, /filename=myshape.zip/gi.test(cd));
|
||||
var tmpfile = '/tmp/myshape.zip';
|
||||
var err = fs.writeFileSync(tmpfile, res.body, 'binary');
|
||||
if (err) { done(err); return }
|
||||
var zf = new zipfile.ZipFile(tmpfile);
|
||||
assert.ok(_.contains(zf.names, 'myshape.shp'), 'SHP zipfile does not contain .shp: ' + zf.names);
|
||||
assert.ok(_.contains(zf.names, 'myshape.shx'), 'SHP zipfile does not contain .shx: ' + zf.names);
|
||||
assert.ok(_.contains(zf.names, 'myshape.dbf'), 'SHP zipfile does not contain .dbf: ' + zf.names);
|
||||
// missing SRID, so no PRJ (TODO: add ?)
|
||||
//assert.ok(_.contains(zf.names, 'myshape.prj'), 'SHP zipfile does not contain .prj: ' + zf.names);
|
||||
fs.unlinkSync(tmpfile);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
test('SHP format, unauthenticated, with custom, dangerous filename', function(done){
|
||||
assert.response(app, {
|
||||
url: '/api/v1/sql?q=SELECT%20*%20FROM%20untitle_table_4%20LIMIT%201&format=shp&filename=b;"%20()[]a',
|
||||
headers: {host: 'vizzuality.cartodb.com'},
|
||||
encoding: 'binary',
|
||||
method: 'GET'
|
||||
},{ }, function(res){
|
||||
assert.equal(res.statusCode, 200, res.body);
|
||||
var fname = "b_______a";
|
||||
var cd = res.header('Content-Disposition');
|
||||
assert.equal(true, /^attachment/.test(cd), 'SHP is not disposed as attachment: ' + cd);
|
||||
assert.equal(true, /filename=b_______a.zip/gi.test(cd), 'Unexpected SHP filename: ' + cd);
|
||||
var tmpfile = '/tmp/myshape.zip';
|
||||
var err = fs.writeFileSync(tmpfile, res.body, 'binary');
|
||||
if (err) { done(err); return }
|
||||
var zf = new zipfile.ZipFile(tmpfile);
|
||||
assert.ok(_.contains(zf.names, fname + '.shp'), 'SHP zipfile does not contain .shp: ' + zf.names);
|
||||
assert.ok(_.contains(zf.names, fname + '.shx'), 'SHP zipfile does not contain .shx: ' + zf.names);
|
||||
assert.ok(_.contains(zf.names, fname + '.dbf'), 'SHP zipfile does not contain .dbf: ' + zf.names);
|
||||
// missing SRID, so no PRJ (TODO: add ?)
|
||||
//assert.ok(_.contains(zf.names, fname+ '.prj'), 'SHP zipfile does not contain .prj: ' + zf.names);
|
||||
fs.unlinkSync(tmpfile);
|
||||
done();
|
||||
});
|
||||
});
|
||||
@ -702,12 +817,22 @@ test('SHP format, authenticated', function(done){
|
||||
assert.response(app, {
|
||||
url: '/api/v1/sql?q=SELECT%20*%20FROM%20untitle_table_4%20LIMIT%201&format=shp&api_key=1234',
|
||||
headers: {host: 'vizzuality.cartodb.com'},
|
||||
encoding: 'binary',
|
||||
method: 'GET'
|
||||
},{ }, function(res){
|
||||
assert.equal(res.statusCode, 200, res.body);
|
||||
var cd = res.header('Content-Disposition');
|
||||
assert.equal(true, /filename=cartodb-query.zip/gi.test(cd));
|
||||
// TODO: check for actual content, at least try to uncompress..
|
||||
var tmpfile = '/tmp/myshape.zip';
|
||||
var err = fs.writeFileSync(tmpfile, res.body, 'binary');
|
||||
if (err) { done(err); return }
|
||||
var zf = new zipfile.ZipFile(tmpfile);
|
||||
assert.ok(_.contains(zf.names, 'cartodb-query.shp'), 'SHP zipfile does not contain .shp: ' + zf.names);
|
||||
assert.ok(_.contains(zf.names, 'cartodb-query.shx'), 'SHP zipfile does not contain .shx: ' + zf.names);
|
||||
assert.ok(_.contains(zf.names, 'cartodb-query.dbf'), 'SHP zipfile does not contain .dbf: ' + zf.names);
|
||||
// missing SRID, so no PRJ (TODO: add ?)
|
||||
//assert.ok(_.contains(zf.names, 'cartodb-query.prj'), 'SHP zipfile does not contain .prj: ' + zf.names);
|
||||
fs.unlinkSync(tmpfile);
|
||||
done();
|
||||
});
|
||||
});
|
||||
@ -729,6 +854,21 @@ test('KML format, unauthenticated', function(done){
|
||||
});
|
||||
});
|
||||
|
||||
test('KML format, unauthenticated, custom filename', function(done){
|
||||
assert.response(app, {
|
||||
url: '/api/v1/sql?q=SELECT%20*%20FROM%20untitle_table_4%20LIMIT%201&format=kml&filename=kmltest',
|
||||
headers: {host: 'vizzuality.cartodb.com'},
|
||||
method: 'GET'
|
||||
},{ }, function(res){
|
||||
assert.equal(res.statusCode, 200, res.body);
|
||||
var cd = res.header('Content-Disposition');
|
||||
assert.equal(true, /^attachment/.test(cd), 'KML is not disposed as attachment: ' + cd);
|
||||
assert.equal(true, /filename=kmltest.kml/gi.test(cd), 'Unexpected KML filename: ' + cd);
|
||||
// TODO: check for actual content, at least try to uncompress..
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
test('KML format, authenticated', function(done){
|
||||
assert.response(app, {
|
||||
url: '/api/v1/sql?q=SELECT%20*%20FROM%20untitle_table_4%20LIMIT%201&format=kml&api_key=1234',
|
||||
|
Loading…
Reference in New Issue
Block a user