diff --git a/.travis.yml b/.travis.yml index 96b5dc6..62ded64 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,9 @@ sudo: false language: node_js node_js: - "6" + - "5" + - "4" + - "0.12" - "0.10" before_install: npm install -g grunt-cli install: npm install diff --git a/Gruntfile.js b/Gruntfile.js index d31735a..2bd980d 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -36,6 +36,13 @@ module.exports = function(grunt) { bower; browserFileList.push( + // needed because IE10 doesn't support Uint8ClampedArray + // which is required by CryptoJS for typedarray support + "node_modules/js-polyfills/typedarray.js", + // needed because IE10 doesn't have ArrayBuffer slice + "node_modules/arraybuffer-slice/index.js", + // needed for IE and Safari for TextDecoder/TextEncoder + "node_modules/text-encoding/lib/encoding.js", "src/Environment/Browser.js" ); nodeFileList.push( diff --git a/README.md b/README.md index bb55e48..004fa59 100644 --- a/README.md +++ b/README.md @@ -48,5 +48,19 @@ Environments ------------ Implementing a new Environment should be straightforward and requires overloading a couple -of methods on the `TinCan.LRS` prototype. There are currently two examples, `Environment/Browser` +of methods in the library. There are currently two examples, `Environment/Browser` and `Environment/Node`. + +Attachment Support +------------------ + +Sending and retrieving statements with attachments via the multipart/mixed request/response +cycle works end to end with binary attachments in Node.js 4+ and in the typical modern browsers: +Chrome 53+, Firefox 48+, Safari 9+, IE 10+ (current versions at time of implementation, older versions +may work without changes but have not been tested). Attachments without included content (those using +only the `fileUrl` property) should be supported in all environments supported by the library. + +Several polyfills (TypedArrays, ArrayBuffer w/ slice, Blob, TextDecoder/TextEncoder) are needed +to support various browser versions, if you are targeting a recent enough set of browsers you +can reduce the overall size of the built library by commenting out those polyfills in the +`Gruntfile.js` file and building yourself. diff --git a/package.json b/package.json index 44193a0..a3986f6 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,10 @@ "doc": "doc" }, "dependencies": { - "xhr2": "0.0.7" + "xhr2": "0.0.7", + "js-polyfills": "0.1.22", + "text-encoding": "0.6.0", + "arraybuffer-slice": "0.1.2" }, "author": "Rustici Software ", "contributors": [ diff --git a/src/Attachment.js b/src/Attachment.js index c570aab..98d08e8 100644 --- a/src/Attachment.js +++ b/src/Attachment.js @@ -74,7 +74,7 @@ TinCan client library /** @property content - @type String + @type ArrayBuffer */ this.content = null; @@ -118,9 +118,12 @@ TinCan client library } if (cfg.hasOwnProperty("content") && cfg.content !== null) { - this.content = cfg.content; - this.length = cfg.content.length; - this.sha2 = TinCan.Utils.getSHA256String(cfg.content); + if (typeof cfg.content === "string") { + this.setContentFromString(cfg.content); + } + else { + this.setContent(cfg.content); + } } }, @@ -162,7 +165,37 @@ TinCan client library @method getLangDictionaryValue */ - getLangDictionaryValue: TinCan.Utils.getLangDictionaryValue + getLangDictionaryValue: TinCan.Utils.getLangDictionaryValue, + + /** + @method setContent + @param {ArrayBuffer} content Sets content from ArrayBuffer + */ + setContent: function (content) { + this.content = content; + this.length = content.byteLength; + this.sha2 = TinCan.Utils.getSHA256String(content); + }, + + /** + @method setContentFromString + @param {String} content Sets the content property of the attachment from a string + */ + setContentFromString: function (content) { + var _content = content; + + _content = TinCan.Utils.stringToArrayBuffer(content); + + this.setContent(_content); + }, + + /** + @method getContentAsString + @return {String} Value of content property as a string + */ + getContentAsString: function () { + return TinCan.Utils.stringFromArrayBuffer(this.content); + } }; /** @@ -176,4 +209,6 @@ TinCan client library return new Attachment(_attachment); }; + + Attachment._defaultEncoding = "utf-8"; }()); diff --git a/src/Environment/Browser.js b/src/Environment/Browser.js index 24f8c0f..10eefa7 100644 --- a/src/Environment/Browser.js +++ b/src/Environment/Browser.js @@ -21,14 +21,16 @@ TinCan client library @submodule TinCan.Environment.Browser **/ (function () { - /* globals window, XMLHttpRequest, XDomainRequest */ + /* globals window, XMLHttpRequest, XDomainRequest, Blob */ "use strict"; var LOG_SRC = "Environment.Browser", + requestComplete, + __IEModeConversion, nativeRequest, xdrRequest, - requestComplete, + __createJSONSegment, + __createAttachmentSegment, __delay, - __IEModeConversion, env = {}, log = TinCan.prototype.log; @@ -268,18 +270,35 @@ TinCan client library // http://blogs.msdn.com/b/ie/archive/2006/01/23/516393.aspx // xhr = new ActiveXObject("Microsoft.XMLHTTP"); + + if (cfg.expectMultipart) { + err = new Error("Attachment support not available"); + if (typeof cfg.callback !== "undefined") { + cfg.callback(err, null); + } + return { + err: err, + xhr: null + }; + } } xhr.open(cfg.method, fullUrl, async); + + // + // setting the .responseType before .open was causing IE to fail + // with a StateError, so moved it to here + // + if (cfg.expectMultipart) { + xhr.responseType = "arraybuffer"; + } + for (prop in headers) { if (headers.hasOwnProperty(prop)) { xhr.setRequestHeader(prop, headers[prop]); } } - if (typeof cfg.data !== "undefined") { - cfg.data += ""; - } data = cfg.data; if (async) { @@ -330,6 +349,16 @@ TinCan client library }, err; + if (cfg.expectMultipart) { + err = new Error("Attachment support not available"); + if (typeof cfg.callback !== "undefined") { + cfg.callback(err, null); + } + return { + err: err, + xhr: null + }; + } if (typeof headers["Content-Type"] !== "undefined" && headers["Content-Type"] !== "application/json") { err = new Error("Unsupported content type for IE Mode request"); if (cfg.callback) { @@ -565,4 +594,87 @@ TinCan client library // Synchronous xhr handling is accepted in the browser environment // TinCan.LRS.syncEnabled = true; + + TinCan.LRS.prototype._getMultipartRequestData = function (boundary, jsonContent, requestAttachments) { + var parts = [], + i; + + parts.push( + __createJSONSegment( + boundary, + jsonContent + ) + ); + for (i = 0; i < requestAttachments.length; i += 1) { + if (requestAttachments[i].content !== null) { + parts.push( + __createAttachmentSegment( + boundary, + requestAttachments[i].content, + requestAttachments[i].sha2, + requestAttachments[i].contentType + ) + ); + } + } + parts.push("\r\n--" + boundary + "--\r\n"); + + return new Blob(parts); + }; + + __createJSONSegment = function (boundary, jsonContent) { + var content = [ + "--" + boundary, + "Content-Type: application/json", + "", + JSON.stringify(jsonContent) + ].join("\r\n"); + + content += "\r\n"; + + return content; + }; + + __createAttachmentSegment = function (boundary, content, sha2, contentType) { + var blobParts = [], + header = [ + "--" + boundary, + "Content-Type: " + contentType, + "Content-Transfer-Encoding: binary", + "X-Experience-API-Hash: " + sha2 + ].join("\r\n"); + + header += "\r\n\r\n"; + + blobParts.push(header); + blobParts.push(content); + + return new Blob(blobParts); + }; + + TinCan.Utils.stringToArrayBuffer = function (content, encoding) { + /* global TextEncoder */ + var encoder; + + if (! encoding) { + encoding = TinCan.Utils.defaultEncoding; + } + + encoder = new TextEncoder(encoding); + + return encoder.encode(content).buffer; + }; + + TinCan.Utils.stringFromArrayBuffer = function (content, encoding) { + /* global TextDecoder */ + var decoder; + + if (! encoding) { + encoding = TinCan.Utils.defaultEncoding; + } + + decoder = new TextDecoder(encoding); + + return decoder.decode(content); + }; }()); diff --git a/src/Environment/Node.js b/src/Environment/Node.js index fff80f6..d536358 100644 --- a/src/Environment/Node.js +++ b/src/Environment/Node.js @@ -21,13 +21,15 @@ TinCan client library @submodule TinCan.Environment.Node **/ (function () { - /* globals require */ + /* globals require,Buffer,ArrayBuffer,Uint8Array */ "use strict"; var LOG_SRC = "Environment.Node", log = TinCan.prototype.log, querystring = require("querystring"), XMLHttpRequest = require("xhr2"), - requestComplete; + requestComplete, + __createJSONSegment, + __createAttachmentSegment; requestComplete = function (xhr, cfg) { log("requestComplete - xhr.status: " + xhr.status, LOG_SRC); @@ -95,6 +97,11 @@ TinCan client library xhr = new XMLHttpRequest(); xhr.open(cfg.method, url, async); + + if (cfg.expectMultipart) { + xhr.responseType = "arraybuffer"; + } + for (prop in headers) { if (headers.hasOwnProperty(prop)) { xhr.setRequestHeader(prop, headers[prop]); @@ -127,4 +134,134 @@ TinCan client library // Synchronos xhr handling is unsupported in node // TinCan.LRS.syncEnabled = false; + + TinCan.LRS.prototype._getMultipartRequestData = function (boundary, jsonContent, requestAttachments) { + var parts = [], + i; + + parts.push( + __createJSONSegment( + boundary, + jsonContent + ) + ); + for (i = 0; i < requestAttachments.length; i += 1) { + if (requestAttachments[i].content !== null) { + parts.push( + __createAttachmentSegment( + boundary, + requestAttachments[i].content, + requestAttachments[i].sha2, + requestAttachments[i].contentType + ) + ); + } + } + if (typeof Buffer.from === "undefined") { + parts.push( new Buffer("\r\n--" + boundary + "--\r\n") ); + } + else { + parts.push( Buffer.from("\r\n--" + boundary + "--\r\n") ); + } + + return Buffer.concat(parts); + }; + + __createJSONSegment = function (boundary, jsonContent) { + var content = [ + "--" + boundary, + "Content-Type: application/json", + "", + JSON.stringify(jsonContent) + ].join("\r\n"); + + content += "\r\n"; + + if (typeof Buffer.from === "undefined") { + return new Buffer(content); + } + return Buffer.from(content); + }; + + __createAttachmentSegment = function (boundary, content, sha2, contentType) { + var bufferParts = [], + header = [ + "--" + boundary, + "Content-Type: " + contentType, + "Content-Transfer-Encoding: binary", + "X-Experience-API-Hash: " + sha2 + ].join("\r\n"); + + header += "\r\n\r\n"; + + if (typeof Buffer.from === "undefined") { + bufferParts.push( new Buffer(header) ); + bufferParts.push( new Buffer(content) ); + } + else { + bufferParts.push(Buffer.from(header)); + bufferParts.push(Buffer.from(content)); + } + + return Buffer.concat(bufferParts); + }; + + TinCan.Utils.stringToArrayBuffer = function (content, encoding) { + var b, + ab, + view, + i; + + if (! encoding) { + encoding = TinCan.Utils.defaultEncoding; + } + + if (typeof Buffer.from === "undefined") { + // for Node.js prior to v4.x + b = new Buffer(content, encoding); + + ab = new ArrayBuffer(b.length); + view = new Uint8Array(ab); + for (i = 0; i < b.length; i += 1) { + view[i] = b[i]; + } + + return ab; + } + + b = Buffer.from(content, encoding); + ab = b.buffer; + + // + // this .slice is required because of the internals of how Buffer is + // implemented, it uses a shared ArrayBuffer underneath for small buffers + // see http://stackoverflow.com/a/31394257/1464957 + // + return ab.slice(b.byteOffset, b.byteOffset + b.byteLength); + }; + + TinCan.Utils.stringFromArrayBuffer = function (content, encoding) { + var b, + view, + i; + + if (! encoding) { + encoding = TinCan.Utils.defaultEncoding; + } + + if (typeof Buffer.from === "undefined") { + // for Node.js prior to v4.x + b = new Buffer(content.byteLength); + + view = new Uint8Array(content); + for (i = 0; i < b.length; i += 1) { + b[i] = view[i]; + } + } + else { + b = Buffer.from(content); + } + + return b.toString(encoding); + }; }()); diff --git a/src/LRS.js b/src/LRS.js index f48a4a6..5c12e96 100644 --- a/src/LRS.js +++ b/src/LRS.js @@ -165,29 +165,7 @@ TinCan client library @private */ _getBoundary: function () { - return TinCan.Utils.getUUID().replace(/-/g,""); - }, - - /** - Returns the stringified version, with boundary and content-type, - of a statement to place in the request - - @method _createStatementSegment - @private - */ - _createStatementSegment: function (boundary, statement) { - return "--" + boundary + "\r\n" + "Content-Type: application/json" + "\r\n\r\n" + JSON.stringify(statement) + "\r\n"; - }, - - /** - Returns the stringified version, with boundary and content-type, - of an attachment to place in the request - - @method _createAttachmentSegment - @private - */ - _createAttachmentSegment: function (boundary, content, sha2, contentType) { - return "--" + boundary + "\r\n" + "Content-Type: " + contentType + "\r\n" + "Content-Transfer-Encoding: binary" + "\r\n" + "X-Experience-API-Hash: " + sha2 + "\r\n\r\n" + content + "\r\n"; + return TinCan.Utils.getUUID().replace(/-/g, ""); }, /** @@ -213,6 +191,17 @@ TinCan client library this.log("_makeRequest not overloaded - no environment loaded?"); }, + /** + Method should be overloaded by an environment to do per + environment specifics for building multipart request data + + @method _getMultipartRequestData + @private + */ + _getMultipartRequestData: function () { + this.log("_getMultipartRequestData not overloaded - no environment loaded?"); + }, + /** Method is overloaded by the browser environment in order to test converting an HTTP request that is greater than a defined length @@ -224,6 +213,30 @@ TinCan client library this.log("_IEModeConversion not overloaded - browser environment not loaded."); }, + _processGetStatementResult: function (xhr, params) { + var boundary, + parsedResponse, + statement, + attachmentMap = {}, + i; + + if (! params.attachments) { + return TinCan.Statement.fromJSON(xhr.responseText); + } + + boundary = xhr.getResponseHeader("Content-Type").split("boundary=")[1]; + + parsedResponse = this._parseMultipart(boundary, xhr.response); + statement = JSON.parse(parsedResponse[0].body); + for (i = 1; i < parsedResponse.length; i += 1) { + attachmentMap[parsedResponse[i].headers["X-Experience-API-Hash"]] = parsedResponse[i].body; + } + + this._assignAttachmentContent([statement], attachmentMap); + + return new TinCan.Statement(statement); + }, + /** Method used to send a request via browser objects to the LRS @@ -232,13 +245,14 @@ TinCan client library @param {String} cfg.url URL portion to add to endpoint @param {String} [cfg.method] GET, PUT, POST, etc. @param {Object} [cfg.params] Parameters to set on the querystring - @param {String} [cfg.data] String of body content + @param {String|ArrayBuffer} [cfg.data] Body content as a String or ArrayBuffer @param {Object} [cfg.headers] Additional headers to set in the request @param {Function} [cfg.callback] Function to run at completion @param {String|Null} cfg.callback.err If an error occurred, this parameter will contain the HTTP status code. If the operation succeeded, err will be null. @param {Object} cfg.callback.xhr XHR object @param {Boolean} [cfg.ignore404] Whether 404 status codes should be considered an error + @param {Boolean} [cfg.expectMultipart] Whether to expect the response to be a multipart response @return {Object} XHR if called in a synchronous way (in other words no callback) */ sendRequest: function (cfg) { @@ -342,8 +356,12 @@ TinCan client library */ saveStatement: function (stmt, cfg) { this.log("saveStatement"); - var requestCfg, + var requestCfg = { + url: "statements", + headers: {} + }, versionedStatement, + requestAttachments = [], boundary, i; @@ -376,34 +394,47 @@ TinCan client library }; } - if (versionedStatement.hasOwnProperty("attachments") && versionedStatement.attachments !== null) { + if (versionedStatement.hasOwnProperty("attachments") && stmt.hasAttachmentWithContent()) { boundary = this._getBoundary(); - requestCfg = { - url: "statements", - headers: { - "Content-Type": "application/json" + + requestCfg.headers["Content-Type"] = "multipart/mixed; boundary=" + boundary; + + for (i = 0; i < stmt.attachments.length; i += 1) { + if (stmt.attachments[i].content !== null) { + requestAttachments.push(stmt.attachments[i]); } - }; + } - if (stmt.hasAttachmentWithContent()) { - requestCfg.headers["Content-Type"] = "multipart/mixed; boundary=" + boundary; - requestCfg.data = this._createStatementSegment(boundary, versionedStatement); - for (i = 0; i < stmt.attachments.length; i += 1) { - if (stmt.attachments[i].content !== null) { - requestCfg.data += this._createAttachmentSegment(boundary, stmt.attachments[i].content, versionedStatement.attachments[i].sha2, versionedStatement.attachments[i].contentType); + try { + requestCfg.data = this._getMultipartRequestData(boundary, versionedStatement, requestAttachments); + } + catch (ex) { + if (this.allowFail) { + this.log("[warning] multipart request data could not be created (attachments probably not supported): " + ex); + if (typeof cfg.callback !== "undefined") { + cfg.callback(null, null); + return; } + return { + err: null, + xhr: null + }; } - requestCfg.data += "--" + boundary + "--"; + + this.log("[error] multipart request data could not be created (attachments probably not supported): " + ex); + if (typeof cfg.callback !== "undefined") { + cfg.callback(ex, null); + return; + } + return { + err: ex, + xhr: null + }; } } else { - requestCfg = { - url: "statements", - data: JSON.stringify(versionedStatement), - headers: { - "Content-Type": "application/json" - } - }; + requestCfg.headers["Content-Type"] = "application/json"; + requestCfg.data = JSON.stringify(versionedStatement); } if (stmt.id !== null) { requestCfg.method = "PUT"; @@ -452,32 +483,14 @@ TinCan client library }; if (cfg.params.attachments) { requestCfg.params.attachments = true; + requestCfg.expectMultipart = true; } if (typeof cfg.callback !== "undefined") { callbackWrapper = function (err, xhr) { - var result = xhr, - boundary, - parsedResponse, - statement, - attachmentMap = {}, - i; + var result = xhr; if (err === null) { - if (! cfg.params.attachments) { - result = TinCan.Statement.fromJSON(xhr.responseText); - } - else { - boundary = xhr.getResponseHeader("Content-Type").split("boundary=")[1]; - - parsedResponse = lrs._parseMultipart(boundary, xhr.responseText); - statement = JSON.parse(parsedResponse[0].body); - for (i = 1; i < parsedResponse.length; i += 1) { - attachmentMap[parsedResponse[i].head["X-Experience-API-Hash"]] = parsedResponse[i].body; - } - - lrs._assignAttachmentContent([statement], attachmentMap); - result = new TinCan.Statement(statement); - } + result = lrs._processGetStatementResult(xhr, cfg.params); } cfg.callback(err, result); @@ -489,7 +502,7 @@ TinCan client library if (! callbackWrapper) { requestResult.statement = null; if (requestResult.err === null) { - requestResult.statement = TinCan.Statement.fromJSON(requestResult.xhr.responseText); + requestResult.statement = lrs._processGetStatementResult(requestResult.xhr, cfg.params); } } @@ -502,6 +515,8 @@ TinCan client library @method retrieveVoidedStatement @param {String} ID of voided statement to retrieve @param {Object} [cfg] Configuration options + @param {Object} [cfg.params] Query parameters + @param {Boolean} [cfg.params.attachments] Include attachments in multipart response or don't (default: false) @param {Function} [cfg.callback] Callback to execute on completion @return {TinCan.Statement} Statement retrieved */ @@ -509,9 +524,11 @@ TinCan client library this.log("retrieveVoidedStatement"); var requestCfg, requestResult, - callbackWrapper; + callbackWrapper, + lrs = this; cfg = cfg || {}; + cfg.params = cfg.params || {}; requestCfg = { url: "statements", @@ -523,6 +540,10 @@ TinCan client library } else { requestCfg.params.voidedStatementId = stmtId; + if (cfg.params.attachments) { + requestCfg.params.attachments = true; + requestCfg.expectMultipart = true; + } } if (typeof cfg.callback !== "undefined") { @@ -530,7 +551,7 @@ TinCan client library var result = xhr; if (err === null) { - result = TinCan.Statement.fromJSON(xhr.responseText); + result = lrs._processGetStatementResult(xhr, cfg.params); } cfg.callback(err, result); @@ -542,7 +563,7 @@ TinCan client library if (! callbackWrapper) { requestResult.statement = null; if (requestResult.err === null) { - requestResult.statement = TinCan.Statement.fromJSON(requestResult.xhr.responseText); + requestResult.statement = lrs._processGetStatementResult(requestResult.xhr, cfg.params); } } @@ -560,13 +581,17 @@ TinCan client library */ saveStatements: function (stmts, cfg) { this.log("saveStatements"); - var requestCfg, + var requestCfg = { + url: "statements", + method: "POST", + headers: {} + }, versionedStatement, versionedStatements = [], requestAttachments = [], boundary, i, - x; + j; cfg = cfg || {}; @@ -581,14 +606,6 @@ TinCan client library }; } - requestCfg = { - url: "statements", - method: "POST", - headers: { - "Content-Type": "application/json" - } - }; - for (i = 0; i < stmts.length; i += 1) { try { versionedStatement = stmts[i].asVersion( this.version ); @@ -618,9 +635,9 @@ TinCan client library } if (stmts[i].hasAttachmentWithContent()) { - for (x = 0; x < stmts[i].attachments.length; x += 1) { - if (stmts[i].attachments[x].content !== null) { - requestAttachments.push(stmts[i].attachments[x]); + for (j = 0; j < stmts[i].attachments.length; j += 1) { + if (stmts[i].attachments[j].content !== null) { + requestAttachments.push(stmts[i].attachments[j]); } } } @@ -630,17 +647,38 @@ TinCan client library if (requestAttachments.length !== 0) { boundary = this._getBoundary(); + requestCfg.headers["Content-Type"] = "multipart/mixed; boundary=" + boundary; - requestCfg.data = this._createStatementSegment(boundary, versionedStatements); - for (i = 0; i < requestAttachments.length; i += 1) { - if (requestAttachments[i] !== null) { - requestCfg.data += this._createAttachmentSegment(boundary, requestAttachments[x].content, requestAttachments[x].sha2, requestAttachments[x].contentType); + try { + requestCfg.data = this._getMultipartRequestData(boundary, versionedStatements, requestAttachments); + } + catch (ex) { + if (this.allowFail) { + this.log("[warning] multipart request data could not be created (attachments probably not supported): " + ex); + if (typeof cfg.callback !== "undefined") { + cfg.callback(null, null); + return; + } + return { + err: null, + xhr: null + }; } + + this.log("[error] multipart request data could not be created (attachments probably not supported): " + ex); + if (typeof cfg.callback !== "undefined") { + cfg.callback(ex, null); + return; + } + return { + err: ex, + xhr: null + }; } - requestCfg.data += "--" + boundary + "--"; } else { + requestCfg.headers["Content-Type"] = "application/json"; requestCfg.data = JSON.stringify(versionedStatements); } @@ -699,6 +737,10 @@ TinCan client library // try { requestCfg = this._queryStatementsRequestCfg(cfg); + + if (cfg.params.attachments) { + requestCfg.expectMultipart = true; + } } catch (ex) { this.log("[error] Query statements failed - " + ex); @@ -728,10 +770,10 @@ TinCan client library else { boundary = xhr.getResponseHeader("Content-Type").split("boundary=")[1]; - parsedResponse = lrs._parseMultipart(boundary, xhr.responseText); + parsedResponse = lrs._parseMultipart(boundary, xhr.response); statements = JSON.parse(parsedResponse[0].body); for (i = 1; i < parsedResponse.length; i += 1) { - attachmentMap[parsedResponse[i].head["X-Experience-API-Hash"]] = parsedResponse[i].body; + attachmentMap[parsedResponse[i].headers["X-Experience-API-Hash"]] = parsedResponse[i].body; } lrs._assignAttachmentContent(statements.statements, attachmentMap); @@ -931,39 +973,92 @@ TinCan client library @method _parseMultipart @private @param {String} [boundary] Boundary used to mark off the sections of the response - @param {String} [response] Text of the response + @param {ArrayBuffer} [response] Body of the response @return {Array} Array of objects containing the parsed headers and body of each part */ _parseMultipart: function (boundary, response) { - var parts = [], - sections = [], - head, + /* global Uint8Array */ + var __boundary = "--" + boundary, + byteArray, + bodyEncodedInString, + fullBodyEnd, + sliceStart, + sliceEnd, + headerStart, + headerEnd, + bodyStart, + bodyEnd, + headers, body, - i; + parts = [], + CRLF = 2; - parts = response.split("--" + boundary); - for (i = 0; i < parts.length; i += 1) { - parts[i] = parts[i].replace(/^\s+/g, ""); - if (parts[i] === "") { - continue; - } - else if (parts[i] === "--") { - break; + // + // treating the reponse as a stream of bytes and assuming that headers + // and related mime boundaries are all US-ASCII (which is a safe assumption) + // allows us to treat the whole response as a string when looking for offsets + // but then slice on the raw array buffer + // + byteArray = new Uint8Array(response); + bodyEncodedInString = this.__uint8ToString(byteArray); + + fullBodyEnd = bodyEncodedInString.indexOf(__boundary + "--"); + + sliceStart = bodyEncodedInString.indexOf(__boundary); + while (sliceStart !== -1) { + sliceEnd = bodyEncodedInString.indexOf(__boundary, sliceStart + __boundary.length); + + headerStart = sliceStart + __boundary.length + CRLF; + headerEnd = bodyEncodedInString.indexOf("\r\n\r\n", sliceStart); + bodyStart = headerEnd + CRLF + CRLF; + bodyEnd = sliceEnd - 2; + + headers = this._parseHeaders( + this.__uint8ToString( + new Uint8Array( response.slice(headerStart, headerEnd) ) + ) + ); + body = response.slice(bodyStart, bodyEnd); + + // + // we know the first slice is the statement, and we know it is a string in UTF-8 (spec requirement) + // + if (parts.length === 0) { + body = TinCan.Utils.stringFromArrayBuffer(body); } - parts[i] = parts[i].split("\r\n\r\n", 2); - head = parts[i][0]; - body = parts[i][1]; - body = body.replace(/\r\n$/, ""); - sections.push( + parts.push( { - head: this._parseHeaders(head), + headers: headers, body: body } ); + + if (sliceEnd === fullBodyEnd) { + sliceStart = -1; + } + else { + sliceStart = sliceEnd; + } } - return sections; + return parts; + }, + + // + // implemented as a function to avoid 'RangeError: Maximum call stack size exceeded' + // when calling .fromCharCode on the full byteArray which results in a too long + // argument list for large arrays + // + __uint8ToString: function (byteArray) { + var result = "", + len = byteArray.byteLength, + i; + + for (i = 0; i < len; i += 1) { + result += String.fromCharCode(byteArray[i]); + } + return result; }, /** diff --git a/src/Statement.js b/src/Statement.js index 063aff1..5c4e575 100644 --- a/src/Statement.js +++ b/src/Statement.js @@ -406,14 +406,13 @@ TinCan client library this.log("hasAttachmentWithContent"); var i; - if (! this.hasOwnProperty("attachments")) { + if (this.attachments === null) { return false; } - else { - for (i = 0; i < this.attachments.length; i += 1) { - if (this.attachments[i].content !== null) { - return true; - } + + for (i = 0; i < this.attachments.length; i += 1) { + if (this.attachments[i].content !== null) { + return true; } } diff --git a/src/Utils.js b/src/Utils.js index 623f0d0..6278c03 100644 --- a/src/Utils.js +++ b/src/Utils.js @@ -27,6 +27,8 @@ TinCan client library @class TinCan.Utils */ TinCan.Utils = { + defaultEncoding: "utf8", + /** Generates a UUIDv4 compliant string that should be reasonably unique @@ -195,13 +197,16 @@ TinCan client library /** @method getSHA256String @static - @param {String} str Content to hash + @param {ArrayBuffer|String} content Content to hash @return {String} SHA256 for contents */ - getSHA256String: function (str) { + getSHA256String: function (content) { /*global CryptoJS*/ - return CryptoJS.SHA256(str).toString(CryptoJS.enc.Hex); + if (Object.prototype.toString.call(content) === "[object ArrayBuffer]") { + content = CryptoJS.lib.WordArray.create(content); + } + return CryptoJS.SHA256(content).toString(CryptoJS.enc.Hex); }, /** @@ -375,7 +380,7 @@ TinCan client library /** @method getContentTypeFromHeader @static - @param {String} Content-Type header value + @param {String} header Content-Type header value @return {String} Primary value from Content-Type */ getContentTypeFromHeader: function (header) { @@ -385,11 +390,33 @@ TinCan client library /** @method isApplicationJSON @static - @param {String} Content-Type header value + @param {String} header Content-Type header value @return {Boolean} whether "application/json" was matched */ isApplicationJSON: function (header) { return TinCan.Utils.getContentTypeFromHeader(header).toLowerCase().indexOf("application/json") === 0; + }, + + /** + @method stringToArrayBuffer + @static + @param {String} content String of content to convert to an ArrayBuffer + @param {String} [encoding] Encoding to use for conversion + @return {ArrayBuffer} Converted content + */ + stringToArrayBuffer: function () { + TinCan.prototype.log("stringToArrayBuffer not overloaded - no environment loaded?"); + }, + + /** + @method stringFromArrayBuffer + @static + @param {ArrayBuffer} content ArrayBuffer of content to convert to a String + @param {String} [encoding] Encoding to use for conversion + @return {String} Converted content + */ + stringFromArrayBuffer: function () { + TinCan.prototype.log("stringFromArrayBuffer not overloaded - no environment loaded?"); } }; }()); diff --git a/test/index.html b/test/index.html index a2c6b4a..93bb5d6 100644 --- a/test/index.html +++ b/test/index.html @@ -57,6 +57,7 @@

Single Test Files

Special Conditions

diff --git a/test/js/BrowserPrep.js b/test/js/BrowserPrep.js index aca3448..cf3e632 100644 --- a/test/js/BrowserPrep.js +++ b/test/js/BrowserPrep.js @@ -53,4 +53,22 @@ var TinCanTest, ok(false, desc + " (unrecognized request environment)"); }; + + TinCanTest.loadBinaryFileContents = function (callback) { + var request = new XMLHttpRequest(); + request.open("GET", "files/image.jpg", true); + request.responseType = "arraybuffer"; + + request.onload = function (e) { + if (request.status !== 200) { + throw "Failed to retrieve binary file contents (" + request.status + ")"; + } + fileContents = request.response; + callback.call(null, fileContents); + }; + + request.send(); + }; + + TinCanTest.testAttachments = true; }()); diff --git a/test/js/NodePrep.js b/test/js/NodePrep.js index b1248b6..9977162 100644 --- a/test/js/NodePrep.js +++ b/test/js/NodePrep.js @@ -14,10 +14,40 @@ limitations under the License. */ (function () { - module.exports = { - assertHttpRequestType: function (xhr, name) { - var desc = "(not implemented) assertHttpRequestType: " + name; - ok(true, desc); - } - }; + var fs = require("fs"), + config = { + assertHttpRequestType: function (xhr, name) { + var desc = "(not implemented) assertHttpRequestType: " + name; + ok(true, desc); + }, + loadBinaryFileContents: function (callback) { + fs.readFile( + __dirname + "/../files/image.jpg", + function (err, data) { + var fileContents, + ab, + view, + i; + + if (err) throw err; + + if (typeof data.buffer === "undefined") { + ab = new ArrayBuffer(data.length); + view = new Uint8Array(ab); + for (i = 0; i < data.length; i += 1) { + view[i] = data[i]; + } + fileContents = ab; + } + else { + fileContents = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); + } + callback.call(null, fileContents); + } + ); + }, + testAttachments: true + }; + + module.exports = config; }()); diff --git a/test/js/unit/Attachment.js b/test/js/unit/Attachment.js index 12eff67..7c3b47b 100644 --- a/test/js/unit/Attachment.js +++ b/test/js/unit/Attachment.js @@ -137,12 +137,25 @@ } }, { - name: "Attachment with content", + name: "Attachment with string content", instanceConfig: { content: "test text content" }, checkProps: { - content: "test text content" + sha2: "889f4b4a820461e25c2431acab679831f7eed2fc25f42a809769045527e7a73b", + length: 17, + content: TinCan.Utils.stringToArrayBuffer("test text content") + } + }, + { + name: "Attachment with binary content", + instanceConfig: { + content: TinCan.Utils.stringToArrayBuffer("test text content") + }, + checkProps: { + sha2: "889f4b4a820461e25c2431acab679831f7eed2fc25f42a809769045527e7a73b", + length: 17, + content: TinCan.Utils.stringToArrayBuffer("test text content") } } ], @@ -162,5 +175,5 @@ } } } - ) + ); }()); diff --git a/test/js/unit/Context.js b/test/js/unit/Context.js index b048812..e68e487 100644 --- a/test/js/unit/Context.js +++ b/test/js/unit/Context.js @@ -288,7 +288,7 @@ result = obj.asVersion(); }, Error, - message + " (exception)" + "asVersion throws Error" ); continue; } diff --git a/test/js/unit/LRS.js b/test/js/unit/LRS.js index 0e171ae..ba22870 100644 --- a/test/js/unit/LRS.js +++ b/test/js/unit/LRS.js @@ -214,22 +214,6 @@ ok(noDupe, "no duplicates in 500"); } ); - test( - "_createStatementSegment", - function () { - var testString = "--testBoundary\r\nContent-Type: application/json\r\n\r\n\"test\"\r\n", - lrs = new TinCan.LRS({ endpoint: endpoint }); - deepEqual(lrs._createStatementSegment("testBoundary", "test"), testString, "Statement segment created correctly"); - } - ); - test( - "_createAttachmentSegment", - function () { - var testString = "--testBoundary\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: binary\r\nX-Experience-API-Hash: testHash\r\n\r\ntest\r\n", - lrs = new TinCan.LRS({ endpoint: endpoint }); - deepEqual(lrs._createAttachmentSegment("testBoundary", "test", "testHash", "text/plain"), testString, "Statement segment created correctly"); - } - ); (function () { var versions = TinCan.versions(), @@ -1136,4 +1120,100 @@ } } }()); + + if (TinCanTest.testAttachments) { + (function () { + var versions = TinCan.versions(), + stCfg = { + actor: { + mbox: "mailto:tincanjs-test-tincan+" + Date.now() + "@tincanapi.com" + }, + verb: { + id: "http://adlnet.gov/expapi/verbs/experienced" + }, + target: { + id: "http://tincanapi.com/TinCanJS/Test/TinCan.LRS" + } + }, + fileContents, + testBinaryAttachmentRoundTrip = function (lrs) { + asyncTest( + "Binary Attachment - round trip (" + lrs.version + ")", + function () { + var stCfgAtt = JSON.parse(JSON.stringify(stCfg)), + statement; + + stCfgAtt.target.id = stCfgAtt.target.id + "/binary-attachment-roundtrip/" + lrs.version; + + stCfgAtt.attachments = [ + { + display: { + "en-US": "Test Attachment" + }, + usageType: "http://id.tincanapi.com/attachment/supporting_media", + contentType: "image/jpeg", + content: fileContents + } + ]; + + statement = new TinCan.Statement(stCfgAtt); + + lrs.saveStatement( + statement, + { + callback: function (err, xhr) { + start(); + ok(err === null, "statement saved successfully"); + if (err !== null) { + console.log("save statement failed: " + err); + console.log(xhr.responseText); + } + ok(xhr.status === 204, "xhr received 204"); + stop(); + + lrs.retrieveStatement( + statement.id, + { + params: { + attachments: true + }, + callback: function (err, result) { + start(); + ok(err === null, "statement retrieved successfully"); + ok(statement.attachments[0].sha2 === result.attachments[0].sha2, "re-hash matches original"); + } + } + ); + } + } + ); + } + ); + }; + + QUnit.module( + "LRS - Binary Attachments", + { + setup: function () { + TinCanTest.loadBinaryFileContents( + function (contents) { + fileContents = contents; + start(); + } + ); + stop(); + } + } + ); + + for (i = 0; i < versions.length; i += 1) { + if ((! (versions[i] === "0.9" || versions[i] === "0.95")) && TinCanTestCfg.recordStores[versions[i]]) { + lrs = new TinCan.LRS(TinCanTestCfg.recordStores[versions[i]]); + lrs.allowFail = false; + + testBinaryAttachmentRoundTrip(lrs); + } + } + }()); + } }()); diff --git a/test/js/unit/Statement.js b/test/js/unit/Statement.js index 0659cc7..2bf22af 100644 --- a/test/js/unit/Statement.js +++ b/test/js/unit/Statement.js @@ -210,4 +210,32 @@ } } ); + + test( + "hasAttachmentWithContent", + function () { + var st; + + st = new TinCan.Statement(); + ok(st.hasAttachmentWithContent() === false, "no attachments"); + + st = new TinCan.Statement( + { + attachments: [ + new TinCan.Attachment({ usageType: USAGE_TYPE }) + ] + } + ); + ok(st.hasAttachmentWithContent() === false, "attachment without content"); + + st = new TinCan.Statement( + { + attachments: [ + new TinCan.Attachment({ content: "some content" }) + ] + } + ); + ok(st.hasAttachmentWithContent() === true, "attachment with content"); + } + ); }()); diff --git a/test/js/unit/TinCan-async.js b/test/js/unit/TinCan-async.js index 0ab6ef4..0e12964 100644 --- a/test/js/unit/TinCan-async.js +++ b/test/js/unit/TinCan-async.js @@ -97,6 +97,7 @@ ] } ); + session[v].recordStores[0].allowFail = false; } } }, @@ -398,7 +399,18 @@ // that ought to be tested against a 1.0.0 spec // //actorMbox = "mailto:TinCanJS-test-TinCan+" + Date.now() + "@tincanapi.com"; - actorMbox = "mailto:tincanjs-test-tincan+" + Date.now() + "@tincanapi.com"; + actorMbox = "mailto:tincanjs-test-tincan+" + Date.now() + "@tincanapi.com", + attachment = new TinCan.Attachment( + { + display: { + "en-US": "Test Attachment" + }, + usageType: USAGE_TYPE, + contentType: "text/plain" + } + ); + + attachment.setContentFromString("test content"); sendResult = session[v].sendStatement( { @@ -412,14 +424,7 @@ id: "http://tincanapi.com/TinCanJS/Test/TinCan_getStatementWithAttachment/async/" + v }, attachments: [ - { - display: { - "en-US": "Test Attachment" - }, - usageType: USAGE_TYPE, - content: "test content", - contentType: "text/plain" - } + attachment ] }, function (results, sentStatement) { @@ -464,7 +469,18 @@ // that ought to be tested against a 1.0.0 spec // //actorMbox = "mailto:TinCanJS-test-TinCan+" + Date.now() + "@tincanapi.com"; - actorMbox = "mailto:tincanjs-test-tincan+" + Date.now() + "@tincanapi.com"; + actorMbox = "mailto:tincanjs-test-tincan+" + Date.now() + "@tincanapi.com", + attachment = new TinCan.Attachment( + { + display: { + "en-US": "Test Attachment" + }, + usageType: USAGE_TYPE, + contentType: "text/plain" + } + ); + + attachment.setContentFromString("test content"); sendResult = session[v].sendStatement( { @@ -478,14 +494,7 @@ id: "http://tincanapi.com/TinCanJS/Test/TinCan_getStatementsWithAttachment/async/" + v }, attachments: [ - { - display: { - "en-US": "Test Attachment" - }, - usageType: USAGE_TYPE, - content: "test content", - contentType: "text/plain" - } + attachment ] }, function (results, sentStatement) { @@ -517,9 +526,11 @@ if (version !== "0.9" && version !== "0.95") { doGetStatementsVerbIDAsyncTest(version); doGetStatementsActivityIDAsyncTest(version); - doSendStatementWithAttachmentTest(version); - doGetStatementWithAttachmentTest(version); - doGetStatementsWithAttachmentTest(version); + if (TinCanTest.testAttachments) { + doSendStatementWithAttachmentTest(version); + doGetStatementWithAttachmentTest(version); + doGetStatementsWithAttachmentTest(version); + } } } } diff --git a/test/js/unit/Utils.js b/test/js/unit/Utils.js index dd43989..b26c599 100644 --- a/test/js/unit/Utils.js +++ b/test/js/unit/Utils.js @@ -79,7 +79,14 @@ test( "getSHA256String", function () { - ok(TinCan.Utils.getSHA256String("test") === "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "return value"); + var str = "test", + strAB = TinCan.Utils.stringToArrayBuffer(str); + + ok(TinCan.Utils.getSHA256String(str) === "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "string content return value"); + + if (Object.prototype.toString.call(strAB) === "[object ArrayBuffer]") { + ok(TinCan.Utils.getSHA256String(strAB) === "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "array buffer content return value"); + } } ); test( @@ -389,4 +396,32 @@ } } ); + + test( + "stringToArrayBuffer/stringFromArrayBuffer", + function () { + var str = "string of test content", + bytes = [115, 116, 114, 105, 110, 103, 32, 111, 102, 32, 116, 101, 115, 116, 32, 99, 111, 110, 116, 101, 110, 116], + ab = new ArrayBuffer(str.length), + abView = new Uint8Array(ab); + + if (Object.prototype.toString.call(ab) !== "[object ArrayBuffer]") { + expect(0); + return; + } + + for (i = 0; i < bytes.length; i += 1) { + abView[i] = bytes[i]; + } + + result = TinCan.Utils.stringFromArrayBuffer(ab); + equal(Object.prototype.toString.call(result), "[object String]", "string from array buffer: toString type"); + ok(str === result, "string from array buffer: result"); + + result = TinCan.Utils.stringToArrayBuffer(str); + equal(Object.prototype.toString.call(result), "[object ArrayBuffer]", "string to array buffer: toString type"); + equal(result.byteLength, bytes.length, "string to array buffer: byteLength"); + deepEqual(bytes, Array.prototype.slice.call(new Uint8Array(result)), "string to array buffer: result"); + } + ); }()); diff --git a/test/node-runner.js b/test/node-runner.js index 620289c..a3c2851 100644 --- a/test/node-runner.js +++ b/test/node-runner.js @@ -80,6 +80,9 @@ testRunner.run( }, function (err, report) { if (err) { + if (err instanceof Error) { + throw err; + } throw new Error(err); } if (report.failed > 0) { diff --git a/test/single/Image.html b/test/single/Manual-Image.html similarity index 78% rename from test/single/Image.html rename to test/single/Manual-Image.html index 8577536..7a566cf 100644 --- a/test/single/Image.html +++ b/test/single/Manual-Image.html @@ -33,9 +33,27 @@

Image Test