All Downloads are FREE. Search and download functionalities are using the official Maven repository.

META-INF.dirigible.http.v4.rs.js Maven / Gradle / Ivy

/*
 * Copyright (c) 2022 SAP SE or an SAP affiliate company and Eclipse Dirigible contributors
 *
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v2.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v20.html
 *
 * SPDX-FileCopyrightText: 2022 SAP SE or an SAP affiliate company and Eclipse Dirigible contributors
 * SPDX-License-Identifier: EPL-2.0
 */
/**
 * @module http/v4/rs
 * @example
 * ```js
 * var rs = require("http/v4/rs")
 * ```
 */

/************************************
 *   ResourceMappings builder API   *
 ************************************/

/**
 * Compares two arrays for equality by inspecting if they are arrays, refer to the same instance,
 * have same length and contain equal components in the same order.
 *
 * @param {array} source The source array to compare to
 * @param {array} target The target array to compare with
 * @return {Boolean} true if the arrays are equal, false otherwise
 * @private
 */
var arrayEquals = function(source, target){
	if(source===target)
		return true;
	if(!Array.isArray(source) || !Array.isArray(source))
		return false;
	if(source!==undefined && target===undefined || source===undefined && target!==undefined)
		return false;
	if(source.length !== target.length)
		return false;
	for(var i=0; i 1 ? handlers : handlers[0];

};

var buildMethod = function(sMethodName, args){
	if(args.length>0){
		if(typeof args[0] === 'function')
			return this.method(sMethodName).serve(args[0]);
		else if(typeof args[0] === 'object')
			return this.method(sMethodName, args[0]);
		else
			throw Error('Invalid argument: Resource.' + sMethodName + ' method first argument must be valid javascript function or configuration object, but instead is ' + (typeof args[0]) + ' ' + args[0]);
	} else {
		return this.method(sMethodName);
	}
	return;
};

/**
 * Creates a handling specification for the HTTP method "GET".
 *
 * Same as invoking method("get") on a resource.
 *
 * @param {Function|Object} [fServeCb|oConfiguration] serve function callback or oConfiguraiton to initilaize the method
 */
Resource.prototype.get = function(){
	return buildMethod.call(this, 'get', arguments);
};
/**
 * Creates a handling specification for the HTTP method "POST".
 *
 * Same as invoking method("post") on a resource.
 *
 * @param {Function|Object} [fServeCb|oConfiguration] serve function callback or oConfiguraiton to initilaize the method
 */
Resource.prototype.post = function(){
	return buildMethod.call(this, 'post', arguments);
};
/**
 * Creates a handling specification for the HTTP method "PUT".
 *
 * Same as invoking method("put") on a resource.
 *
 * @param {Function|Object} [fServeCb|oConfiguration] serve function callback or oConfiguraiton to initilaize the method
 */
Resource.prototype.put = function(){
	return buildMethod.call(this, 'put', arguments);
};
/**
 * Creates a handling specification for the HTTP method "DELETE".
 *
 * Same as invoking method("delete") on a resource.
 *
 * @param {Function|Object} [fServeCb|oConfiguration] serve function callback or oConfiguraiton to initilaize the method
 */
Resource.prototype["delete"] = Resource.prototype.remove = function(){
	return buildMethod.call(this, 'delete', arguments);
};

/**
 * Finds a ResourceMethod with the given constraints.
 *
 * @param {String} sMethod the name of the method property of the ResourceMethod in search
 * @param {Array} arrConsumesMimeTypeStrings the consumes constraint property of the ResourceMethod in search
 * @param {Array} arrProducesMimeTypeStrings the produces constraint property of the ResourceMethod in search
 */
Resource.prototype.find = function(sVerb, arrConsumesMimeTypeStrings, arrProducesMimeTypeStrings){
	let hit;
	Object.keys(this.cfg).filter(function(sVerbName){
		return sVerb === undefined || (sVerb!==undefined && sVerb === sVerbName);
	}).forEach(function(sVerbName){
		this.cfg[sVerbName].forEach(function(verbHandlerSpec){
			if(arrayEquals(verbHandlerSpec.consumes, arrConsumesMimeTypeStrings) && arrayEquals(verbHandlerSpec.produces, arrProducesMimeTypeStrings)){
				hit  =  new ResourceMethod(verbHandlerSpec, this.controller, this, this.mappings);
				return;
			}
		});
		if(hit)
			return;
	}.bind(this));
	return hit;
};

/**
 * Returns the configuration of this resource.
 *
 */
Resource.prototype.configuration = function(){
	return this.cfg;
};

/**
 * Instructs redirection of the request base don the parameter. If it is a stirng representing URI, the request will be
 * redirected to this URI for any method. If it's a function it will be invoked and epxected to return a URI string to redirect to.
 *
 * @param {Function|String}
 */
Resource.prototype.redirect = function(fRedirector){
	if(typeof fRedirector === 'string'){
		fRedirector = function(){
			return fRedirector;
		}
	}
	return handlerFunction.apply(this, ['redirect', fRedirector]);
};

/**
 * Disables the ResourceMethods that match the given constraints
 */
Resource.prototype.disable = function(sVerb, arrConsumesTypeStrings, arrProducesTypeStrings){
	Object.keys(this.cfg).filter(function(sVerbName){
		return !(sVerb === undefined || (sVerb!==undefined && sVerb === sVerbName));
	}).forEach(function(sVerbName){
		this.cfg[sVerbName].forEach(function(verbHandlerSpec, i, verbHandlerSpecs){
			if(arrayEquals(verbHandlerSpec.consumes, arrConsumesTypeStrings) && arrayEquals(verbHandlerSpec.produces, arrProducesTypeStrings))
				verbHandlerSpecs.splice(i, 1);
		});
	});
	return this;
};

/**
 * Disables all but 'read' HTTP methods in this resource.
 */
Resource.prototype.readonly = function(){
	Object.keys(this.cfg).forEach(function(method){
		if(['get','head','trace'].indexOf(method)<0)
			delete this.cfg[method];
	}.bind(this));
	return this;
};


/****************************
 *   ResourceMappings API   *
 ****************************/


/**
 * Constructor function for ResourceMappings instances.
 * A ResourceMapping abstracts the mappings between resource URL path templates and their corresponding resource handler
 * specifications. Generally, it's used internally by the HttpController exposed by the service factory function adn it is
 * where all settings provided by the fluent API ultimately end up. Another utilization of it is as initial configuration,
 * which is less error prone and config changes-friendly than constructing JSON manually for the same purpose.
 *
 * @class
 * @param {Object} [oConfiguration]
 * @param {HttpController} [controller] The controller instance, for which this ResourceMappings handles configuration
 * @returns {ResourceMappings}
 * @static
 */
var ResourceMappings = exports.ResourceMappings = function(oConfiguration, controller){
	this.resources = {};
	if(oConfiguration){
		Object.keys(oConfiguration).forEach(function(sPath){
			this.resources[sPath] = this.resource(sPath, oConfiguration[sPath], controller);
		}.bind(this));
	}

	if(controller){
		this.controller = controller;
		this.execute = controller.execute.bind(controller);
	}
};

/**
 * Creates new Resource object. The second, optional argument can be used to initialize the resource prior to manipulating it.
 *
 * @param {String} sPath
 * @param {Object} [oConfiguration]
 *
 * @returns {Resource}
 */
ResourceMappings.prototype.path = ResourceMappings.prototype.resourcePath = ResourceMappings.prototype.resource = function(sPath, oConfiguration){
	if(this.resources[sPath] === undefined)
		this.resources[sPath] = new Resource(sPath, oConfiguration, this.controller, this);
	return this.resources[sPath];
};

/**
 * Returns the configuration object for this ResourceMappings.
 */
ResourceMappings.prototype.configuration = function(){
	const _cfg = {};
	Object.keys(this.resources).forEach(function(sPath){
		_cfg[sPath] = this.resources[sPath].configuration();
	}.bind(this));
	return _cfg;
};

/**
 * Removes all but GET resource handlers.
 */
ResourceMappings.prototype.readonly = function(){
	Object.keys(this.resources).forEach(function(sPath){
		this.resources[sPath].readonly();
	}.bind(this));
	return this;
};

/**
 * Disables resource handling specifications mathcing the arguments, effectively removing them from this API.
 */
ResourceMappings.prototype.disable = function(sPath, sVerb, arrConsumes, arrProduces){
	Object.keys(this.resources[sPath]).forEach(function(resource){
		resource.disable(sVerb, arrConsumes, arrProduces);
	}.bind(this));
	return this;
};

/**
 * Provides a reference to a handler specification matching the supplied arguments.
 */
ResourceMappings.prototype.find = function(sPath, sVerb, arrConsumes, arrProduces){
	if(this.resources[sPath]){
		const hit = this.resources[sPath].find(sVerb, arrConsumes, arrProduces);
		if(hit)
			return hit;
	}
	return;
};



/**************************
 *   HttpController API   *
 **************************/

/**
 * Constructor function for HttpController instances.
 *
 * @class
 * @param {ResourceMappings|Object} [oMappings] the mappings configuration for this controller.
 *
 */
var HttpController = exports.HttpController = function(oMappings){
	this.logger = require('log/logging').getLogger('http.rs.controller');
	//var xss = require("utils/xss");

	const self = this;

	const matchRequestUrl = function (requestPath, method, cfg) {
		var pathDefs = Object.keys(cfg);
		var matches = [];
		for (var i = 0; i < pathDefs.length; i++) {
			var pathDef = pathDefs[i];
			var resolvedPath;
			if (pathDef === requestPath) {
				resolvedPath = pathDef;
				matches.push({w: 1, p: resolvedPath, d: pathDef});
			} else {
				var pathDefSegments = pathDef.split('/');
				var reqPathSegments;
				if (requestPath.trim().length > 0)
					reqPathSegments = requestPath.split('/');
				else
					reqPathSegments = [];
				if (pathDefSegments.length === reqPathSegments.length) {
					var verbHandlers = Object.keys(cfg[pathDef]);
					if (verbHandlers && verbHandlers.length > 0 && verbHandlers.indexOf(method) > -1) {
						var pathParams = {};
						var resolvedPathDefSegments = pathDefSegments.map(function (pSeg, i) {
							pSeg = pSeg.trim();
							var matcher = pSeg.match(/{(.*?)}/);
							if (matcher !== null) {
								var param = matcher[1];
								pathParams[param] = reqPathSegments[i];
								return reqPathSegments[i];
							} else {
								return pSeg;
							}
						});
						var p = resolvedPathDefSegments.join('/');
						if (p === requestPath) {
							resolvedPath = p;
							var match = {w: 0, p: resolvedPath, d: pathDef};
							if (Object.keys(pathParams).length > 0) {
								match.pathParams = pathParams;
							}
							matches.push(match);
						}
					}
				}
			}
		}
		//sort matches by weight
		matches = matches.sort(function (p, n) {
			if (n.w === p.w) {
				//the one with less placeholders wins
				var m1 = p.d.match(/{(.*?)}/g);
				var placeholdersCount1 = m1 !== null ? m1.length : 0;
				var m2 = n.d.match(/{(.*?)}/g);
				var placeholdersCount2 = m2 !== null ? m2.length : 0;
				if (placeholdersCount1 > placeholdersCount2) {
					n.w = n.w + 1;
				} else if (placeholdersCount1 < placeholdersCount2) {
					p.w = p.w + 1;
				}
			}
			return n.w - p.w;
		});
		return matches;
	};

	//  content-type, consumes
	//  accepts, produces
	const isMimeTypeCompatible = this.isMimeTypeCompatible = function (source, target) {
		if (source === target)
			return true;
		var targetM = target.split('/');
		var sourceM = source.split('/');
		if ((targetM[0] === '*' && targetM[1] === sourceM[1]) || (source[0] === '*' && targetM[1] === sourceM[1]))
			return true;
		if ((targetM[1] === '*' && targetM[0] === sourceM[0]) || (sourceM[1] === '*' && targetM[0] === sourceM[0]))
			return true;
	};

	const normalizeMediaTypeHeaderValue = this.normalizeMediaTypeHeaderValue = function (sMediaType) {
		if (sMediaType === undefined || sMediaType === null)
			return;
		sMediaType = sMediaType.split(',');//convert to array
		sMediaType = sMediaType.map(function (mimeTypeEntry) {
			return mimeTypeEntry.replace('\\', '').split(';')[0].trim();//remove escaping, remove quality or other atributes
		});
		return sMediaType;
	};

	//find MIME types intersections
	const matchMediaType = function (request, producesMediaTypes, consumesMediaTypes) {
		var isProduceMatched = false;
		var acceptsMediaTypes = normalizeMediaTypeHeaderValue(request.getHeader('Accept'));
		if (!acceptsMediaTypes || acceptsMediaTypes.indexOf('*/*') > -1) { //output media type is not restricted
			isProduceMatched = true;
		} else {
			var matchedProducesMIME;
			if (producesMediaTypes && producesMediaTypes.length) {
				matchedProducesMIME = acceptsMediaTypes.filter(function (acceptsMediaType) {
					return producesMediaTypes.filter(function (producesMediaType) {
						return isMimeTypeCompatible(acceptsMediaType, producesMediaType)
					}).length > 0;
				});
				isProduceMatched = matchedProducesMIME && matchedProducesMIME.length > 0;
			}
		}

		var isConsumeMatched = false;
		var contentTypeMediaTypes = normalizeMediaTypeHeaderValue(request.getContentType());
		if (!consumesMediaTypes || consumesMediaTypes.indexOf('*') > -1) { //input media type is not restricted
			isConsumeMatched = true;
		} else {
			var matchedConsumesMIME;
			if (contentTypeMediaTypes && consumesMediaTypes && consumesMediaTypes.length) {
				matchedConsumesMIME = contentTypeMediaTypes.filter(function (contentTypeMediaType) {
					return consumesMediaTypes.filter(function (consumesMediaType) {
						return isMimeTypeCompatible(contentTypeMediaType, consumesMediaType);
					}).length > 0;
				});
				isConsumeMatched = matchedConsumesMIME && matchedConsumesMIME.length > 0;
			}
		}
		return isProduceMatched && isConsumeMatched;
	};

	const catchErrorHandler = function (logctx, ctx, err, request, response) {
		if (ctx.suppressStack) {
			var detailsMsg = (ctx.errorName || "") + (ctx.errorCode ? " [" + ctx.errorCode + "]" : "") + (ctx.errorMessage ? ": " + ctx.errorMessage : "");
			this.logger.info('Serving resource[{}], Verb[{}], Content-Type[{}], Accept[{}] finished in error. {}', logctx.path, logctx.method, logctx.contentType, logctx.accepts, detailsMsg);
		} else
			this.logger.error('Serving resource[' + logctx.path + '], Verb[' + logctx.method + '], Content-Type[' + logctx.contentType + '], Accept[' + logctx.accepts + '] finished in error', err);

		var httpErrorCode = ctx.httpErrorCode || response.INTERNAL_SERVER_ERROR;
		var errorMessage = ctx.errorMessage || (err && err.message);
		var errorName = ctx.errorName || (err && err.name);
		var errorCode = ctx.errorCode;
		this.sendError(httpErrorCode, errorCode, errorName, errorMessage);
	};

	this.execute = function(request, response){
  		request = request || require("http/v4/request");
		var requestPath = request.getResourcePath();
		var method = request.getMethod().toLowerCase();
		var _oConfiguration = self.resourceMappings.configuration();

		var matches = matchRequestUrl(requestPath, method, _oConfiguration);
		var resourceHandler;
		if(matches && matches[0]){
			var verbHandlers = _oConfiguration[matches[0].d][method];
			if(verbHandlers){
				resourceHandler = verbHandlers.filter(function(handlerDef){
					return matchMediaType(request, handlerDef.produces, handlerDef.consumes);
				})[0];
			}
		}

		response = response || require("http/v4/response");
		const queryParams = request.getQueryParametersMap() || {};
		const acceptsHeader = normalizeMediaTypeHeaderValue(request.getHeader('Accept')) || '[]';
		const contentTypeHeader = normalizeMediaTypeHeaderValue(request.getHeader('Content-Type')) || '[]';
		const resourcePath = requestPath;

		if(resourceHandler){
			const ctx = {
				"pathParameters": {},
				"queryParameters": {}
			};
			if(matches[0].pathParams){
				ctx.pathParameters =  matches[0].pathParams;
			}
			ctx.queryParameters = queryParams;

			const noop = function () {
			};
			let _before, _serve, _catch, _finally;
			_before = resourceHandler.before || noop;
			_serve = resourceHandler.handler || resourceHandler.serve || noop;
			_catch = resourceHandler.catch || catchErrorHandler.bind(self, {
				path: resourcePath,
				method: method.toUpperCase(),
				contentType: contentTypeHeader,
				accepts: acceptsHeader
			});
			_finally = resourceHandler.finally || noop;
			const callbackArgs = [ctx, request, response, resourceHandler, this];
			try{
		 		self.logger.trace('Before serving request for Resource[{}], Method[{}], Content-Type[{}], Accept[{}]', resourcePath, method.toUpperCase(), contentTypeHeader, acceptsHeader);
				_before.apply(self, callbackArgs);
				if(!response.isCommitted()){
					self.logger.trace('Serving request for Resource[{}], Method[{}], Content-Type[{}], Accept[{}]', resourcePath, method.toUpperCase(), contentTypeHeader, acceptsHeader);
					_serve.apply(this, callbackArgs);
					self.logger.trace('Serving request for Resource[{}], Method[{}], Content-Type[{}], Accept[{}] finished', resourcePath, method.toUpperCase(), contentTypeHeader, acceptsHeader);
				}
			} catch(err){
				try{
					callbackArgs.splice(1, 0, err);
					_catch.apply(self, callbackArgs);
				} catch(_catchErr){
					self.logger.error('Serving request for Resource[{}], Method[{}], Content-Type[{}], Accept[{}] error handler threw error', _catchErr);
					throw _catchErr;
				}
			} finally{
				HttpController.prototype.closeResponse.call(this);
				try{
					_finally.apply(self, []);
				} catch(_finallyErr){
					self.logger.error('Serving request for Resource[{}], Method[{}], Content-Type[{}], Accept[{}] post handler threw error', _finallyErr);
				}
			}
		} else {
			self.logger.error('No suitable resource handler for Resource[' + resourcePath + '], Method['+method.toUpperCase()+'], Content-Type['+contentTypeHeader+'], Accept['+acceptsHeader+'] found');
			self.sendError(response.BAD_REQUEST, undefined, 'Bad Request', 'No suitable processor for this request.');
		}
  	};


	if(oMappings instanceof ResourceMappings){
		this.resourceMappings = oMappings;
	} else if(typeof oMappings === 'object' || 'undefined') {
		 this.resourceMappings = new ResourceMappings(oMappings, this);
	}

	this.resource = this.resourcePath = this.resourceMappings.resourcePath.bind(this.resourceMappings);

	//weave-in HTTP method-based factory functions - shortcut for service().resource(sPath).method
	['get','post','put','delete','remove','method'].forEach(function(sMethodName){
			this[sMethodName] = function(sPath, sVerb, arrConsumes, arrProduces){
				if(arguments.length < 1)
					throw Error('Insufficient arguments provided to HttpController method ' + sMethodName + '.');
				if(sPath === undefined)
					sPath = "";
				const resource = this.resourceMappings.find(sPath, sVerb, arrConsumes, arrProduces) || this.resourceMappings.resource(sPath);
				resource[sMethodName]['apply'](resource, Array.prototype.slice.call(arguments, 1));
				return this;
			}.bind(this);
		}.bind(this));

};

HttpController.prototype.mappings = function() {
	return this.resourceMappings;
};

HttpController.prototype.sendError = function(httpErrorCode, applicationErrorCode, errorName, errorDetails) {
	const request = require("http/v4/request");
	const clientAcceptMediaTypes = this.normalizeMediaTypeHeaderValue(request.getHeader('Accept')) || ['application/json'];
	const isHtml = clientAcceptMediaTypes.some(function (acceptMediaType) {
		return this.isMimeTypeCompatible('*/html', acceptMediaType);
	}.bind(this));
	const response = require("http/v4/response");
	response.setStatus(httpErrorCode || response.INTERNAL_SERVER_ERROR);
	if(isHtml){
		const message = errorName + (applicationErrorCode !== undefined ? '[' + applicationErrorCode + ']' : '') + (errorDetails ? ': ' + errorDetails : '');
		response.sendError(httpErrorCode || response.INTERNAL_SERVER_ERROR, message);
	} else {
		const body = {
			"code": applicationErrorCode,
			"error": errorName,
			"details": errorDetails
		};
		response.setHeader("Content-Type", "application/json");
	    response.print(JSON.stringify(body, null, 2));
	}
	this.closeResponse();
};

HttpController.prototype.closeResponse = function(){
	const response = require("http/v4/response");
	response.flush();
	response.close();
};




/****************************
 *   http/v4/rs Module API   *
 ****************************/

/**
 * Creates a service, optionally initialized with oMappings
 *
 * @param {Object|ResourceMappings} [oMappings] configuration object or configuration builder with configuration() getter function
 *
 */
exports.service = function(oConfig){
	let config;
	if(oConfig!==undefined){
		if(typeof oConfig === 'object' || oConfig instanceof ResourceMappings){
			config = oConfig;
		} else {
			throw Error('Illegal argument type: oConfig['+(typeof oConfig)+']');
		}
	}
	return new HttpController(config);
};




© 2015 - 2025 Weber Informatics LLC | Privacy Policy