jii
Version:
Jii - Full-Stack JavaScript Framework
460 lines (389 loc) • 15.4 kB
JavaScript
/**
* @author <a href="http://www.affka.ru">Vladimir Kozhin</a>
* @license MIT
*/
'use strict';
const Jii = require('../index');
const InvalidConfigException = require('../exceptions/InvalidConfigException');
const _trim = require('lodash/trim');
const _trimEnd = require('lodash/trimEnd');
const _trimStart = require('lodash/trimStart');
const _isArray = require('lodash/isArray');
const _indexOf = require('lodash/indexOf');
const _isEmpty = require('lodash/isEmpty');
const _isObject = require('lodash/isObject');
const _each = require('lodash/each');
const _has = require('lodash/has');
const _clone = require('lodash/clone');
const _cloneDeep = require('lodash/cloneDeep');
const BaseObject = require('../base/BaseObject');
class UrlRule extends BaseObject {
preInit(config) {
/**
* The regex for matching the route part. This is used in generating URL.
* @type {string}
*/
this._routeRule = null;
/**
* The template for generating a new URL. This is derived from [[pattern]] and is used in generating URL.
* @type {string}
*/
this._template = null;
/**
* A value indicating if this rule should be used for both request parsing and URL creation,
* parsing only, or creation only.
* If not set or 0, it means the rule is both request parsing and URL creation.
* If it is [[PARSING_ONLY]], the rule is for request parsing only.
* If it is [[CREATION_ONLY]], the rule is for URL creation only.
* @type {number}
*/
this.mode = null;
/**
* The HTTP verb (e.g. GET, POST, DELETE) that this rule should match.
* Use array to represent multiple verbs that this rule may match.
* If this property is not set, the rule can match any verb.
* Note that this property is only used when parsing a request. It is ignored for URL creation.
* @type {string|array}
*/
this.verb = null;
/**
* The URL suffix used for this rule.
* For example, ".html" can be used so that the URL looks like pointing to a static HTML page.
* If not, the value of [[UrlManager::suffix]] will be used.
* @type {string}
*/
this.suffix = null;
/**
* The default GET parameters (name => value) that this rule provides.
* When this rule is used to parse the incoming request, the values declared in this property
* will be injected into $_GET.
* @type {object}
*/
this.defaults = {};
/**
* The route to the controller action
* @type {string}
*/
this.route = null;
/**
* The pattern used to parse and create the host info part of a URL.
* @type {string}
* @see pattern
*/
this.host = null;
/**
* The pattern used to parse and create the path info part of a URL.
* @type {string}
* @see host
*/
this.pattern = null;
/**
* The name of this rule. If not set, it will use [[pattern]] as the name.
* @type {string}
*/
this.name = null;
/**
* List of regex for matching parameters. This is used in generating URL.
* @type {array}
*/
this._paramRules = [];
/**
* List of parameters used in the route.
* @type {object}
*/
this._routeParams = {};
super.preInit(...arguments);
}
init() {
if (this.pattern === null) {
throw new InvalidConfigException('UrlRule::pattern must be set.');
}
if (this.route === null) {
throw new InvalidConfigException('UrlRule::route must be set.');
}
if (this.verb !== null) {
if (!_isArray(this.verb)) {
this.verb = [this.verb];
}
_each(this.verb, (value, key) => {
this.verb[key] = value.toUpperCase();
});
}
if (this.name === null) {
this.name = this.pattern;
}
this.pattern = _trim(this.pattern, '/');
if (this.host !== null) {
this.pattern = _trimEnd(this.host, '/') + _trimEnd('/' + this.pattern, '/') + '/';
} else if (this.pattern === '') {
this._template = '';
this.pattern = '^$';
return;
} else {
this.pattern = '/' + this.pattern + '/';
}
this.route = _trim(this.route, '/');
if (_indexOf(this.route, '<') !== -1) {
var matches = this.route.match(/<(\w+)>/g) || [];
_each(matches, name => {
var key = name.substr(1, name.length - 2);
this._routeParams[key] = name;
});
}
var tr = {
'.': '\\.',
'*': '\\*',
'$': '\\$',
'[': '\\[',
']': '\\]',
'(': '\\(',
')': '\\)'
};
var tr2 = {};
var matches2 = this.pattern.match(/<(\w+):?([^>]+)?>/g) || [];
_each(matches2, match => {
match = match.substr(1, match.length - 2);
var i = match.indexOf(':');
var name = i !== -1 ? match.substr(0, i) : match;
var pattern = i !== -1 ? match.substr(i + 1) : this.constructor.DEFAULT_PATTERN;
if (_has(this.defaults, name)) {
var length = match.length;
var offset = this.pattern.indexOf(match);
if (offset > 0 && this.pattern.substr(offset - 2, 1) === '/' && this.pattern.substr(offset + length + 1, 1) === '/') {
tr['/<' + name + '>'] = '(/(?P<' + name + '>' + pattern + '))?';
} else {
tr['<' + name + '>'] = '((?P<' + name + '>' + pattern + '))?';
}
} else {
tr['<' + name + '>'] = '((?P<' + name + '>' + pattern + '))';
}
if (_has(this._routeParams, name)) {
tr2['<' + name + '>'] = '((?P<' + name + '>' + pattern + '))';
} else {
this._paramRules[name] = pattern === this.constructor.DEFAULT_PATTERN ? '' : '^' + pattern + '$';
}
});
this._template = this.pattern.replace(/<(\w+):?([^>]+)?>/g, '<$1>');
this.pattern = '^' + _trim(this._strtr(this._template, tr), '/') + '$';
if (!_isEmpty(this._routeParams)) {
this._routeRule = '^' + this._strtr(this.route, tr2) + '$';
}
}
/**
* Parses the given request and returns the corresponding route and parameters.
* @param {UrlManager} manager the URL manager
* @param {BaseRequest} request the request component
* @return {[]|boolean} the parsing result. The route and the parameters are returned as an array.
* If false, it means this rule cannot be used to parse this path info.
*/
parseRequest(manager, request) {
if (this.mode === UrlRule.CREATION_ONLY) {
return false;
}
if (this.verb !== null && _indexOf(request.getMethod(), this.verb) === -1) {
return false;
}
var pathInfo = request.getPathInfo();
var suffix = this.suffix || manager.suffix;
if (suffix && pathInfo) {
var length = suffix.length;
if (pathInfo.substr(-1 * length) === suffix) {
pathInfo = pathInfo.substr(0, pathInfo.length - length);
if (pathInfo === '') {
// suffix alone is not allowed
return false;
}
} else {
return false;
}
}
if (this.host !== null) {
pathInfo = request.getHostInfo().toLowerCase() + '/' + pathInfo;
}
var matches = this._keyMatch(pathInfo, this.pattern, 'g');
if (!matches || matches.length === 0) {
return false;
}
if (_isObject(matches[0])) {
// Skip first slash (added in pattern)
_each(matches[0], (value, name) => {
matches[0][name] = _trimStart(value, '/');
});
_each(this.defaults, (value, name) => {
if (!_has(matches[0], name) || matches[0][name] === '') {
matches[0][name] = value;
}
});
}
var params = _clone(this.defaults);
var tr = {};
if (_isObject(matches[0])) {
_each(matches[0], (value, name) => {
if (_has(this._routeParams, name)) {
tr[this._routeParams[name]] = value;
delete params[name];
} else if (_has(this._paramRules, name)) {
params[name] = value;
}
});
}
var route = this._routeRule !== null ? this._strtr(this.route, tr) : this.route;
return [
route,
params
];
}
/**
* Creates a URL according to the given route and parameters.
* @param {UrlManager} manager the URL manager
* @param {string} route the route. It should not have slashes at the beginning or the end.
* @param {array} params the parameters
* @return string|boolean the created URL, or false if this rule cannot be used for creating this URL.
*/
createUrl(manager, route, params) {
if (this.mode === UrlRule.PARSING_ONLY) {
return false;
}
var tr = {};
// match the route part first
if (!(route === this.route || '/' + route === this.route)) {
if (this._routeRule === null) {
return false;
}
var matches = this._keyMatch(route, this._routeRule, 'g');
if (!matches || matches.length === 0) {
return false;
}
_each(this._routeParams, (token, name) => {
tr[token] = !_has(this.defaults, name) || this.defaults[name] !== matches[0][name] ? matches[0][name] : '';
});
}
params = _cloneDeep(params);
// match default params
// if a default param is not in the route pattern, its value must also be matched
for (var name in this.defaults) {
if (!this.defaults.hasOwnProperty(name) || _has(this._routeParams, name)) {
continue;
}
var value = this.defaults[name];
if (!_has(params, name)) {
return false;
} else if (params[name] === value) {
delete params[name];
if (_has(this._paramRules, name)) {
tr['<' + name + '>'] = '';
}
} else if (!_has(this._paramRules, name)) {
return false;
}
}
// match params in the pattern
for (var ruleName in this._paramRules) {
if (!this._paramRules.hasOwnProperty(ruleName)) {
continue;
}
var rule = this._paramRules[ruleName];
if (_has(params, ruleName) && !_isArray(params[ruleName]) && (rule === '' || new RegExp(rule).test(params[ruleName]))) {
tr['<' + ruleName + '>'] = encodeURIComponent(params[ruleName]);
delete params[ruleName];
} else if (!_has(this.defaults, ruleName) || _has(params, ruleName)) {
return false;
}
}
var url = _trim(this._strtr(this._template, tr), '/');
if (this.host !== null) {
var index = url.indexOf('/', 8);
if (index !== -1) {
url = url.substr(0, index) + url.substr(index).replace(/\/+/g, '/');
}
} else if (url.indexOf('//') !== -1) {
url = url.replace(/\/+/g, '/');
}
if (url !== '') {
url += this.suffix || manager.suffix || '';
}
if (!_isEmpty(params)) {
const UrlManager = require('./UrlManager');
url += '?' + UrlManager.buildQuery(params);
}
return url;
}
_strtr(str, params) {
_each(params, (to, from) => {
from = from.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&');
str = str.replace(new RegExp(from, 'g'), to);
});
return str;
}
/**
* Javascript RegExp Match Named Captures
* This script getted from http://trentrichardson.com/2011/08/02/javascript-regexp-match-named-captures/
* @param {string} str
* @param {string} re
* @param {string} flags
* @returns {array}
* @private
*/
_keyMatch(str, re, flags) {
var isGlobal = false,
results = [],
keys = {},
nativeRegExp = null,
tmpstr = str;
if (flags === undefined)
flags = '';
// find the keys inside the re, and place in mapping array {'1':'key1', '2':'key2', ...}
var tmpkeys = re.match(/(?!\(\?P<)(\w+)(?=\>)/g);
if (!tmpkeys) {
// no keys, do a regular match
return str.match(re);
} else {
for (var i = 0, l = tmpkeys.length; i < l; i++) {
keys[i * 2 + 1] = tmpkeys[i];
}
}
// remove keys from regexp leaving standard regexp
nativeRegExp = re.replace(/\?P<\w+\>/g, '');
if (flags.indexOf('g') >= 0)
isGlobal = true;
flags = flags.replace('g', '');
nativeRegExp = new RegExp(nativeRegExp, flags);
do {
// parse string
var tmpmatch = tmpstr.match(nativeRegExp),
tmpkeymatch = {},
tmpsubstr = '';
if (tmpmatch) {
// get the entire string found
tmpsubstr = tmpmatch[0];
tmpkeymatch[0] = tmpsubstr;
// map them back out
for (var i2 = 1, l2 = tmpmatch.length; i2 < l2; i2++) {
tmpkeymatch[keys[i2 - 1]] = tmpmatch[i2] || '';
}
// add to results
results.push(tmpkeymatch);
tmpstr = tmpstr.slice(tmpstr.indexOf(tmpsubstr) + tmpsubstr.length);
} else {
tmpstr = '';
}
} while (isGlobal && tmpstr.length > 0);
// if global loop until end of str, else do once
return results;
}
}
/**
* @type {string}
*/
UrlRule.DEFAULT_PATTERN = '[^/]+';
/**
* Set [[mode]] with this value to mark that this rule is for URL creation only
* @type {number}
*/
UrlRule.CREATION_ONLY = 2;
/**
* Set [[mode]] with this value to mark that this rule is for URL parsing only
* @type {number}
*/
UrlRule.PARSING_ONLY = 1;
module.exports = UrlRule;