alchemymvc
Version:
MVC framework for Node.js
862 lines (677 loc) • 17.5 kB
JavaScript
/**
* Route Class
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 0.2.0
* @version 1.3.21
*/
const Route = Function.inherits('Alchemy.Base', function Route(router, paths, options) {
// Store the parent router
this.router = router;
// Store the route options
this.options = options;
// This is not middleware by default
this.is_middleware = options.is_middleware || false;
// The methods to listen to
this.methods = null;
// The paths to listen to
this.paths = null;
// The weight of this route (read-only)
this.weight = null;
// Is this route prefix-aware?
this.is_prefix_aware = false;
// The name of this route
this.name = null;
// The optional function
this.fnc = null;
// All the keys used
this.keys = null;
// The breadcrumb string
this.breadcrumb = '';
this.has_breadcrumb_assignments = false;
// The optional permissions to check
this.permission = null;
this.has_permission_assignments = false;
// The sitemap configuration
this.sitemap = null;
// Can this route's path be used in the browser's address location?
this.visible_location = true;
// Is this a system route of some kind?
// (meaning: not for end users)
this.is_system_route = false;
// If no fnc is given, these will be called
this.controller = null;
this.action = null;
// All routes can be postponed by default
this.can_be_postponed = true;
// Possible cache instructions
this.cache = null;
this.setPaths(paths);
});
/**
* Deprecated property names
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.0.0
* @version 1.0.0
*/
Route.setDeprecatedProperty('isMiddleware', 'is_middleware');
/**
* Does this route require extra data for translating to another language?
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.0
* @version 1.3.0
*
* @return {boolean}
*/
Route.setProperty(function requires_data_for_translation() {
if (!this.is_prefix_aware || !this.has_path_assignments) {
return false;
}
return true;
});
/**
* Does this route have any path assignments?
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.0
* @version 1.3.0
*
* @return {boolean}
*/
Route.setProperty(function has_path_assignments() {
return !!this.keys?.length;
});
/**
* Does this route use any type class checks?
* (Type CLASS checks use the type checker of a specific class)
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.0
* @version 1.3.0
*
* @return {boolean}
*/
Route.setProperty(function has_type_class_checks() {
let result = false,
path,
key;
for (key in this.paths) {
path = this.paths[key];
if (path.uses_type_class_checks) {
result = true;
break;
}
}
return result;
});
/**
* Routes with parameters can have schemas
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.7
* @version 1.4.0
*
* @return {Schema}
*/
Route.enforceProperty(function schema(new_value) {
if (new_value) {
return new_value;
}
if (!this.has_path_assignments) {
return false;
}
let added_models = {},
added_fields = {},
prefix,
param,
definition;
new_value = alchemy.createSchema();
for (prefix in this.paths) {
definition = this.paths[prefix];
if (!definition?.param_definitions?.length) {
continue;
}
for (param of definition.param_definitions) {
let model_name = param.model_constructor?.model_name;
if (model_name) {
if (added_models[model_name]) {
continue;
}
if (added_fields[param.name]) {
continue;
}
added_models[model_name] = true;
try {
new_value.belongsTo(model_name);
} catch (err) {
// Ignored
alchemy.distinctProblem('route-schema-' + this.name, 'Route schema error', {error: err});
}
} else {
new_value.addField(param.name, 'String');
added_fields[param.name] = true;
}
}
}
return new_value;
});
/**
* Get all the param definitions
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.7
* @version 1.3.7
*
* @return {Object}
*/
Route.enforceProperty(function param_definitions(new_value) {
if (new_value) {
return new_value;
}
let prefix,
param,
key,
definition;
new_value = {};
for (prefix in this.paths) {
definition = this.paths[prefix];
if (!definition.param_definitions?.length) {
continue;
}
for (param of definition.param_definitions) {
key = prefix + '_' + param.name;
new_value[key] = {
name : param.name,
type_class_name : param.type_class_name,
type_field_name : param.type_field_name,
is_model_type : param.is_model_type,
};
}
}
return new_value;
});
/**
* Set the breadcrumb for this route
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 0.3.0
* @version 0.3.0
*
* @param {string} breadcrumb
*/
Route.setMethod(function setBreadcrumb(breadcrumb) {
var assignments;
if (!breadcrumb) {
breadcrumb = '';
} else {
// See if this breadcrumb has assignments
assignments = breadcrumb.assignments();
if (assignments.length) {
this.has_breadcrumb_assignments = true;
}
}
this.breadcrumb = breadcrumb;
});
/**
* Set the permissions for this route
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.2.5
* @version 1.2.5
*
* @param {string|string[]} permission
*/
Route.setMethod(function setPermission(permission) {
if (!permission) {
return;
}
permission = Array.cast(permission);
this.permission = permission;
for (let entry of permission) {
let assignments = entry.assignments();
if (assignments.length) {
this.has_permission_assignments = true;
break;
}
}
});
/**
* Set if this route can be postponed
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.1
* @version 1.3.1
*
* @param {boolean} postponable
*/
Route.setMethod(function setCanBePostponed(postponable) {
this.can_be_postponed = postponable;
});
/**
* Compile paths for this route
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 0.2.0
* @version 1.3.0
*
* @param {string|Object} paths
*/
Route.setMethod(function setPaths(paths) {
// Certain routes (like socket only routes)
// have no paths
if (paths == null) {
return;
}
let definition,
prefix,
keys = [],
path;
if (!Object.isPlainObject(paths)) {
paths = {'': paths};
} else {
this.is_prefix_aware = true;
}
// Reset the paths
this.paths = {};
for (prefix in paths) {
path = paths[prefix];
definition = new Classes.Alchemy.PathDefinition(path, {
prefix,
end: !this.is_middleware,
});
this.paths[prefix] = definition;
if (definition.keys.length) {
keys.include(definition.keys);
}
}
// Only use the unique values
this.keys = keys.unique();
});
/**
* Match the given path & prefix to this route,
* and return the in-url named parameters
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 0.2.0
* @version 1.2.5
*
* @param {string} method
* @param {string} path The path (without the prefix)
* @param {string} prefix The prefix name
*
* @return {boolean|Object} False if it doesn't match, or named params
*/
Route.setMethod(function match(conduit, method, path, prefix) {
conduit.markRouteAsTested(this);
if (this.methods && this.methods.indexOf(method) == -1) {
return false;
}
let result = false;
if (!prefix) {
prefix = '';
}
path = path.before('?') || path;
// If this route isn't prefix aware, but a prefix was detected in the route,
// see if we can add it
if (!this.is_prefix_aware && prefix) {
// Prepend the prefix ONLY if the path doesn't equal the prefix already
// For example: don't turn "/nl" into "/nl/nl"
if (path != ('/' + prefix)) {
path = '/' + prefix + path;
}
prefix = '';
}
if (this.paths[prefix]) {
// Get the path definition, including the regex & keys array
let definition = this.paths[prefix];
// Test it
let temp = definition.test(path, conduit);
if (!temp) {
return false;
}
if (temp.then) {
let pledge = new Blast.Classes.Pledge();
temp.then(function gotValue(values) {
if (!values) {
return pledge.resolve(null);
}
pledge.resolve({
definition : definition,
parameters : definition.getParametersObject(values),
original_parameters : definition.getParametersObject(values, 'original_value'),
parameters_array : values
});
});
temp.catch(function onError(err) {
pledge.reject(err);
});
return pledge;
}
result = {
definition : definition,
parameters : definition.getParametersObject(temp),
original_parameters : definition.getParametersObject(temp, 'original_value'),
parameters_array : temp
};
}
return result;
});
/**
* Set handler
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 0.2.0
* @version 0.2.0
*
* @param {string|Object} paths
*/
Route.setMethod(function setHandler(fnc) {
var assignments,
split;
if (typeof fnc === 'function') {
this.fnc = fnc;
return;
}
// Strings like 'StaticController#index'
if (typeof fnc === 'string') {
split = fnc.split('#');
this.controller = split[0];
this.action = split[1];
// See if there are assignments in the string
assignments = fnc.assignments();
// If there are assignments,
// the controller & action will be different upon each request
if (assignments.length) {
this.fnc = this.callControllerAssignments;
} else {
this.fnc = this.callController;
}
}
});
/**
* Check the policy
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.0.7
* @version 1.1.0
*
* @param {Conduit} conduit
*/
Route.setMethod(function checkPolicy(conduit) {
var policy = this.options.policy;
if (!policy) {
return true;
}
if (policy == 'logged_in') {
let user_data = conduit.session('UserData');
if (user_data && user_data.$pk) {
return true;
}
} else if (typeof policy == 'function') {
return policy.call(this, conduit);
}
return false;
});
/**
* Check the permission
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.2.5
* @version 1.2.5
*
* @param {Conduit} conduit
*/
Route.setMethod(function checkPermission(conduit) {
// Check the rouer section first
if (!this.router.checkPermission(conduit)) {
return false;
}
if (!this.permission) {
return true;
}
let permission;
for (permission of this.permission) {
if (this.has_permission_assignments) {
permission = permission.assign(conduit.route_string_parameters).toLowerCase();
}
if (conduit.hasPermission(permission)) {
return true;
}
}
return false;
});
/**
* Call the handler for this route
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 0.2.0
* @version 1.2.5
*
* @param {Conduit} conduit
* @param {Array} parameters
*/
Route.setMethod(async function callHandler(conduit, parameters) {
var breadcrumb,
parameters,
keys,
i;
if (this.fnc) {
if (!this.checkPermission(conduit)) {
let user = conduit.session('UserData');
if (!user) {
return conduit.notAuthorized();
} else {
return conduit.forbidden();
}
}
if (this.options.policy && !await this.checkPolicy(conduit)) {
return conduit.notAuthorized();
}
// If a client-side render is preferred and a layout is given,
// only render the layout
if (Blast.isNode && !conduit.ajax && this.options.prefer == 'client' && this.options.layout) {
conduit.internal('force_client_render', true);
return conduit.render(this.options.layout);
}
// If no arguments are given as an array,
// construct them if needed
if (!parameters) {
// If this is a socket call, and there are no parameters,
// use the body
// @TODO: what about streams?
if (this.methods.indexOf('socket') > -1) {
if (conduit.loopback_arguments) {
parameters = conduit.loopback_arguments;
} else {
parameters = Array.cast(conduit.body);
}
// Add the end method
parameters.push(function cb(err) {
var args;
if (err) {
return conduit.error(err);
}
args = Array.cast(arguments);
args.shift();
conduit.end.apply(conduit, args);
});
} else {
// Old-style path definitions made from regexes have no named
// parameters, so use their indexes as keys
if (conduit.path_definition.from_regex) {
keys = Object.keys(conduit.params);
} else {
keys = conduit.path_definition.keys;
}
parameters = new Array(keys.length);
for (i = 0; i < keys.length; i++) {
parameters[i] = conduit.params[keys[i]];
}
}
}
if (this.breadcrumb) {
breadcrumb = this.breadcrumb;
if (this.has_breadcrumb_assignments) {
conduit.internal('breadcrumb_pattern', breadcrumb);
// @TODO: {[Document]slug} parameters will be Documents,
// so this results in [Object object] strings!
breadcrumb = breadcrumb.assign(conduit.route_string_parameters).toLowerCase();
}
if (breadcrumb) {
// Set the breadcrumb path
conduit.internal('breadcrumb', breadcrumb);
}
}
return this.fnc(conduit, ...parameters);
}
throw new Error('No valid handler was found');
});
/**
* Generate a url
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 0.3.0
* @version 1.2.5
*
* @param {Object} parameters (optional)
* @param {Object|Alchemy.Conduit} conduit Conduit or options (optional)
*/
Route.setMethod(function generateUrl(parameters, conduit) {
// Use the "no locale" by default
let locale = '';
// If a conduit is given, get the locale
if (conduit) {
if (conduit.locales) {
let i;
for (i = 0; i < conduit.locales.length; i++) {
if (this.paths[conduit.locales[i]]) {
locale = conduit.locales[i];
break;
}
}
}
if (!locale && conduit.locale) {
locale = conduit.locale;
}
}
return Router.getUrl(this.name, parameters, {locale});
});
/**
* Call a controller action as handler.
* This gets called by `callHandler`.
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 0.2.0
* @version 1.3.0
*
* @param {Conduit} conduit
*/
Route.setMethod(function callController(...args) {
const conduit = args[0];
if (this.controller) {
let instance = Controller.get(this.controller, conduit);
if (!instance) {
return conduit.error(new Error('Could not find controller "' + this.controller + '"'));
}
return instance.doAction(this.action, args);
}
conduit.error(new Error('No valid controller was set'));
});
/**
* Call a controller action as handler, but get info from the url first.
* This gets called by `callHandler`.
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 0.2.0
* @version 1.3.0
*
* @param {Conduit} conduit
*/
Route.setMethod(function callControllerAssignments(...args) {
const conduit = args[0];
if (this.controller) {
// Get the controller name from the route parameters
let controller = this.controller.assign(conduit.params);
// Try getting a controller instance
let instance = Controller.get(controller, conduit);
if (!instance) {
throw new Error('Could not find controller "' + controller + '"');
}
let action = this.action.assign(conduit.params);
return instance.doAction(action, args);
}
throw new Error('No valid controller was set');
});
/**
* Set the values needed for translating this route
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.0
* @version 1.3.0
*
* @param {Controller} controller
* @param {Conduit} conduit
*/
Route.setMethod(function getRouteTranslations(controller, conduit) {
if (!this.has_type_class_checks) {
return;
}
const current_prefix = conduit.prefix,
paths = [];
let path;
for (let key in this.paths) {
if (!key || key == current_prefix) {
continue;
}
path = this.paths[key];
if (!path?.param_definitions?.length) {
continue;
}
paths.push(path);
}
if (!paths.length) {
return;
}
return this._getRouteTranslations(controller, conduit, paths);
});
/**
* Do the actual path value translations
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.0
* @version 1.3.10
*
* @param {Controller} controller
* @param {Conduit} conduit
*/
Route.setMethod(async function _getRouteTranslations(controller, conduit, paths) {
let param_def,
result = {},
path;
// Also add the current prefix
result[conduit.prefix] = alchemy.routeUrl(this.name, conduit.route_string_parameters, {prefix: conduit.prefix});
for (path of paths) {
// Create a shallow clone of the original string parameters
let params = Object.assign({}, conduit.route_string_parameters);
for (param_def of path.param_definitions) {
let current_value = conduit.param(param_def.name),
new_value;
if (current_value && current_value.getTranslatedValueOfFieldForRoute) {
new_value = await current_value.getTranslatedValueOfFieldForRoute(param_def.name, path.prefix);
}
if (!new_value) {
params = false;
break;
}
params[param_def.name] = new_value;
}
if (!params) {
result[path.prefix] = false;
continue;
}
result[path.prefix] = alchemy.routeUrl(this.name, params, {prefix: path.prefix});
}
return result;
});