Source: linkedin_learning/index.js

/**
 * @module linkedinLearning
 */
const got = require("got");
const log = require("../../util/log");
const {differenceInDays} = require("date-fns");
const config = require("../../config").linkedinLearningAPI;

const learningObjectRepository = require("../../repositories/learning_object");

/**
 * Number of failed API token renewals.
 * Functions use this to avoid repeating
 * unauthorised API requests
 */
let failedRenewals = 0;

/**
 * Attempt to renew LinkedIn Learning API token.
 * Update failedRenewals counter based on result.
 */
const renewAccessToken = async () => {
    log.info("LinkedIn-L API: Attempting to renew access token..");

    try {
        const response = await got.post("https://www.linkedin.com/oauth/v2/accessToken", {
            responseType: "json",
            searchParams: {
                "grant_type": "client_credentials",
                "client_id": process.env.LINKEDIN_LEARNING_ID,
                "client_secret": process.env.LINKEDIN_LEARNING_SECRET,
            },
        });

        if (response.statusCode === 200) {
            process.env.LINKEDIN_LEARNING_TOKEN = response.body.access_token;
            failedRenewals = 0;
            log.info("LinkedIn-L API: Successfully renewed access token.");
        } else {
            ++failedRenewals;
            log.error("LinkedIn-L API: Failed to renew access token, statusCode: %s, statusMsg: %s",
                response.statusCode, response.statusMessage);
        }
    } catch (error) {
        ++failedRenewals;
        log.error("LinkedIn-L API: Failed to POST token renewal endpoint, err:" + error.message);
    }
};

/**
 * Attempt to fetch learningObject from LinkedIn Learning API
 * based on its Unique Resource Identifier (URN) if respective
 * object in database is out of date.
 * If the API response is 401 Unauthorised and there haven't been
 * any failed renewals, reattempts with a renewed token.
 * Logs attempt details including whether the cached value is up to date.
 * Returns undefined on failure.
 * @param {String} urn
 * @return {Object} learningObject or undefined
 */
const fetchLearningObject = async (urn) => {
    const findResult = await learningObjectRepository.findByURN(urn);
    if (findResult.rows.length > 0) {
        const lastUpdated = new Date(findResult.rows[0].timestamp);
        if (differenceInDays((new Date()), lastUpdated) < config.ttl) {
            log.info("LinkedIn-L API: Found up to date learning object (%s) in database", urn);
            return findResult.rows[0].data;
        }
        log.info("LinkedIn-L API: Found outdated learning object (%s) in database, re-fetching...", urn);
    }

    log.info("LinkedIn-L API: Attempting to fetch learning obj(%s)..", urn);

    const meta = [
        "urn",
        "title:(value)",
    ];

    const details = [
        "urls:(webLaunch)",
        "shortDescriptionIncludingHtml:(value)",
        "images:(primary)",
        "descriptionIncludingHtml:(value)",
        "timeToComplete:(duration)",
    ];

    try {
        const response = await got("https://api.linkedin.com/v2/learningAssets/" + urn, {
            responseType: "json",
            headers: {
                Authorization: getOAuthToken(),
            },
            searchParams: {
                "fields": `${meta.join()},details:(${details.join()})`,
                "expandDepth": 1,
                "targetLocale.language": "en",
            },
            hooks: {
                afterResponse: [
                    async (response, retryWithNewToken) => {
                        if (response.statusCode === 401 && failedRenewals === 0) { // Unauthorized
                            log.error("LinkedIn-L API: failed to authenticate for learning asset endpoint. " +
                                "Attempting to retry with a new access token..");
                            await renewAccessToken();

                            // Retry
                            log.info("LinkedIn-L API: Retrying with new access token...");
                            return retryWithNewToken();
                        }

                        // No changes otherwise
                        return response;
                    },
                ],
                beforeRetry: [
                    async (options) => {
                        options.headers.Authorization = getOAuthToken();
                    },
                ],
            },
        });
        if (response.statusCode === 200) {
            log.info("LinkedIn-L API: Successfully fetched learning asset.");
            await learningObjectRepository.insert({
                urn,
                timestamp: (new Date()).toUTCString(),
                data: JSON.stringify(response.body),
            });
            return response.body;
        }
    } catch (error) {
        log.error("LinkedIn-L API: Failed to GET learning asset (%s) endpoint, err: " + error.message, urn);
    }
};

/**
 * Attempt to fetch matching URN to given learningObject from LinkedIn Learning API.
 * In order for a URN to be considered matching, the user input hyperlink must match
 * the full path of the API object's hyperlink.
 * If the API response is 401 Unauthorised and there haven't been
 * any failed renewals, reattempts with a renewed token.
 * Logs attempt details including degree of match.
 * Returns undefined on failure.
 * @param {Object} learningObject must have title & hyperlink
 * @param {String} type learning object type COURSE / VIDEO
 * @return {String} URN or undefined
 */
const fetchURNByContent = async (learningObject, type) => {
    log.info("LinkedIn-L API: Attempting to find matching URN for %s (%s)", type, learningObject.data.title);
    try {
        const response = await got("https://api.linkedin.com/v2/learningAssets", {
            responseType: "json",
            headers: {
                Authorization: getOAuthToken(),
            },
            searchParams: {
                "q": "criteria",
                "start": 0,
                "count": 10,
                "assetRetrievalCriteria.includeRetired": false,
                "assetRetrievalCriteria.expandDepth": 1,
                "assetFilteringCriteria.keyword": learningObject.data.title,
                "assetFilteringCriteria.assetTypes[0]": type,
                "fields": "urn,details:(urls:(webLaunch))",
            },
            hooks: {
                afterResponse: [
                    async (response, retryWithNewToken) => {
                        if (response.statusCode === 401 && failedRenewals === 0) { // Unauthorized
                            log.error("LinkedIn-L API: failed to authenticate for learning asset endpoint. " +
                                "Attempting to retry with a new access token..");
                            await renewAccessToken();

                            // Retry
                            log.info("LinkedIn-L API: Retrying with new access token...");
                            return retryWithNewToken();
                        }

                        // No changes otherwise
                        return response;
                    },
                ],
                beforeRetry: [
                    async (options) => {
                        options.headers.Authorization = getOAuthToken();
                    },
                ],
            },
        });
        const elements = response.body.elements;
        for (const e of elements) {
            if (learningObject.data.hyperlink.startsWith(e.details.urls.webLaunch.substring(0, e.details.urls.webLaunch.length -2))) {
                log.info("LinkedIn-L API: Found matching URN for %s (%s) - (%s)", type, learningObject.data.title, e.urn);
                return e.urn;
            }
        }
        log.error("LinkedIn-L API: No matching URN found for %s (%s)", type, learningObject.data.title);
    } catch (error) {
        log.error("LinkedIn-L API: Failed to GET learning assets endpoint, err: " + error.message);
    }
};

/**
 * Returns the server's LinkedIn Learning API token in OAuth 2.0 Authorisation header format.
 * @return {String} API token in OAuth 2.0 format
 */
const getOAuthToken = () => {
    return `Bearer ${process.env.LINKEDIN_LEARNING_TOKEN}`;
};

/**
 * TEST FUNCTION: Must only be used for testing purposes.
 * Reset failedRenewals counter to 0.
 */
const resetFailedRenewalsCounter = () => {
    failedRenewals = 0;
};

module.exports = {
    renewAccessToken,
    fetchLearningObject,
    fetchURNByContent,
    resetFailedRenewalsCounter,
};