Source: maltypart.js

/**	Maltypart is a simple multipart builder.
 *	It is very similar to HTML5 FormData, but offers a bit more control over fields and serialization.
 *
 *	@example
 *	var RequestBody = require('maltypart').RequestBody;
 *
 *	var request = new RequestBody();
 *
 *	request.append({
 *		// String values get serialized with the default content-type, application/octet-stream:
 *		some_key : "some value",
 *
 *		// Specify an Object with "contentType" and "data" properties to set your own content-type.
 *		otherKey : {
 *			contentType : "image/png",
 *			data : "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAABlBMVEUAAAD"
 *					+ "///+l2Z/dAAAAM0lEQVR4nGP4/5/h/1+G/58ZDrAz3D/McH8yw83NDDe"
 *					+ "NGe4Ug9C9zwz3gVLMDA/A6P9/AFGGFyjOXZtQAAAAAElFTkSuQmCC"
 *		}
 *	});
 *
 *	request.append('some-file', new File(), function() {
 *		// file added.
 *
 *		// Log the body as text
 *		console.log( request.toString() );
 *
 *		// Send the multipart body with XMLHttpRequest:
 *		xhr.setRequestHeader('Content-Type', request.getContentType());
 *		xhr.send( request.getData() );
 *	});
 */
(function(root, factory) {
	if (typeof define==='function' && define.amd) {
		define([], factory);
	}
	else if (typeof module==='object' && module.exports) {
		module.exports = factory();
	}
	else {
		root.maltypart = factory();
	}
}(this, function() {
	/**	@private */
	var count=0, p;

	/**	Represents a multipart message body to be serialized.
	 *	@class
	 *	@memberOf module:maltypart
	 *	@param {Object|Array} fields		A key-value Object map of fields to append
	 *	@param {String} [boundary=random]	Override the default MIME part boundary
	 *	@param {String} [callback]			If set, invoked after appending `fields`
	 */
	function RequestBody(fields, boundary, callback) {
		this.fields = {};
		if (typeof boundary==='function') {
			callback = boundary;
			boundary = null;
		}
		if (fields) {
			this.append(fields, callback);
		}
		this.setBoundary(boundary);
	}

	RequestBody.prototype = {

		/** @ignore */
		constructor : RequestBody,

		/** @private */
		availableTypes : ['multipart', 'form-encoded'],

		/** Override the automatic `mutlipart`/`form-encoded` detection.
		 *	@memberOf module:maltypart.RequestBody#
		 *	@param {String} type		One of: `multipart` or `form-encoded`.
		 *	@returns {this}
		 */
		setType : function(type) {
			this.typeOverride = this.availableTypes.indexOf(type)!==-1 ? type : null;
			return this;
		},

		/** Returns a `String` indicating if the RequestBody will be serialized as `multipart`, or `form-encoded`.
		 *	If no fields have been added that have a `contentType`, the request will be serialized as `form-encoded`.
		 *	@memberOf module:maltypart.RequestBody#
		 *	@returns {String} type
		 */
		getType : function() {
			var override = this.typeOverride;
			if (this.availableTypes.indexOf(override)!==-1) {
				return override;
			}
			for (var i in this.fields) {
				if (this.fields.hasOwnProperty(i) && typeof this.fields[i] && this.fields[i].contentType) {
					return 'multipart';
				}
			}
			return 'form-encoded';
		},

		/** Append one or more fields to the RequestBody.
		 *	If a `fields` is a `string`, this method acts as an alias of {@link module:maltypart.RequestBody#setField}.
		 *	@memberOf module:maltypart.RequestBody#
		 *	@param {Array|Object} fields		An Array of objects with `name` and `value` properties, or a key-value Object.
		 *	@param {Boolean} [replace=true]		By default, any existing field with the same name is replaced. If `replace` is set to `false`, existing values will be left in-place, and the new value will be appended. This results in duplicate keys (which can be useful!).
		 *	@returns {this}
		 */
		append : function(fields, replace, callback) {
			var name, val, i, pending, cb;
			if (typeof fields==='string' && arguments.length>=2) {
				return this.setField.apply(this, arguments);
			}

			if (typeof replace==='function') {
				i = callback;
				callback = replace;
				replace = i;
			}

			if (typeof callback==='function') {
				pending = 1;
				cb = function() {
					if (!--pending) callback();
				};
			}

			if (isArray(fields)) {
				for (i=0; i<fields.length; i++) {
					val = fields[i];
					pending++;
					this.setField(val.name, val.value, replace!==false, null, cb);
				}
			}
			else {
				for (name in fields) {
					if (fields.hasOwnProperty(name)) {
						val = fields[name];
						if (isArray(val)) {
							for (i=0; i<val.length; i++) {
								pending++;
								this.setField(name, val[i], replace===true, null, cb);
							}
						}
						else {
							pending++;
							this.setField(name, val, replace!==false, null, cb);
						}
					}
				}
			}

			if (pending) cb();

			return this;
		},

		/** Set the value of a new or existing field.
		 *	@memberOf module:maltypart.RequestBody#
		 *	@param {String} name				A field name with which to associate the specified value
		 *	@param {String|module:maltypart.RequestField|Element|File} value	Any of: a {@link module:maltypart.RequestField RequestField}, a simple String value, an HTML5 [File](https://developer.mozilla.org/en/docs/Web/API/File) object, or any form input Element.
		*	@param {Boolean} [replace=true]		By default, any existing field with the same name is replaced. If `replace` is set to `false`, existing values will be left in-place, and the new value will be appended. This results in duplicate keys (which can be useful!).
		*	@param {Object} [headers]			If set, headers will be added to the field
		 *	@returns {this}
		 */
		setField : function(name, value, replace, headers, callback) {
			var fields = this.fields;
			name = name + '';
			if ((window.File && value instanceof window.File) || (window.Blob && value instanceof window.Blob)) {
				return this.setFileField.apply(this, arguments);
			}
			if (typeof replace==='function') {
				callback = replace;
				replace = true;
			}
			if (value && value.nodeType && value.nodeName && value.getAttribute) {
				value = value.value;
			}
			else if (!isArray(value) && (!value || !(value instanceof RequestField || (value.contentType && value.data)))) {
				value = value + '';
			}
			if (name.match(/\[\]$/g)) {
				replace = false;
			}
			if (replace===false && fields.hasOwnProperty(name)) {
				if (!isArray(fields[name])) {
					fields[name] = [fields[name]];
				}
				fields[name].push(value);
			}
			else {
				fields[name] = value;
			}
			if (typeof callback==='function') {
				callback();
			}
			return this;
		},

		/** Add an HTML5 File object to the request body
		 *	@memberOf module:maltypart.RequestBody#
		 *	@param {String} name			A field name with which to associate the acquired field value
		 *	@param {File} file				An HTML5 [File](https://developer.mozilla.org/en/docs/Web/API/File) object.
		 *	@param {Function} [callback]	A callback to call once the file has been fully added.
		 *	@returns {this}
		 */
		setFileField : function(name, file, replace, headers, callback) {
			var self = this,
				reader = new FileReader();
			if (typeof replace==='function') {
				callback = replace;
				replace = true;
			}
			reader.onloadend = function() {
				self.setField(name, new RequestField(
					arrayBufferToBinaryString(reader.result),
					file.type,
					file.name,
					headers || file.headers
				), replace!==false);
				if (typeof callback==='function') {
					callback();
				}
				file = callback = self = reader = null;
			};
			reader.readAsArrayBuffer(file);
			return this;
		},

		/** Set a custom "boundary" String to use when separating multipart fields.
		 *	@memberOf module:maltypart.RequestBody#
		 *	@param {String} [boundary=rnd()]	A custom boundary. If omitted, generates a new random boundary.
		 *	@returns {this}
		 */
		setBoundary : function(boundary) {
			this.boundary = (boundary || rnd()) + '';
			return this;
		},

		/** Get the request body as a String.
		 *	@memberOf module:maltypart.RequestBody#
		 *	@param {Object} [fields]	Optionally add fields prior to serialization
		 *	@returns {String} encodedMultipartBody
		 */
		toString : function(fields) {
			var bodyType = this.getType(),
				body = '',
				iter, name, value, type, item, v, i, headers, p;

			if (!this.boundary) {
				this.setBoundary();
			}

			if (fields) {
				this.append(fields);
			}

			for (name in this.fields) {
				if (this.fields.hasOwnProperty(name)) {
					value = this.fields[name];
					iter = isArray(value) ? value : [value];
					for (i=0; i<iter.length; i++) {
						v = iter[i];
						if (bodyType==='multipart') {
							headers = v.headers;
							//type = 'application/octet-stream';
							type = null;
							if (v.contentType) {
								type = v.contentType;
								v = v.data;
							}
							item = '--' + this.boundary + '\r\n' +
								'Content-Disposition: form-data; name="' + name + '"';
							if (iter[i].filename) {
								item += '; filename="' + iter[i].filename + '"';
							}
							item += '\r\n';
							if (type) {
								item += 'Content-type: ' + type + '\r\n';
							}
							if (headers) {
								for (p in headers) {
									if (headers.hasOwnProperty(p)) {
										item += p + ': ' + headers[p] + '\r\n';
									}
								}
							}
							item += '\r\n' + v + '\r\n';
						}
						else if (bodyType==='form-encoded') {
							item = encodeURIComponent(name) + '=' + encodeURIComponent(v) + '&';
						}
						body += item;
					}
				}
			}

			if (bodyType==='multipart') {
				body += '--' + this.boundary + '--\r\n';
			}
			else if (bodyType==='form-encoded') {
				body = body.substring(0, body.length-1);
			}
			return body;
		},

		/** Get the String or Uint8Array multipart request body.
		 *	@memberOf module:maltypart.RequestBody#
		 *	@returns {String|Uint8Array} data
		 */
		getData : function() {
			var str = this.toString(),
				len, buf;
			if (this.getType()==='multipart') {
				len = str.length;
				buf = new Uint8Array(len);
				for (var i=0; i<len; i++) {
					buf[i] = str.charCodeAt(i) & 0xff;
				}
				return buf;
			}
			return str;
		},

		/** Returns the appropriate Content-Type header value for this request body.
		 *	@memberOf module:maltypart.RequestBody#
		 *	@param {Boolean} [includeCharset=false]		If `true`, specifies a character set within the content-type.
		 *	@returns {String} contentType
		 */
		getContentType : function(includeCharset) {
			var type = this.getType();
			if (type==='multipart') {
				return 'multipart/form-data; ' + (includeCharset ? 'charset=utf-8; ' : '') + 'boundary=' + this.boundary;
			}
			return 'application/x-www-form-encoded';
		}
	};



	/**	Represents a content field to be added to a request.
	 *	@class
	 *	@memberOf module:maltypart
	 *	@param {Any} data				The binary or textual value of the field
	 *	@param {String} contentType		Content-Type header for the field
	 *	@param {String} [filename]		A filename to attribute to the field when serializing
	 *	@param {Object} [headers]		Extra headers to append to the field when serializing
	 */
	function RequestField(data, contentType, filename, headers) {
		/** The binary or textual value of the field
		 *	@instance @any
		 */
		this.data = data;

		/** Content-Type header for the field
		 *	@instance @string
		 */
		this.contentType = contentType;

		/** A filename to attribute to the field when serializing
		 *	@instance @string
		 */
		this.filename = filename;

		/** Extra headers to append to the field when serializing
		 *	@instance @object
		 */
		this.headers = headers;
	}


	/** @ignore */
	function isArray(arr) {
		return Object.prototype.toString.call(arr)==='[object Array]';
	}

	/** @ignore */
	function rnd() {
		return Math.random().toString(36).substring(2) + (++count).toString(36);
	}

	/** @ignore */
	function arrayBufferToBinaryString(buffer) {
		var bin   = '';
		var bytes = new Uint8Array(buffer);

		for (var i = 0; i < bytes.byteLength; i++)
			bin += String.fromCharCode(bytes[i]);

		return bin;
	}


	return {
		RequestBody : RequestBody,
		RequestField : RequestField
	};
}));
Menu