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

http.v3.rs.js Maven / Gradle / Ivy

/*
 * Copyright (c) 2010-2019 SAP and others.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 *
 * Contributors:
 *   SAP - initial API and implementation
 */
/**
 * @module http/v3/rs
 * @example
 * ```js
 * var rs = require("http/v3/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){
	var 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(){
	var _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]){
		var 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");
	
	var self =this;
	
	var matchRequestUrl = function(requestPath, method, cfg){
		var pathDefs = Object.keys(cfg);
		var matches = [];
		for(var i=0; i0)
					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
	var 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;		
	};
	
	var 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
	var 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;
	};
	
	var 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/v3/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/v3/response");
		var queryParams = request.getQueryParametersMap() || {};		
		var acceptsHeader = normalizeMediaTypeHeaderValue(request.getHeader('Accept')) || '[]';
		var contentTypeHeader = normalizeMediaTypeHeaderValue(request.getHeader('Content-Type')) || '[]';
		var resourcePath = requestPath;
		
		if(resourceHandler){
			var ctx = {
				"pathParameters": {},
				"queryParameters": {}
			};
			if(matches[0].pathParams){
				ctx.pathParameters =  matches[0].pathParams;
			}
			ctx.queryParameters = queryParams;
						
			var noop = function(){};
			var _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;
			var 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 = "";
				var 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) {
	var request = require("http/v3/request");
	var clientAcceptMediaTypes = this.normalizeMediaTypeHeaderValue(request.getHeader('Accept')) || ['application/json'];
	var isHtml = clientAcceptMediaTypes.some(function(acceptMediaType){
					return this.isMimeTypeCompatible( '*/html', acceptMediaType);
				}.bind(this));
	var response = require("http/v3/response");				
	response.setStatus(httpErrorCode || response.INTERNAL_SERVER_ERROR);
	if(isHtml){
		var message = errorName + (applicationErrorCode!==undefined ? '['+applicationErrorCode+']' : '') + (errorDetails ? ': ' + errorDetails : '');
		response.sendError(httpErrorCode || response.INTERNAL_SERVER_ERROR, message);
	} else {
	    var 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(){
	var response = require("http/v3/response");
	response.flush();
	response.close();
};




/****************************
 *   http/v3/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){
	var config;
	if(oConfig!==undefined){
		if(typeof oConfig === 'object' || oConfig instanceof ResourceMappings){
			config = oConfig;
		} else {
			throw Error('Illegal argument type: oConfig['+(typeof oConfig)+']');
		}
	}
	var controller = new HttpController(config);
	return controller;
};




© 2015 - 2025 Weber Informatics LLC | Privacy Policy