UNPKG

ltijs

Version:

Easily turn your web application into a LTI 1.3 Learning Tool.

224 lines • 13.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Grading = void 0; const id_token_constants_1 = require("#services/launch/id-token.constants"); const lti_scopes_constants_1 = require("#shared/lti-scopes.constants"); const authorization_header_1 = require("#utils/request/authorization-header"); const link_header_1 = require("#utils/request/link-header"); const validation_1 = require("#utils/validation/validation"); const grading_schemas_1 = require("#services/grading/grading.schemas"); const grading_serializer_1 = require("#services/grading/grading.serializer"); const errors_1 = require("#services/grading/errors"); const errors_2 = require("#shared/errors"); class Grading { LOG_COMPONENT = 'gradingService'; LINEITEM_CONTAINER_ACCEPT = 'application/vnd.ims.lis.v2.lineitemcontainer+json'; LINEITEM_MEDIA_TYPE = 'application/vnd.ims.lis.v2.lineitem+json'; RESULT_CONTAINER_ACCEPT = 'application/vnd.ims.lis.v2.resultcontainer+json'; SCORE_CONTENT_TYPE = 'application/vnd.ims.lis.v1.score+json'; RESOURCE_LINK_ID_PARAM = 'resource_link_id'; RESOURCE_ID_PARAM = 'resource_id'; TAG_PARAM = 'tag'; LIMIT_PARAM = 'limit'; USER_ID_PARAM = 'user_id'; launchContext; accessTokenManager; requestHandler; logger; constructor(launchContext, accessTokenManager, requestHandler, logger) { this.launchContext = launchContext; this.accessTokenManager = accessTokenManager; this.requestHandler = requestHandler; this.logger = logger; } /** Whether this launch declared AGS support. Check before calling any other method on this service. */ isAvailable() { return this.launchContext.idToken.services.assignmentAndGrades.available; } async getLineItems(options) { this.ensureServiceAvailability(); const { platform, rawIdToken: idToken } = this.launchContext; if (options?.url === undefined) this.ensureLineItemsAvailable(idToken); this.logger.debug(this.LOG_COMPONENT, 'Attempting to retrieve line items'); const url = options?.url ?? this.resolveLineItemsEndpoint(idToken); const query = options?.url !== undefined ? undefined : this.buildLineItemsQuery(idToken, options); const accessToken = await this.accessTokenManager.getAccessToken(platform, lti_scopes_constants_1.AGS_LINEITEM_READONLY_SCOPE); const { lineItems, links } = await this.fetchLineItemsPage(url, query, accessToken); return (0, grading_serializer_1.buildLineItems)({ lineItems: this.filterLineItems(lineItems, options), ...this.buildPaginationLinks(links) }); } async createLineItem(lineItem, options) { this.ensureServiceAvailability(); const { platform, rawIdToken: idToken } = this.launchContext; this.ensureLineItemsAvailable(idToken); const validatedLineItem = (0, validation_1.validate)(grading_schemas_1.LineItemSchema, lineItem); this.logger.debug(this.LOG_COMPONENT, 'Attempting to create a line item'); const endpoint = this.resolveLineItemsEndpoint(idToken); const payload = options?.resourceLinkId === true ? { ...validatedLineItem, resourceLinkId: this.resolveResourceLinkId(idToken) } : validatedLineItem; const accessToken = await this.accessTokenManager.getAccessToken(platform, lti_scopes_constants_1.AGS_LINEITEM_SCOPE); const response = await this.requestHandler.post(endpoint, payload, { headers: { authorization: (0, authorization_header_1.buildBearerAuthorization)(accessToken), contentType: this.LINEITEM_MEDIA_TYPE }, }); return (0, grading_serializer_1.buildLineItem)((0, validation_1.validate)(grading_schemas_1.LineItemSchema, response.data)); } async getLineItemById(lineItemId) { this.ensureServiceAvailability(); const validatedLineItemId = (0, validation_1.validate)(grading_schemas_1.LineItemIdSchema, lineItemId); const { platform } = this.launchContext; this.logger.debug(this.LOG_COMPONENT, `Attempting to retrieve line item: ${validatedLineItemId}`); const accessToken = await this.accessTokenManager.getAccessToken(platform, lti_scopes_constants_1.AGS_LINEITEM_READONLY_SCOPE); return await this.fetchLineItemById(validatedLineItemId, accessToken); } async fetchLineItemById(lineItemId, accessToken) { const response = await this.requestHandler.get(lineItemId, { headers: { authorization: (0, authorization_header_1.buildBearerAuthorization)(accessToken), accept: this.LINEITEM_MEDIA_TYPE }, }); return (0, grading_serializer_1.buildLineItem)((0, validation_1.validate)(grading_schemas_1.LineItemSchema, response.data)); } async updateLineItemById(lineItemId, lineItem) { this.ensureServiceAvailability(); const validatedLineItemId = (0, validation_1.validate)(grading_schemas_1.LineItemIdSchema, lineItemId); const validatedLineItem = (0, validation_1.validate)(grading_schemas_1.LineItemSchema, lineItem); const { platform } = this.launchContext; this.logger.debug(this.LOG_COMPONENT, `Attempting to update line item: ${validatedLineItemId}`); const accessToken = await this.accessTokenManager.getAccessToken(platform, lti_scopes_constants_1.AGS_LINEITEM_SCOPE); const response = await this.requestHandler.put(validatedLineItemId, validatedLineItem, { headers: { authorization: (0, authorization_header_1.buildBearerAuthorization)(accessToken), contentType: this.LINEITEM_MEDIA_TYPE }, }); return (0, grading_serializer_1.buildLineItem)((0, validation_1.validate)(grading_schemas_1.LineItemSchema, response.data)); } async deleteLineItemById(lineItemId) { this.ensureServiceAvailability(); const validatedLineItemId = (0, validation_1.validate)(grading_schemas_1.LineItemIdSchema, lineItemId); const { platform } = this.launchContext; this.logger.debug(this.LOG_COMPONENT, `Attempting to delete line item: ${validatedLineItemId}`); const accessToken = await this.accessTokenManager.getAccessToken(platform, lti_scopes_constants_1.AGS_LINEITEM_SCOPE); await this.requestHandler.delete(validatedLineItemId, { headers: { authorization: (0, authorization_header_1.buildBearerAuthorization)(accessToken) }, }); return true; } async submitScore(lineItemId, score) { this.ensureServiceAvailability(); const validatedLineItemId = (0, validation_1.validate)(grading_schemas_1.LineItemIdSchema, lineItemId); const validatedScore = (0, validation_1.validate)(grading_schemas_1.ScoreSchema, score); const { platform, rawIdToken: idToken } = this.launchContext; this.logger.debug(this.LOG_COMPONENT, `Attempting to submit score to line item: ${validatedLineItemId}`); const built = { ...validatedScore }; const needsScoreMaximum = built.scoreGiven !== undefined && built.scoreMaximum === undefined; const scope = needsScoreMaximum ? `${lti_scopes_constants_1.AGS_SCORE_SCOPE} ${lti_scopes_constants_1.AGS_LINEITEM_READONLY_SCOPE}` : lti_scopes_constants_1.AGS_SCORE_SCOPE; const accessToken = await this.accessTokenManager.getAccessToken(platform, scope); if (needsScoreMaximum) { const lineItem = await this.fetchLineItemById(validatedLineItemId, accessToken); built.scoreMaximum = lineItem.scoreMaximum; } built.userId ??= idToken.sub; built.timestamp ??= new Date().toISOString(); await this.requestHandler.post(this.appendPathSegment(validatedLineItemId, 'scores'), built, { headers: { authorization: (0, authorization_header_1.buildBearerAuthorization)(accessToken), contentType: this.SCORE_CONTENT_TYPE }, }); return (0, grading_serializer_1.buildScore)(built); } async getScores(lineItemId, options) { this.ensureServiceAvailability(); const validatedLineItemId = (0, validation_1.validate)(grading_schemas_1.LineItemIdSchema, lineItemId); const { platform } = this.launchContext; this.logger.debug(this.LOG_COMPONENT, `Attempting to retrieve scores for line item: ${validatedLineItemId}`); const accessToken = await this.accessTokenManager.getAccessToken(platform, `${lti_scopes_constants_1.AGS_LINEITEM_READONLY_SCOPE} ${lti_scopes_constants_1.AGS_RESULT_READONLY_SCOPE}`); const url = options?.url ?? this.appendPathSegment(validatedLineItemId, 'results'); const query = options?.url !== undefined ? undefined : this.buildScoresQuery(options); const response = await this.requestHandler.get(url, { query, headers: { authorization: (0, authorization_header_1.buildBearerAuthorization)(accessToken), accept: this.RESULT_CONTAINER_ACCEPT }, }); const scores = (0, validation_1.validate)(grading_schemas_1.ResultsListSchema, response.data); return (0, grading_serializer_1.buildScores)({ scores, ...this.buildPaginationLinks((0, link_header_1.parseLinkHeader)(response.headers.link)) }); } async fetchLineItemsPage(url, query, accessToken) { const response = await this.requestHandler.get(url, { query, headers: { authorization: (0, authorization_header_1.buildBearerAuthorization)(accessToken), accept: this.LINEITEM_CONTAINER_ACCEPT }, }); return { lineItems: (0, validation_1.validate)(grading_schemas_1.LineItemsListSchema, response.data), links: (0, link_header_1.parseLinkHeader)(response.headers.link), }; } buildLineItemsQuery(idToken, options) { const query = new URLSearchParams(); if (options?.resourceLinkId === true) { query.append(this.RESOURCE_LINK_ID_PARAM, this.resolveResourceLinkId(idToken)); } // limit is either sent to the server (here) or applied client-side after an id/label filter // (filterLineItems), never both, since the id/label filter can only run after the fetch, // which would make a server-side limit truncate results before they're ever filtered. if (options?.limit !== undefined && !this.filtersByIdOrLabel(options)) { query.append(this.LIMIT_PARAM, String(options.limit)); } if (options?.tag !== undefined) query.append(this.TAG_PARAM, options.tag); if (options?.resourceId !== undefined) query.append(this.RESOURCE_ID_PARAM, options.resourceId); return query; } buildScoresQuery(options) { const query = new URLSearchParams(); if (options?.userId !== undefined) query.append(this.USER_ID_PARAM, options.userId); if (options?.limit !== undefined) query.append(this.LIMIT_PARAM, String(options.limit)); return query; } filterLineItems(lineItems, options) { let filtered = lineItems; if (options?.id !== undefined) filtered = filtered.filter(lineItem => lineItem.id === options.id); if (options?.label !== undefined) filtered = filtered.filter(lineItem => lineItem.label === options.label); if (options?.limit !== undefined && this.filtersByIdOrLabel(options)) { filtered = filtered.slice(0, options.limit); } return filtered; } filtersByIdOrLabel(options) { return options?.id !== undefined || options?.label !== undefined; } buildPaginationLinks(links) { return { next: links?.next?.url, prev: links?.prev?.url, first: links?.first?.url, last: links?.last?.url }; } ensureServiceAvailability() { if (!this.isAvailable()) throw new errors_1.AssignmentAndGradesNotAvailableError(); } ensureLineItemsAvailable(idToken) { if (idToken[id_token_constants_1.IdTokenClaim.Endpoint]?.lineitems === undefined) { throw new errors_1.MissingLineItemsEndpointError(); } } resolveLineItemsEndpoint(idToken) { const endpoint = idToken[id_token_constants_1.IdTokenClaim.Endpoint]?.lineitems; if (endpoint === undefined) throw new errors_1.MissingLineItemsEndpointError(); return endpoint; } // Some platforms (e.g. Moodle) return a line item URL that already carries its own query string // (`.../lineitem?type_id=151`). Naively concatenating a path segment onto that string would land it // after the query string instead of before it, producing a URL the platform can't route. Going // through URL keeps the new segment in the path and the existing query string intact. appendPathSegment(url, segment) { const parsed = new URL(url); parsed.pathname = `${parsed.pathname}/${segment}`; return parsed.toString(); } resolveResourceLinkId(idToken) { const resourceLinkId = idToken[id_token_constants_1.IdTokenClaim.ResourceLink]?.id; if (resourceLinkId === undefined) throw new errors_2.MissingOrInvalidResourceLinkIdError(); return resourceLinkId; } } exports.Grading = Grading; exports.default = Grading; //# sourceMappingURL=grading.service.js.map