Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

query string route syntax & parameter-based URI decoding #668

Closed
wants to merge 18 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
159 changes: 152 additions & 7 deletions backbone.js
Expand Up @@ -664,6 +664,7 @@


// Cached regular expressions for matching named param parts and splatted // Cached regular expressions for matching named param parts and splatted
// parts of route strings. // parts of route strings.
var queryStringParam = /^\?(.*)/;
var namedParam = /:([\w\d]+)/g; var namedParam = /:([\w\d]+)/g;
var splatParam = /\*([\w\d]+)/g; var splatParam = /\*([\w\d]+)/g;
var escapeRegExp = /[-[\]{}()+?.,\\^$|#\s]/g; var escapeRegExp = /[-[\]{}()+?.,\\^$|#\s]/g;
Expand Down Expand Up @@ -714,17 +715,149 @@
// against the current location hash. // against the current location hash.
_routeToRegExp : function(route) { _routeToRegExp : function(route) {
route = route.replace(escapeRegExp, "\\$&") route = route.replace(escapeRegExp, "\\$&")
.replace(namedParam, "([^\/]*)") .replace(namedParam, "([^\/?]*)")
.replace(splatParam, "(.*?)"); .replace(splatParam, "([^\?]*)");
route += '([\?]{1}.*)?';
return new RegExp('^' + route + '$'); return new RegExp('^' + route + '$');
}, },


// Given a route, and a URL fragment that it matches, return the array of // Given a route, and a URL fragment that it matches, return the array of
// extracted parameters. // extracted parameters.
_extractParameters : function(route, fragment) { _extractParameters : function(route, fragment) {
return route.exec(fragment).slice(1); var params = route.exec(fragment).slice(1);
}
// do we have an additional query string?
var match = params.length && params[params.length-1] && params[params.length-1].match(queryStringParam);
if (match) {
var queryString = match[1];
var data = {};
if (queryString) {
var keyValues = queryString.split('&');
var self = this;
_.each(keyValues, function(keyValue) {
var arr = keyValue.split('=');
if (arr.length > 1 && arr[1]) {
self._setParamValue(arr[0], arr[1], data);
}
});
}
params[params.length-1] = data;
}

// decode params
for (var i=1; i<params.length; i++) {
if (_.isString(params[i])) {
params[i] = decodeURIComponent(params[i]);
}
}

return params;
},

_setParamValue : function(key, value, data) {
// use '.' to define hash separators
var parts = key.split('.');
var _data = data;
for (var i=0; i<parts.length; i++) {
var part = parts[i];
if (i === parts.length-1) {
// set the value
_data[part] = this._decodeParamValue(value, _data[part]);
} else {
_data = _data[part] = _data[part] || {};
}
}
},

_decodeParamValue : function(value, currentValue) {
// '|' will indicate an array. Array with 1 value is a=|b - multiple values can be a=b|c
if (value.indexOf('|') >= 0) {
var values = value.split('|');
// clean it up
for (var i=values.length-1; i>=0; i--) {
if (!values[i]) {
values.splice(i, 1);
} else {
values[i] = decodeURIComponent(values[i])
}
}
return values;
}
if (!currentValue) {
return decodeURIComponent(value);
} else if (_.isArray(currentValue)) {
currentValue.push(value);
return currentValue;
} else {
return [currentValue, value];
}
},


// Return the route fragment with queryParameters serialized to query parameter string
toFragment: function(route, queryParameters) {
if (queryParameters) {
if (!_.isString(queryParameters)) {
queryParameters = this._toQueryString(queryParameters);
}
route += '?' + queryParameters;
}
return route;
},

// Serialize the val hash to query parameters and return it. Use the namePrefix to prefix all param names (for recursion)
_toQueryString: function(val, namePrefix) {
if (!val) return '';
namePrefix = namePrefix || '';
var rtn = '';
for (var name in val) {
var _val = val[name];
if (_.isString(_val) || _.isNumber(_val) || _.isBoolean(_val) || _.isDate(_val)) {
// primitave type
_val = this._stringifyQueryParam(_val);
if (_.isBoolean(_val) || _val) {
rtn += (rtn ? '&' : '') + this._toQueryParamName(name, namePrefix) + '=' + encodeURIComponent(_val).replace('|', '%7C');
}
} else if (_.isArray(_val)) {
// arrrays use | separator
var str = '';
for (var i in _val) {
var param = this._stringifyQueryParam(_val[i]);
if (_.isBoolean(param) || param) {
str += '|' + encodeURIComponent(param).replace('|', '%7C');
}
}
if (str) {
rtn += (rtn ? '&' : '') + this._toQueryParamName(name, namePrefix) + '=' + str;
}
} else {
// dig into hash
var result = this._toQueryString(_val, this._toQueryParamName(name, namePrefix, true));
if (result) {
rtn += (rtn ? '&' : '') + result;
}
}
}
return rtn;
},

// return the actual parameter name
// name: the parameter name
// namePrefix: the prefix to the name
// createPrefix: true if we're creating a name prefix, false if we're creating the name
_toQueryParamName: function(name, prefix, isPrefix) {
return (prefix + name + (isPrefix ? '.' : ''));
},

// Return the string representation of the param used for the query string
_stringifyQueryParam: function (param) {
if (_.isNull(param) || _.isUndefined(param)) {
return null;
}
if (_.isDate(param)) {
return param.getDate().getTime();
}
return param;
}
}); });


