caccl-grade-passback
Version:
Sends LTI 1.1 grade passback to Canvas. Support text and url submissions and overall score.
197 lines • 11.8 kB
JavaScript
;
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;
return g = { next: verb(0), "throw": verb(1), "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, 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:
_a.sent();
// 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.response.status, " error (").concat(err_1.response.statusText, ")."),
code: ErrorCode_1.default.PassbackRequestError,
});
case 4: return [2 /*return*/];
}
});
}); };
exports.default = handlePassback;
//# sourceMappingURL=index.js.map