UNPKG

caccl-grade-passback

Version:

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

276 lines (250 loc) 7.86 kB
// Import libraries import uuid from 'uuid'; import oauth from 'oauth-signature'; import CryptoJS from 'crypto-js'; // Import caccl modules import CACCLError from 'caccl-error'; // Import local modules import ErrorCode from './shared/types/ErrorCode'; /*------------------------------------------------------------------------*/ /* Helpers */ /*------------------------------------------------------------------------*/ /** * Encodes headers for sending * @author Gabe Abrams * @param str the text of the header to encode * @returns encoded text */ const encode = (str: string): string => { 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 */ const postProcessText = (text: string): string => { // Use CDATA and encode newlines for Canvas return `<![CDATA[${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 */ const handlePassback = async ( opts: { request: { text?: string, url?: string, score?: number, percent?: number, submittedAt?: (Date | string), }, info: { sourcedId: string, url: string, }, credentials: { consumerKey: string, consumerSecret: string, }, }, ): Promise<boolean> => { const { request, info, credentials, } = opts; /* --------------- Pre-processing and Verification -------------- */ // Enforce constraints if (request.text && request.url) { throw new CACCLError({ 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.TooManySubmissionValues, }); } if (request.score && request.percent) { throw new CACCLError({ 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.TooManyScores, }); } // Determine the submission type and extract the submission let submissionType; if (request.text) { submissionType = 'text'; } else if (request.url) { submissionType = 'url'; } const submission = (request.text || request.url); // > Format the submittedAt timestamp let submittedAt; if (request.submittedAt) { submittedAt = ( typeof submittedAt === 'string' ? submittedAt : (submittedAt as any).toISOString() ); } // Extract score const { score, percent } = request; // Deconstruct info const { sourcedId } = info; const outcomeURL = info.url; // Deconstruct credentials const { consumerKey, consumerSecret } = credentials; /* ------------------------ XML Building ------------------------ */ // Score let scoreBlock = ''; if (score) { scoreBlock = ( ` <resultTotalScore> <language>en</language> <textString>${score}</textString> </resultTotalScore>` ); } // Percent of grade let percentBlock = ''; if (percent) { percentBlock = ( ` <resultScore> <language>en</language> <textString>${percent / 100}</textString> </resultScore>` ); } // Submission let subBlock = ''; if (submissionType) { subBlock = ( submissionType === 'url' ? ( ` <resultData> <url>${submission}</url> </resultData>` ) : ( ` <resultData> <text>${postProcessText(submission)}</text> </resultData>` ) ); } // Timestamp const timestampBlock = ( submittedAt ? ( ` <submissionDetails> <submittedAt> ${submittedAt} </submittedAt> </submissionDetails>` ) : '' ); // Put all the pieces together into one XML const xml = `<?xml version="1.0" encoding="UTF-8"?> <imsx_POXEnvelopeRequest xmlns="http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"> <imsx_POXHeader> <imsx_POXRequestHeaderInfo> <imsx_version>V1.0</imsx_version> <imsx_messageIdentifier>${uuid.v1()}</imsx_messageIdentifier> </imsx_POXRequestHeaderInfo> </imsx_POXHeader> <imsx_POXBody> <replaceResultRequest>${timestampBlock} <resultRecord> <sourcedGUID> <sourcedId>${sourcedId}</sourcedId> </sourcedGUID> <result>${percentBlock}${scoreBlock}${subBlock} </result> </resultRecord> </replaceResultRequest> </imsx_POXBody> </imsx_POXEnvelopeRequest>`; /* ---------------------- Sign the Request ---------------------- */ // Build the oauth headers const oauthNonce = uuid.v4(); const oauthTimestamp = Math.round(Date.now() / 1000); const bodyHash = CryptoJS.SHA1(xml).toString(CryptoJS.enc.Base64); // Put oauth headers into one object const oauthHeaders = { oauth_version: '1.0', oauth_nonce: oauthNonce, oauth_timestamp: oauthTimestamp, oauth_consumer_key: consumerKey, oauth_body_hash: bodyHash, oauth_signature_method: 'HMAC-SHA1', }; // Sign the oauth headers const oauthSignature = ( oauth.generate( 'POST', outcomeURL, oauthHeaders, consumerSecret ) ); // Create request headers const headers = { Authorization: `OAuth realm="",oauth_version="1.0",oauth_nonce="${encode(oauthNonce)}",oauth_timestamp="${encode(String(oauthTimestamp))}",oauth_consumer_key="${encode(consumerKey)}",oauth_body_hash="${encode(bodyHash)}",oauth_signature_method="HMAC-SHA1",oauth_signature="${oauthSignature}"`, 'Content-Type': 'application/xml', 'Content-Length': String(xml.length), }; // Send the request to Canvas try { await fetch( outcomeURL, { method: 'POST', headers, body: xml, }, ); // Success! Resolve with true return true; } catch (err) { // Failure! Throw an error throw new CACCLError({ message: `We could not pass submission data back to Canvas because we encountered a ${err.response.status} error (${err.response.statusText}).`, code: ErrorCode.PassbackRequestError, }); } }; export default handlePassback;