// Backbone.History // Backbone.History
Expand All @@ -739,6 +872,7 @@


// Cached regex for cleaning hashes. // Cached regex for cleaning hashes.
var hashStrip = /^#*/; var hashStrip = /^#*/;
var queryStrip = /\?(.*)/;


// Cached regex for detecting MSIE. // Cached regex for detecting MSIE.
var isExplorer = /msie [\w.]+/; var isExplorer = /msie [\w.]+/;
Expand All @@ -755,7 +889,7 @@


// Get the cross-browser normalized URL fragment, either from the URL, // Get the cross-browser normalized URL fragment, either from the URL,
// the hash, or the override. // the hash, or the override.
getFragment : function(fragment, forcePushState) { getFragment : function(fragment, forcePushState, excludeQueryString) {
if (fragment == null) { if (fragment == null) {
if (this._hasPushState || forcePushState) { if (this._hasPushState || forcePushState) {
fragment = window.location.pathname; fragment = window.location.pathname;
Expand All @@ -765,11 +899,20 @@
fragment = window.location.hash; fragment = window.location.hash;
} }
} }
fragment = decodeURIComponent(fragment.replace(hashStrip, '')); fragment = fragment.replace(hashStrip, '');
if (excludeQueryString) {
fragment = fragment.replace(queryStrip, '');
}
if (!fragment.indexOf(this.options.root)) fragment = fragment.substr(this.options.root.length); if (!fragment.indexOf(this.options.root)) fragment = fragment.substr(this.options.root.length);
return fragment; return fragment;
}, },


getQueryString : function(fragment) {
fragment = this.getFragment(fragment);
var match = fragment.match(queryStrip);
return match && match[1];
},

// Start the hash change handling, returning `true` if the current URL matches // Start the hash change handling, returning `true` if the current URL matches
// an existing route, and `false` otherwise. // an existing route, and `false` otherwise.
start : function(options) { start : function(options) {
Expand Down Expand Up @@ -868,7 +1011,9 @@
} }
} }
if (triggerRoute) this.loadUrl(fragment); if (triggerRoute) this.loadUrl(fragment);
} },




}); });


Expand Down
118 changes: 111 additions & 7 deletions test/router.js
Expand Up @@ -17,28 +17,35 @@ $(document).ready(function() {
this.testing = options.testing; this.testing = options.testing;
}, },


search : function(query, page) { search : function(query, page, queryParams) {
this.query = query; this.query = query;
this.page = page; this.page = page;
this.queryParams = queryParams;
this.queryString = Backbone.history.getQueryString();
this.fragment = Backbone.history.getFragment(null, false, true);
}, },


splat : function(args) { splat : function(args, queryParams) {
this.args = args; this.args = args;
this.queryParams = queryParams;
}, },


complex : function(first, part, rest) { complex : function(first, part, rest, queryParams) {
this.first = first; this.first = first;
this.part = part; this.part = part;
this.rest = rest; this.rest = rest;
this.queryParams = queryParams;
}, },


query : function(entity, args) { query : function(entity, args, queryParams) {
this.entity = entity; this.entity = entity;
this.queryArgs = args; this.queryArgs = args;
this.queryParams = queryParams;
}, },


anything : function(whatever) { anything : function(whatever, queryParams) {
this.anything = whatever; this.anything = whatever;
this.queryParams = queryParams;
} }


}); });
Expand Down Expand Up @@ -71,10 +78,87 @@ $(document).ready(function() {
}, 10); }, 10);
}); });


