UNPKG

caccl-api

Version:

A class that defines a set of smart Canvas endpoints that actually behave how you'd expect them to.

375 lines (336 loc) 12.3 kB
// Import clone import clone from 'fast-clone'; // Import CACCL libs import sendRequest from 'caccl-send-request'; import CACCLError from 'caccl-error'; // Import shared types import SharedArgs from '../types/APIConfig'; import ErrorCode from '../types/ErrorCode'; // Import helpers import interpretCanvasError from './interpretCanvasError'; import VisitEndpointFunc from '../types/VisitEndpointFunc'; import removeUndefinedValues from './removeUndefinedValues'; /** * Generate a visitEndpoint function * @param defaults defaults to use when visiting endpoints * @returns visitEndpoint function */ const genVisitEndpoint = (defaults: SharedArgs) => { /** * Visit a Canvas endpoint * @author Gabe Abrams * @param opts visit endpoint arguments (see shared type) * @returns response from Canvas */ const visitEndpoint: VisitEndpointFunc = async (opts) => { /*----------------------------------------*/ /* Destructure Args */ /*----------------------------------------*/ const { path, config = {}, method = 'GET', action = 'interact with Canvas', params = {}, pagePostProcessor, } = opts; /*----------------------------------------*/ /* Determine Config */ /*----------------------------------------*/ // Create params const updatedParams: { [k: string]: any } = removeUndefinedValues({ ...params, // Canvas access token access_token: ( params.accessToken || params.access_token || config.accessToken || defaults.accessToken ), // Authenticity token authenticity_token: ( params.authenticityToken || params.authenticity_token || config.authenticityToken || defaults.authenticityToken ), // Items per page per_page: ( method === 'GET' ? ( params.per_page || params.perPage || config.itemsPerPage || defaults.itemsPerPage ) : undefined ), }); // Get settings const canvasHost = (config.canvasHost ?? defaults.canvasHost); const numRetries = (config.numRetries ?? defaults.numRetries); const maxPages = (config.maxPages ?? defaults.maxPages); const pathPrefix = (config.pathPrefix ?? defaults.pathPrefix ?? ''); const { onNewPage } = config; /*----------------------------------------*/ /* Fetch Pages */ /*----------------------------------------*/ /** * Helper to fetch one page from Canvas * @author Gabe Abrams * @param pageNumber the number of the page being fetched (1-indexed) * @param [pageBookmark] the bookmark for the next page (if applicable) * @returns { page, nextPageBookmark } */ const fetchPage = async ( pageNumber: number, pageBookmark?: string, ): Promise<( // If no next page | { page: any; nextPageNumber: undefined, nextPageBookmark?: undefined, } // If next page exists | { page: any, nextPageNumber: number, nextPageBookmark?: string, } )> => { // Add page bookmark if there is one let updatedParamsWithBookmark = updatedParams; if (pageBookmark || pageNumber > 1) { // Clone params to avoid mutating original updatedParamsWithBookmark = clone(updatedParams); updatedParamsWithBookmark.page = ( pageBookmark ? `bookmark:${pageBookmark}` : pageNumber ); } // Send the request try { const response = await sendRequest({ method, numRetries, params: updatedParamsWithBookmark, path: `${pathPrefix}${path}`, host: canvasHost, }); /*----------------------------------------*/ /* Handle request failures */ /*----------------------------------------*/ // 404 - endpoint not found if (response.status === 404) { throw new CACCLError({ message: `The endpoint ${(canvasHost ? `https://${canvasHost}` : '')}${path} does not exist: Canvas responded with a 404 message. Please check your endpoint path.`, code: ErrorCode.NotFound, }); } // 400 - Invalid syntax if (response.status === 400) { // Terms only in root accounts if ( response.body.message && response.body.message.includes('Terms only belong to root_accounts') ) { throw new CACCLError({ message: 'We could not look up the list of terms because terms only belong to root accounts and this is not a root account.', code: ErrorCode.TermsOnlyInRootAccounts, }); } // Invalid tab location if (response.body.error && response.body.error === 'That tab location is invalid') { throw new CACCLError({ message: 'The requested tab location is invalid.', code: ErrorCode.InvalidTabLocation, }); } // Compile errors into string let errors: (undefined | string); try { const parsed = JSON.parse(response.body); (parsed.errors || [parsed.message]).forEach((err: any) => { if (!errors) { errors = ''; } else { errors += ', '; } errors += String(err).split(':')[0]; }); errors += '.'; } catch (err) { errors = 'unknown (could not parse Canvas response)'; } // Reject with our generated error throw new CACCLError({ message: `The endpoint https://${canvasHost}${path} or params are invalid. Canvas responded with a 400 message (invalid syntax): ${errors}`, code: ErrorCode.InvalidSyntax, }); } // Parse body (if it's not already parsed) let parsedBody: any; if (response.body && typeof response.body !== 'string') { // Body isn't a string. Assume it's already parsed parsedBody = response.body; } else { // Attempt to parse body try { parsedBody = JSON.parse(response.body); } catch (err) { throw new CACCLError({ message: 'We couldn\'t understand Canvas\'s response because it was malformed. Please contact an admin if this continues to occur.', code: ErrorCode.Malformed, }); } } // Check for a Canvas error const canvasError = interpretCanvasError(parsedBody, response.status); if (canvasError) { // We got an error. Reject! throw canvasError; } // Post-process the body if (pagePostProcessor) { parsedBody = pagePostProcessor(parsedBody, pageNumber); } // Page is valid! const page = parsedBody; // Send notifications if (onNewPage) { onNewPage(parsedBody, pageNumber); } // Check for next page let nextPageBookmark: string | undefined; let hasNextPage = false; try { const { link } = response.headers; // Go through all links and see if there's a next page // Example: // <https://canvas.harvard.edu/api/v1/courses/53450/users?page=bookmark:Acnbawijeflksdifhadnfkie>; rel="next", // <https://canvas.harvard.edu/api/v1/courses/53450/users?page=bookmark:fbjsodifgoirughudhfiuahs>; rel="first", // <https://canvas.harvard.edu/api/v1/courses/53450/users?page=bookmark:vgsdgfyweHDFShiudfhiause>; rel="last", // or // <https://canvas.harvard.edu/api/v1/courses/53450/users?page=2>; rel="next", // <https://canvas.harvard.edu/api/v1/courses/53450/users?page=1>; rel="first", // <https://canvas.harvard.edu/api/v1/courses/53450/users?page=7>; rel="last", const links = String(link ?? '').split(','); const nextPageLink = links.find((linkPart) => { return ( // This is the "next" link linkPart .toLowerCase() .trim() .endsWith('rel="next"') // The link exists && linkPart.split(';')[0].length > 2 ); }); // Extract next page bookmark if it exists hasNextPage = !!nextPageLink; if (hasNextPage) { // Get URL from link const urlPart = nextPageLink.split(';')[0].trim(); const url = urlPart.substring(1, urlPart.length - 1); // Remove < and > // Parse URL const urlObj = new URL(url); const urlParams = urlObj.searchParams; // Check if using a bookmark const pageParam = urlParams.get('page') || ''; const isBookmark = pageParam.startsWith('bookmark:'); if (isBookmark) { // Get bookmark nextPageBookmark = urlParams.get('page')?.replace('bookmark:', '') || undefined; } else { // Not using bookmark - no bookmark to provide nextPageBookmark = undefined; } } } catch (err) { nextPageBookmark = undefined; } // Return data if (!hasNextPage) { return { page, nextPageNumber: undefined, nextPageBookmark: undefined, }; } // Return data with page info return { page, nextPageNumber: (pageNumber || 1) + 1, nextPageBookmark, }; } catch (err) { // Turn into CACCLError if not already let newError = err; if (!err.isCACCLError) { newError = new CACCLError(err); newError.code = ErrorCode.UnnamedEndpointError; } // Add on action to the error if (newError.message.startsWith('While attempting to ')) { // There's already an action. Add an umbrella action const newUmbrella = ` (in order to ${action})`; // Check to see if an umbrella action has already been added const currUmbrella = newError.message.match(/\(in order to .*\)/g); if (currUmbrella && currUmbrella.length > 0) { // Another umbrella action already exists. Replace it newError.message = newError.message.replace( currUmbrella[0], newUmbrella, ); } else { const parts = newError.message.split(','); parts[0] += newUmbrella; newError.message = parts.join(','); } } else { newError.message = `While attempting to ${action}, we ran into an error: ${(err.message || 'unknown')}`; } throw newError; } }; // Iteratively get pages const pages: any[] = []; let nextPageNumber = 1; let nextPageBookmark: string | undefined; while (nextPageNumber === 1 || nextPageBookmark) { // Fetch the page const pageResults = await fetchPage( nextPageNumber, nextPageBookmark, ); // Extract the page const { page } = pageResults; // Add the page to the list pages.push(page); // Prepare for next page const allowedToFetchAnotherPage = (!maxPages || pages.length < maxPages); const anotherPageExists = !!pageResults.nextPageBookmark; if (anotherPageExists && allowedToFetchAnotherPage) { // Getting next page nextPageNumber = pageResults.nextPageNumber; nextPageBookmark = pageResults.nextPageBookmark; } else { // Not getting next page nextPageBookmark = undefined; nextPageNumber = undefined; } } // We don't need to fetch any more pages. Wrap up. // Concatenate pages if necessary const allData = ( pages.length === 1 ? pages[0] : [].concat(...pages) ); return allData; }; return visitEndpoint; }; export default genVisitEndpoint;