UNPKG

caccl-api

Version:

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

637 lines (604 loc) 23.6 kB
/** * Functions for interacting with external LTI apps within courses * @namespace api.course.app */ // Import caccl import CACCLError from 'caccl-error'; // Import shared classes import EndpointCategory from '../../shared/EndpointCategory'; // Import shared types import ErrorCode from '../../shared/types/ErrorCode'; import CanvasExternalTool from '../../types/CanvasExternalTool'; import CanvasTab from '../../types/CanvasTab'; import APIConfig from '../../shared/types/APIConfig'; // Import shared constants import API_PREFIX from '../../shared/constants/API_PREFIX'; // Endpoint category class ECatApp extends EndpointCategory { /*------------------------------------------------------------------------*/ /* Endpoints: */ /*------------------------------------------------------------------------*/ /** * Gets the list of apps installed into a course * @author Gabe Abrams * @memberof api.course.app * @instance * @async * @method list * @param {object} [opts] object containing all arguments * @param {number} [opts.courseId=default course id] Canvas course Id to query * @param {boolean} [opts.excludeParents] If true, excludes tools * installed in all accounts above the current context * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasExternalTool[]>} list of external tools {@link https://canvas.instructure.com/doc/api/external_tools.html} */ public async list( opts: { courseId?: number, excludeParents?: boolean, } = {}, config?: APIConfig, ): Promise<CanvasExternalTool[]> { const params = ( opts.excludeParents ? { include_parents: false } : { include_parents: true } ); return this.visitEndpoint({ config, action: 'get the list of apps installed into a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/external_tools`, method: 'GET', params, }); } /** * Gets info on a single LTI tool * @author Gabe Abrams * @memberof api.course.app * @instance * @async * @method get * @param {object} opts object containing all arguments * @param {number} opts.appId The LTI app Id to get * @param {number} [opts.courseId=default course id] Canvas course Id * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasExternalTool>} Canvas external tool {@link https://canvas.instructure.com/doc/api/external_tools.html#method.external_tools.show} */ public async get( opts: { appId: number, courseId?: number, }, config?: APIConfig, ): Promise<CanvasExternalTool> { return this.visitEndpoint({ config, action: 'get info on a specific LTI app in a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/external_tools/${opts.appId}`, method: 'GET', }); } /** * Adds an LTi app by its XML to a Canvas course * @author Gabe Abrams * @memberof api.course.app * @instance * @async * @method addByXML * @param {object} opts object containing all arguments * @param {string} opts.name The app name (for settings app list) * @param {string} opts.key Installation consumer key * @param {string} opts.secret Installation consumer secret * @param {string} opts.xml XML configuration file, standard LTI format * @param {string} opts.description A human-readable description of the * app * @param {number} [opts.courseId=default course id] Canvas course Id to install into * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasExternalTool>} Canvas external tool {@link https://canvas.instructure.com/doc/api/external_tools.html#method.external_tools.show} */ public async addByXML( opts: { name: string, key: string, secret: string, xml: string, courseId?: number, }, config?: APIConfig, ): Promise<CanvasExternalTool> { // Add the app return this.visitEndpoint({ config, action: 'add an LTI app to a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/external_tools`, method: 'POST', params: { name: opts.name, consumer_key: opts.key, shared_secret: opts.secret, config_type: 'by_xml', config_xml: opts.xml, }, }); } /** * Adds an LTi app by its clientId to a Canvas course * @author Gabe Abrams * @memberof api.course.app * @instance * @async * @method addByClientId * @param {object} opts object containing all arguments * @param {string} opts.clientId the client id of the app that is associated * with the Canvas instance containing the course of interest * @param {number} [opts.courseId=default course id] Canvas course Id to install into * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasExternalTool>} Canvas external tool {@link https://canvas.instructure.com/doc/api/external_tools.html#method.external_tools.show} */ public async addByClientId( opts: { clientId: string, courseId?: number, }, config?: APIConfig, ): Promise<CanvasExternalTool> { // Add the app return this.visitEndpoint({ config, action: 'add an LTI app to a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/external_tools`, method: 'POST', params: { client_id: opts.clientId, }, }); } /** * Add a redirect app to the navigation menu * @author Gabe Abrams * @memberof api.course.app * @instance * @async * @method addRedirect * @param {object} opts object containing all arguments * @param {string} opts.name the name of the app as it shows up in the nav * menu * @param {string} opts.url the url to direct the course to when they click the * redirect app * @param {boolean} [opts.hiddenFromStudents] if true, hide the link from * students * @param {boolean} [opts.dontOpenInNewTab] if true, redirect does not open in * another tab * @param {number} [opts.courseId=default course id] Canvas course Id to install into * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasExternalTool>} Canvas external tool {@link https://canvas.instructure.com/doc/api/external_tools.html#method.external_tools.show} */ public async addRedirect( opts: { name: string, url: string, hiddenFromStudents?: boolean, dontOpenInNewTab?: boolean, courseId?: number, }, config?: APIConfig, ): Promise<CanvasExternalTool> { // Generate install XML const xml = ` <cartridge_basiclti_link xmlns="http://www.imsglobal.org/xsd/imslticc_v1p0" xmlns:blti="http://www.imsglobal.org/xsd/imsbasiclti_v1p0" xmlns:lticm="http://www.imsglobal.org/xsd/imslticm_v1p0" xmlns:lticp="http://www.imsglobal.org/xsd/imslticp_v1p0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.imsglobal.org/xsd/imslticc_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticc_v1p0.xsd http://www.imsglobal.org/xsd/imsbasiclti_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imsbasiclti_v1p0p1.xsd http://www.imsglobal.org/xsd/imslticm_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticm_v1p0.xsd http://www.imsglobal.org/xsd/imslticp_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticp_v1p0.xsd"> <blti:title>Redirect Tool</blti:title> <blti:description>Redirect: ${opts.name}</blti:description> <blti:launch_url>https://www.edu-apps.org/redirect</blti:launch_url> <blti:icon>https://www.edu-apps.org/assets/lti_redirect_engine/redirect_icon.png</blti:icon> <blti:custom> <lticm:property name="url">${opts.url}</lticm:property> </blti:custom> <blti:extensions platform="canvas.instructure.com"> <lticm:options name="course_navigation"> <lticm:property name="enabled">true</lticm:property> <lticm:property name="visibility">${opts.hiddenFromStudents ? 'admins' : 'members'}</lticm:property> <lticm:property name="windowTarget">${opts.dontOpenInNewTab ? '_self' : '_blank'}</lticm:property> </lticm:options> <lticm:property name="icon_url">https://www.edu-apps.org/assets/lti_redirect_engine/redirect_icon.png</lticm:property> <lticm:property name="link_text"/> <lticm:property name="privacy_level">anonymous</lticm:property> <lticm:property name="tool_id">redirect ${opts.name}</lticm:property> </blti:extensions> </cartridge_basiclti_link> `; // Add the app return this.api.course.app.addByXML( { name: opts.name, key: 'N/A', secret: 'N/A', xml, }, config, ); } /** * Makes an app visible in the left-hand navigation menu if it was hidden * @author Gabe Abrams * @memberof api.course.app * @instance * @async * @method showInNav * @param {object} opts object containing all arguments * @param {number} opts.appId The LTI app Id to make visible * @param {boolean} [opts.putAtTop] if true, put the app at the top of * the left-hand nav menu * @param {number} [opts.courseId=default course id] Canvas course Id for the * course containing the app * @returns {Promise<CanvasTab>} Canvas tab {@link https://canvas.instructure.com/doc/api/tabs.html} */ public async showInNav( opts: { appId: number, putAtTop?: boolean, courseId?: number, }, config?: APIConfig, ): Promise<CanvasTab> { return this.visitEndpoint({ config, action: 'show an LTI app in the left-hand nav of a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/tabs/context_external_tool_${opts.appId}`, method: 'PUT', params: { hidden: false, position: ( opts.putAtTop ? 2 : undefined ), }, }); } /** * Hides an app from the left-hand navigation menu if it was visible * @author Gabe Abrams * @memberof api.course.app * @instance * @async * @method hideFromNav * @param {object} opts object containing all arguments * @param {number} opts.appId The LTI app Id to hide * @param {number} [opts.courseId=default course id] Canvas course Id for the * course containing the app * @returns {Promise<CanvasTab>} Canvas tab {@link https://canvas.instructure.com/doc/api/tabs.html} */ public async hideFromNav( opts: { appId: number, courseId?: number, }, config?: APIConfig, ): Promise<CanvasTab> { return this.visitEndpoint({ config, action: 'hide an LTI app from the left-hand nav of a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/tabs/context_external_tool_${opts.appId}`, method: 'PUT', params: { hidden: true, }, }); } /** * Removes an LTI app from a Canvas course * @author Gabe Abrams * @memberof api.course.app * @instance * @async * @method remove * @param {object} opts object containing all arguments * @param {number} opts.appId The LTI app Id to remove * @param {number} [opts.courseId=default course id] Canvas course Id to * remove app from * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasExternalTool>} Canvas external tool {@link https://canvas.instructure.com/doc/api/external_tools.html#method.external_tools.show} */ public async remove( opts: { appId: number, courseId?: number, }, config?: APIConfig, ): Promise<CanvasExternalTool> { return this.visitEndpoint({ config, action: 'remove an LTI app from a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/external_tools/${opts.appId}`, method: 'DELETE', }); } /*------------------------------------------------------------------------*/ /* Metadata */ /*------------------------------------------------------------------------*/ /** * Gets the metadata for an LTI app in a course. Note: this endpoint requires * that the app have a custom parameter called 'metadata_id' with an * identifier that we will use to refer to the metadata. If each installation * of an app will have its own metadata, each installation should have a * different metadata_id. If all installations share the same metadata, they * should all have the same metadata_id. When getting metadata, we return the * metadata for the first app we find that has this metadata_id. * Also note that the variable is 'metadata_id' all lowercase because launch * params are made lowercase. * @author Gabe Abrams * @memberof api.course.app * @instance * @async * @method getMetadata * @param {object} opts object containing all arguments * @param {number} opts.metadata_id metadata identifier (see endpoint * description) * @param {number} [opts.courseId=default course id] Canvas course Id that * holds the app * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<object>} the metadata for the first app that has the given * metadata_id */ public async getMetadata( opts: { metadata_id: string, courseId?: number, }, config?: APIConfig, ): Promise<{ [k: string]: any }> { // Get the list of apps const apps = await this.api.course.app.list( { courseId: (opts.courseId ?? this.defaultCourseId), }, config, ); // Find the first app that has this metadata_id let firstAppWithMetadataId; for (let i = 0; i < apps.length; i++) { if ( apps[i].custom_fields && apps[i].custom_fields.metadata_id && apps[i].custom_fields.metadata_id === opts.metadata_id ) { // Found an app with this metadata id! firstAppWithMetadataId = apps[i]; break; } } if (!firstAppWithMetadataId) { // No apps with this metadata_id could be found! Throw error throw new CACCLError({ message: 'We could not find any apps with the given metadata id.', code: ErrorCode.NoAppWithMetadataFound, }); } // Check if metadata is empty if ( !firstAppWithMetadataId.custom_fields.metadata || firstAppWithMetadataId.custom_fields.metadata === '' || firstAppWithMetadataId.custom_fields.metadata.trim().length === 0 ) { // Metadata empty return Promise.resolve({}); } // Parse metadata try { const metadata = JSON.parse( firstAppWithMetadataId.custom_fields.metadata, ); return metadata; } catch (err) { // Metadata malformed throw new CACCLError({ message: 'Metadata was malformed.', code: ErrorCode.MetadataMalformed, }); } } /** * Updates the metadata for an LTI app in a course. Note: this endpoint requires * that the app have a custom parameter called 'metadata_id' with an * identifier that we will use to refer to the metadata. If each installation * of an app will have its own metadata, each installation should have a * different metadata_id. If all installations share the same metadata, they * should all have the same metadata_id. When getting metadata, we return the * metadata for the first app we find that has this metadata_id. * Also note that the variable is 'metadata_id' all lowercase because launch * params are made lowercase. * @author Gabe Abrams * @memberof api.course.app * @instance * @async * @method updateMetadata * @param {object} opts object containing all arguments * @param {number} opts.metadata_id metadata identifier (see endpoint * description) * @param {object} [opts.metadata={}] json metadata object * @param {number} [opts.courseId=default course id] Canvas course Id that holds the app * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasExternalTool[]>} Array of external tools (the apps that were updated) {@link https://canvas.instructure.com/doc/api/external_tools.html#method.external_tools.show} */ public async updateMetadata( opts: { metadata_id: string, metadata?: { [k: string]: any }, courseId?: number, }, config?: APIConfig, ): Promise<CanvasExternalTool[]> { // Pre-process metadata const metadata = JSON.stringify(opts.metadata || {}); // Get the list of apps const apps = await this.api.course.app.list( { courseId: (opts.courseId ?? this.defaultCourseId), }, config, ); // Find all apps with this metadata_id const appsToUpdate = apps.filter((app) => { return ( app.custom_fields && app.custom_fields.metadata_id && app.custom_fields.metadata_id === opts.metadata_id ); }); if (appsToUpdate.length === 0) { // No apps with this metadata_id could be found! Throw arror throw new CACCLError({ message: 'We could not find any apps with the given metadata id.', code: ErrorCode.NoAppsToUpdateMetadata, }); } // Update all app metadata objects in parallel return Promise.all( appsToUpdate.map((app) => { // Perform merge for custom fields so we don't lose other custom vals const params: { [k: string]: any } = { 'custom_fields[metadata]': metadata, }; Object.keys(app.custom_fields).forEach((customPropName) => { // Don't let old metadata overwrite new metadata if (customPropName === 'metadata') { return; } const customVal = app.custom_fields[customPropName]; params[`custom_fields[${customPropName}]`] = customVal; }); // Update custom params return this.visitEndpoint({ config, action: 'update metadata for an LTI app in a course', params, path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/external_tools/${app.id}`, method: 'PUT', }); }), ); } /*------------------------------------------------------------------------*/ /* Sessionless Launch */ /*------------------------------------------------------------------------*/ /** * Gets a sessionless navigation LTI launch URL * @author Gabe Abrams * @memberof api.course.app * @instance * @async * @method getNavLaunchURL * @param {object} opts object containing all arguments * @param {number} opts.appId The LTI app Id to get a launch URL for * @param {number} [opts.courseId=default course id] Canvas course Id that * holds the app * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<string>} launch URL */ public async getNavLaunchURL( opts: { appId: number, courseId?: number, }, config?: APIConfig, ): Promise<string> { const response = await this.visitEndpoint({ config, action: 'get a sessionless navigation LTI launch url for an app in a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/external_tools/sessionless_launch`, method: 'GET', params: { id: opts.appId, }, }); return response.url; } /** * Gets a sessionless navigation LTI launch URL * @author Gabe Abrams * @memberof api.course.app * @instance * @async * @method getAssignmentLaunchURL * @param {object} opts object containing all arguments * @param {number} opts.appId The LTI app Id to get a launch URL for * @param {number} opts.assignmentId the Canvas assignment id to launch * @param {number} [opts.courseId=default course id] Canvas course Id that holds the app * from * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<string>} launch url */ public async getAssignmentLaunchURL( opts: { appId: number, assignmentId: number, courseId?: number, }, config?: APIConfig, ): Promise<string> { const response = await this.visitEndpoint({ config, action: 'get a sessionless assignment LTI launch url for an app in a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/external_tools/sessionless_launch`, method: 'GET', params: { id: opts.appId, assignment_id: opts.assignmentId, launch_type: 'assessment', }, }); return response.url; } /*------------------------------------------------------------------------*/ /* Navigation */ /*------------------------------------------------------------------------*/ /** * Move an app near the top of the nav menu and make sure it's visible * @author Gabe Abrams * @memberof api.course.app * @instance * @async * @method moveToTopOfNavMenu * @param {object} opts object containing all arguments * @param {number} opts.appId The LTI app Id to make visible and move near * the top of the nav menu * @param {number} [opts.courseId=default course id] Canvas course Id that * holds the app * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasTab>} Canvas tab {@link https://canvas.instructure.com/doc/api/tabs.html#Tab} */ public async moveToTopOfNavMenu( opts: { appId: number, courseId?: number, }, config?: APIConfig, ): Promise<CanvasTab> { return this.visitEndpoint({ config, action: 'move an app near the top of the nav menu and make sure it\'s visible', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/tabs/context_external_tool_${opts.appId}`, method: 'PUT', params: { position: 2, hidden: false, }, }); } } /*------------------------------------------------------------------------*/ /* Export */ /*------------------------------------------------------------------------*/ export default ECatApp;