UNPKG

caccl-grade-passback

Version:

Sends LTI 1.1 grade passback to Canvas. Support text and url submissions and overall score.

201 lines 12.1 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __generator = (this && this.__generator) || function (thisArg, body) { var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; function verb(n) { return function (v) { return step([n, v]); }; } function step(op) { if (f) throw new TypeError("Generator is already executing."); while (g && (g = 0, op[0] && (_ = 0)), _) try { if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; if (y = 0, t) op = [op[0] & 2, t.value]; switch (op[0]) { case 0: case 1: t = op; break; case 4: _.label++; return { value: op[1], done: false }; case 5: _.label++; y = op[1]; op = [0]; continue; case 7: op = _.ops.pop(); _.trys.pop(); continue; default: if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } if (t[2]) _.ops.pop(); _.trys.pop(); continue; } op = body.call(thisArg, _); } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; } }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); // Import libraries var uuid_1 = __importDefault(require("uuid")); var oauth_signature_1 = __importDefault(require("oauth-signature")); var crypto_js_1 = __importDefault(require("crypto-js")); // Import caccl modules var caccl_error_1 = __importDefault(require("caccl-error")); // Import local modules var ErrorCode_1 = __importDefault(require("./shared/types/ErrorCode")); /*------------------------------------------------------------------------*/ /* Helpers */ /*------------------------------------------------------------------------*/ /** * Encodes headers for sending * @author Gabe Abrams * @param str the text of the header to encode * @returns encoded text */ var encode = function (str) { return (encodeURIComponent(str) .replace(/[!'()]/g, escape) .replace(/\*/g, '%2A')); }; /** * Post-processes the text response and preps it for XML * @author Gabe Abrams * @param text the text of the response * @returns the post-processed text */ var postProcessText = function (text) { // Use CDATA and encode newlines for Canvas return "<![CDATA[".concat(text.replace(/\n/g, '<br />'), "]]>"); }; /*------------------------------------------------------------------------*/ /* Main */ /*------------------------------------------------------------------------*/ /** * Submits a grade passback request to Canvas * @author Gabe Abrams * @param opts object containing all arguments * @param opts.request an object containing all the information for the * passback request * @param [opts.request.text] the text of the submission. If this is * included, url cannot be included * @param [opts.request.url] a url to send as the student's submission. * If this is included, text cannot be included * @param [opts.request.score] the student's score on this assignment * @param [opts.request.percent] the student's score as a percent (0-100) * on the assignment * @param [opts.request.submittedAt=now] a timestamp for when the * student submitted the grade. The type must either be a Date object or an * ISO 8601 formatted string * @param opts.info an object containing all LTI info required for the * grade passback process * @param opts.info.sourcedId the LTI sourcedid * @param opts.info.url the LTI outcome service url * @param opts.credentials an object containing the app's credentials * @param opts.credentials.consumerKey the app's consumer key * @param opts.credentials.consumerSecret the app's consumer secret * @returns true if the request was successful */ var handlePassback = function (opts) { return __awaiter(void 0, void 0, void 0, function () { var request, info, credentials, submissionType, submission, submittedAt, score, percent, sourcedId, outcomeURL, consumerKey, consumerSecret, scoreBlock, percentBlock, subBlock, timestampBlock, xml, oauthNonce, oauthTimestamp, bodyHash, oauthHeaders, oauthSignature, headers, response, err_1; return __generator(this, function (_a) { switch (_a.label) { case 0: request = opts.request, info = opts.info, credentials = opts.credentials; /* --------------- Pre-processing and Verification -------------- */ // Enforce constraints if (request.text && request.url) { throw new caccl_error_1.default({ message: 'We could not send a grade passback to Canvas because both a text and url submission were included (only one is allowed).', code: ErrorCode_1.default.TooManySubmissionValues, }); } if (request.score && request.percent) { throw new caccl_error_1.default({ message: 'We could not send a grade passback to Canvas because both a score and grade percent were included (only one is allowed).', code: ErrorCode_1.default.TooManyScores, }); } if (request.text) { submissionType = 'text'; } else if (request.url) { submissionType = 'url'; } submission = (request.text || request.url); if (request.submittedAt) { submittedAt = (typeof submittedAt === 'string' ? submittedAt : submittedAt.toISOString()); } score = request.score, percent = request.percent; sourcedId = info.sourcedId; outcomeURL = info.url; consumerKey = credentials.consumerKey, consumerSecret = credentials.consumerSecret; scoreBlock = ''; if (score) { scoreBlock = ("\n <resultTotalScore>\n <language>en</language>\n <textString>".concat(score, "</textString>\n </resultTotalScore>")); } percentBlock = ''; if (percent) { percentBlock = ("\n <resultScore>\n <language>en</language>\n <textString>".concat(percent / 100, "</textString>\n </resultScore>")); } subBlock = ''; if (submissionType) { subBlock = (submissionType === 'url' ? ("\n <resultData>\n <url>".concat(submission, "</url>\n </resultData>")) : ("\n <resultData>\n <text>".concat(postProcessText(submission), "</text>\n </resultData>"))); } timestampBlock = (submittedAt ? ("\n <submissionDetails>\n <submittedAt>\n ".concat(submittedAt, "\n </submittedAt>\n </submissionDetails>")) : ''); xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<imsx_POXEnvelopeRequest xmlns=\"http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0\">\n <imsx_POXHeader>\n <imsx_POXRequestHeaderInfo>\n <imsx_version>V1.0</imsx_version>\n <imsx_messageIdentifier>".concat(uuid_1.default.v1(), "</imsx_messageIdentifier>\n </imsx_POXRequestHeaderInfo>\n </imsx_POXHeader>\n <imsx_POXBody>\n <replaceResultRequest>").concat(timestampBlock, "\n <resultRecord>\n <sourcedGUID>\n <sourcedId>").concat(sourcedId, "</sourcedId>\n </sourcedGUID>\n <result>").concat(percentBlock).concat(scoreBlock).concat(subBlock, "\n </result>\n </resultRecord>\n </replaceResultRequest>\n </imsx_POXBody>\n</imsx_POXEnvelopeRequest>"); oauthNonce = uuid_1.default.v4(); oauthTimestamp = Math.round(Date.now() / 1000); bodyHash = crypto_js_1.default.SHA1(xml).toString(crypto_js_1.default.enc.Base64); oauthHeaders = { oauth_version: '1.0', oauth_nonce: oauthNonce, oauth_timestamp: oauthTimestamp, oauth_consumer_key: consumerKey, oauth_body_hash: bodyHash, oauth_signature_method: 'HMAC-SHA1', }; oauthSignature = (oauth_signature_1.default.generate('POST', outcomeURL, oauthHeaders, consumerSecret)); headers = { Authorization: "OAuth realm=\"\",oauth_version=\"1.0\",oauth_nonce=\"".concat(encode(oauthNonce), "\",oauth_timestamp=\"").concat(encode(String(oauthTimestamp)), "\",oauth_consumer_key=\"").concat(encode(consumerKey), "\",oauth_body_hash=\"").concat(encode(bodyHash), "\",oauth_signature_method=\"HMAC-SHA1\",oauth_signature=\"").concat(oauthSignature, "\""), 'Content-Type': 'application/xml', 'Content-Length': String(xml.length), }; _a.label = 1; case 1: _a.trys.push([1, 3, , 4]); return [4 /*yield*/, fetch(outcomeURL, { method: 'POST', headers: headers, body: xml, })]; case 2: response = _a.sent(); // Check for a successful response if (!response.ok) { throw new Error("HTTP ".concat(response.status, ": ").concat(response.statusText)); } // Success! Resolve with true return [2 /*return*/, true]; case 3: err_1 = _a.sent(); // Failure! Throw an error throw new caccl_error_1.default({ message: "We could not pass submission data back to Canvas because we encountered a ".concat(err_1.status, " error (").concat(err_1.statusText, ")."), code: ErrorCode_1.default.PassbackRequestError, }); case 4: return [2 /*return*/]; } }); }); }; exports.default = handlePassback; //# sourceMappingURL=index.js.map