test("Router: routes via navigate", 2, function() { asyncTest("Router: routes (two part - encoded reserved char)", 2, function() {
window.location.hash = 'search/nyc/pa%2Fb';
setTimeout(function() {
equals(router.query, 'nyc');
equals(router.page, 'a/b');
start();
}, 10);
});

asyncTest("Router: routes (two part - query params)", 5, function() {
window.location.hash = 'search/nyc/p10?a=b';
setTimeout(function() {
equals(router.query, 'nyc');
equals(router.page, '10');
equals(router.fragment, 'search/nyc/p10');
equals(router.queryString, 'a=b');
equals(router.queryParams.a, 'b');
start();
}, 10);
});

asyncTest("Router: routes (two part - query params - hash and list - location)", 23, function() {
window.location.hash = 'search/nyc/p10?a=b&a2=x&a2=y&a3=x&a3=y&a3=z&b.c=d&b.d=e&b.e.f=g&array1=|a&array2=a|b&array3=|c|d&array4=|e%7C';
setTimeout(function() {
equals(router.query, 'nyc');
equals(router.page, '10');
equals(router.queryParams.a, 'b');
equals(router.queryParams.a2.length, 2);
equals(router.queryParams.a2[0], 'x');
equals(router.queryParams.a2[1], 'y');
equals(router.queryParams.a3.length, 3);
equals(router.queryParams.a3[0], 'x');
equals(router.queryParams.a3[1], 'y');
equals(router.queryParams.a3[2], 'z');
equals(router.queryParams.b.c, 'd');
equals(router.queryParams.b.d, 'e');
equals(router.queryParams.b.e.f, 'g');
equals(router.queryParams.array1.length, 1);
equals(router.queryParams.array1[0], 'a');
equals(router.queryParams.array2.length, 2);
equals(router.queryParams.array2[0], 'a');
equals(router.queryParams.array2[1], 'b');
equals(router.queryParams.array3.length, 2);
equals(router.queryParams.array3[0], 'c');
equals(router.queryParams.array3[1], 'd');
equals(router.queryParams.array4.length, 1);
equals(router.queryParams.array4[0], 'e|');
start();
}, 10);
});

asyncTest("Router: routes (two part - query params - hash and list - navigate)", 15, function() {
window.location.hash = router.toFragment('search/nyc/p10', {
a:'l', b:{c: 'n', d:'m', e:{f: 'o'}}, array1:['p'], array2:['q', 'r'], array3:['s','t','|']
});
setTimeout(function() {
equals(router.query, 'nyc');
equals(router.page, '10');
equals(router.queryParams.a, 'l');
equals(router.queryParams.b.c, 'n');
equals(router.queryParams.b.d, 'm');
equals(router.queryParams.b.e.f, 'o');
equals(router.queryParams.array1.length, 1);
equals(router.queryParams.array1[0], 'p');
equals(router.queryParams.array2.length, 2);
equals(router.queryParams.array2[0], 'q');
equals(router.queryParams.array2[1], 'r');
equals(router.queryParams.array3.length, 3);
equals(router.queryParams.array3[0], 's');
equals(router.queryParams.array3[1], 't');
equals(router.queryParams.array3[2], '|');
start();
}, 10);
});

test("Router: routes via navigate", 4, function() {
Backbone.history.navigate('search/manhattan/p20', true); Backbone.history.navigate('search/manhattan/p20', true);
equals(router.query, 'manhattan'); equals(router.query, 'manhattan');
equals(router.page, '20'); equals(router.page, '20');
equals(router.queryString, undefined);
equals(router.fragment, 'search/manhattan/p20');
}); });


asyncTest("Router: routes (splats)", function() { asyncTest("Router: routes (splats)", function() {
Expand All @@ -85,6 +169,15 @@ $(document).ready(function() {
}, 10); }, 10);
}); });


asyncTest("Router: routes (splats - query params)", 2, function() {
window.location.hash = 'splat/long-list/of/splatted_99args/end?c=d';
setTimeout(function() {
equals(router.args, 'long-list/of/splatted_99args');
equals(router.queryParams.c, 'd');
start();
}, 10);
});

asyncTest("Router: routes (complex)", 3, function() { asyncTest("Router: routes (complex)", 3, function() {
window.location.hash = 'one/two/three/complex-part/four/five/six/seven'; window.location.hash = 'one/two/three/complex-part/four/five/six/seven';
setTimeout(function() { setTimeout(function() {
Expand All @@ -95,8 +188,19 @@ $(document).ready(function() {
}, 10); }, 10);
}); });


asyncTest("Router: routes (complex - query params)", 4, function() {
window.location.hash = 'one/two/three/complex-part/four/five/six/seven?e=f';
setTimeout(function() {
equals(router.first, 'one/two/three');
equals(router.part, 'part');
equals(router.rest, 'four/five/six/seven');
equals(router.queryParams.e, 'f');
start();
}, 10);
});

asyncTest("Router: routes (query)", 2, function() { asyncTest("Router: routes (query)", 2, function() {
window.location.hash = 'mandel?a=b&c=d'; window.location.hash = router.toFragment('mandel', {a:'b', c:'d'});
setTimeout(function() { setTimeout(function() {
equals(router.entity, 'mandel'); equals(router.entity, 'mandel');
equals(router.queryArgs, 'a=b&c=d'); equals(router.queryArgs, 'a=b&c=d');
Expand Down