ltijs
Version:
Easily turn your web application into a LTI 1.3 Learning Tool.
224 lines • 13.3 kB
JavaScript
"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