caccl-grade-passback
Version:
Sends LTI 1.1 grade passback to Canvas. Support text and url submissions and overall score.
282 lines (255 loc) • 8.04 kB
text/typescript
// 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 {
// Send request via fetch
const response = await fetch(
outcomeURL,
{
method: 'POST',
headers,
body: xml,
},
);
// Check for a successful response
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// 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.status} error (${err.statusText}).`,
code: ErrorCode.PassbackRequestError,
});
}
};
export default handlePassback;