Source: linkedin_learning/index.js

  1. /**
  2. * @module linkedinLearning
  3. */
  4. const got = require("got");
  5. const log = require("../../util/log");
  6. const {differenceInDays} = require("date-fns");
  7. const config = require("../../config").linkedinLearningAPI;
  8. const learningObjectRepository = require("../../repositories/learning_object");
  9. /**
  10. * Number of failed API token renewals.
  11. * Functions use this to avoid repeating
  12. * unauthorised API requests
  13. */
  14. let failedRenewals = 0;
  15. /**
  16. * Attempt to renew LinkedIn Learning API token.
  17. * Update failedRenewals counter based on result.
  18. */
  19. const renewAccessToken = async () => {
  20. log.info("LinkedIn-L API: Attempting to renew access token..");
  21. try {
  22. const response = await got.post("https://www.linkedin.com/oauth/v2/accessToken", {
  23. responseType: "json",
  24. searchParams: {
  25. "grant_type": "client_credentials",
  26. "client_id": process.env.LINKEDIN_LEARNING_ID,
  27. "client_secret": process.env.LINKEDIN_LEARNING_SECRET,
  28. },
  29. });
  30. if (response.statusCode === 200) {
  31. process.env.LINKEDIN_LEARNING_TOKEN = response.body.access_token;
  32. failedRenewals = 0;
  33. log.info("LinkedIn-L API: Successfully renewed access token.");
  34. } else {
  35. ++failedRenewals;
  36. log.error("LinkedIn-L API: Failed to renew access token, statusCode: %s, statusMsg: %s",
  37. response.statusCode, response.statusMessage);
  38. }
  39. } catch (error) {
  40. ++failedRenewals;
  41. log.error("LinkedIn-L API: Failed to POST token renewal endpoint, err:" + error.message);
  42. }
  43. };
  44. /**
  45. * Attempt to fetch learningObject from LinkedIn Learning API
  46. * based on its Unique Resource Identifier (URN) if respective
  47. * object in database is out of date.
  48. * If the API response is 401 Unauthorised and there haven't been
  49. * any failed renewals, reattempts with a renewed token.
  50. * Logs attempt details including whether the cached value is up to date.
  51. * Returns undefined on failure.
  52. * @param {String} urn
  53. * @return {Object} learningObject or undefined
  54. */
  55. const fetchLearningObject = async (urn) => {
  56. const findResult = await learningObjectRepository.findByURN(urn);
  57. if (findResult.rows.length > 0) {
  58. const lastUpdated = new Date(findResult.rows[0].timestamp);
  59. if (differenceInDays((new Date()), lastUpdated) < config.ttl) {
  60. log.info("LinkedIn-L API: Found up to date learning object (%s) in database", urn);
  61. return findResult.rows[0].data;
  62. }
  63. log.info("LinkedIn-L API: Found outdated learning object (%s) in database, re-fetching...", urn);
  64. }
  65. log.info("LinkedIn-L API: Attempting to fetch learning obj(%s)..", urn);
  66. const meta = [
  67. "urn",
  68. "title:(value)",
  69. ];
  70. const details = [
  71. "urls:(webLaunch)",
  72. "shortDescriptionIncludingHtml:(value)",
  73. "images:(primary)",
  74. "descriptionIncludingHtml:(value)",
  75. "timeToComplete:(duration)",
  76. ];
  77. try {
  78. const response = await got("https://api.linkedin.com/v2/learningAssets/" + urn, {
  79. responseType: "json",
  80. headers: {
  81. Authorization: getOAuthToken(),
  82. },
  83. searchParams: {
  84. "fields": `${meta.join()},details:(${details.join()})`,
  85. "expandDepth": 1,
  86. "targetLocale.language": "en",
  87. },
  88. hooks: {
  89. afterResponse: [
  90. async (response, retryWithNewToken) => {
  91. if (response.statusCode === 401 && failedRenewals === 0) { // Unauthorized
  92. log.error("LinkedIn-L API: failed to authenticate for learning asset endpoint. " +
  93. "Attempting to retry with a new access token..");
  94. await renewAccessToken();
  95. // Retry
  96. log.info("LinkedIn-L API: Retrying with new access token...");
  97. return retryWithNewToken();
  98. }
  99. // No changes otherwise
  100. return response;
  101. },
  102. ],
  103. beforeRetry: [
  104. async (options) => {
  105. options.headers.Authorization = getOAuthToken();
  106. },
  107. ],
  108. },
  109. });
  110. if (response.statusCode === 200) {
  111. log.info("LinkedIn-L API: Successfully fetched learning asset.");
  112. await learningObjectRepository.insert({
  113. urn,
  114. timestamp: (new Date()).toUTCString(),
  115. data: JSON.stringify(response.body),
  116. });
  117. return response.body;
  118. }
  119. } catch (error) {
  120. log.error("LinkedIn-L API: Failed to GET learning asset (%s) endpoint, err: " + error.message, urn);
  121. }
  122. };
  123. /**
  124. * Attempt to fetch matching URN to given learningObject from LinkedIn Learning API.
  125. * In order for a URN to be considered matching, the user input hyperlink must match
  126. * the full path of the API object's hyperlink.
  127. * If the API response is 401 Unauthorised and there haven't been
  128. * any failed renewals, reattempts with a renewed token.
  129. * Logs attempt details including degree of match.
  130. * Returns undefined on failure.
  131. * @param {Object} learningObject must have title & hyperlink
  132. * @param {String} type learning object type COURSE / VIDEO
  133. * @return {String} URN or undefined
  134. */
  135. const fetchURNByContent = async (learningObject, type) => {
  136. log.info("LinkedIn-L API: Attempting to find matching URN for %s (%s)", type, learningObject.data.title);
  137. try {
  138. const response = await got("https://api.linkedin.com/v2/learningAssets", {
  139. responseType: "json",
  140. headers: {
  141. Authorization: getOAuthToken(),
  142. },
  143. searchParams: {
  144. "q": "criteria",
  145. "start": 0,
  146. "count": 10,
  147. "assetRetrievalCriteria.includeRetired": false,
  148. "assetRetrievalCriteria.expandDepth": 1,
  149. "assetFilteringCriteria.keyword": learningObject.data.title,
  150. "assetFilteringCriteria.assetTypes[0]": type,
  151. "fields": "urn,details:(urls:(webLaunch))",
  152. },
  153. hooks: {
  154. afterResponse: [
  155. async (response, retryWithNewToken) => {
  156. if (response.statusCode === 401 && failedRenewals === 0) { // Unauthorized
  157. log.error("LinkedIn-L API: failed to authenticate for learning asset endpoint. " +
  158. "Attempting to retry with a new access token..");
  159. await renewAccessToken();
  160. // Retry
  161. log.info("LinkedIn-L API: Retrying with new access token...");
  162. return retryWithNewToken();
  163. }
  164. // No changes otherwise
  165. return response;
  166. },
  167. ],
  168. beforeRetry: [
  169. async (options) => {
  170. options.headers.Authorization = getOAuthToken();
  171. },
  172. ],
  173. },
  174. });
  175. const elements = response.body.elements;
  176. for (const e of elements) {
  177. if (learningObject.data.hyperlink.startsWith(e.details.urls.webLaunch.substring(0, e.details.urls.webLaunch.length -2))) {
  178. log.info("LinkedIn-L API: Found matching URN for %s (%s) - (%s)", type, learningObject.data.title, e.urn);
  179. return e.urn;
  180. }
  181. }
  182. log.error("LinkedIn-L API: No matching URN found for %s (%s)", type, learningObject.data.title);
  183. } catch (error) {
  184. log.error("LinkedIn-L API: Failed to GET learning assets endpoint, err: " + error.message);
  185. }
  186. };
  187. /**
  188. * Returns the server's LinkedIn Learning API token in OAuth 2.0 Authorisation header format.
  189. * @return {String} API token in OAuth 2.0 format
  190. */
  191. const getOAuthToken = () => {
  192. return `Bearer ${process.env.LINKEDIN_LEARNING_TOKEN}`;
  193. };
  194. /**
  195. * TEST FUNCTION: Must only be used for testing purposes.
  196. * Reset failedRenewals counter to 0.
  197. */
  198. const resetFailedRenewalsCounter = () => {
  199. failedRenewals = 0;
  200. };
  201. module.exports = {
  202. renewAccessToken,
  203. fetchLearningObject,
  204. fetchURNByContent,
  205. resetFailedRenewalsCounter,
  206. };