├── commitlint.config.js ├── .gitignore ├── .husky ├── pre-commit └── commit-msg ├── .prettierrc ├── tsconfig.build.json ├── lib ├── index.ts ├── utils │ ├── urn-utils.ts │ ├── oauth-utils.ts │ ├── restli-utils.ts │ ├── logging.ts │ ├── constants.ts │ ├── api-utils.ts │ ├── query-tunneling.ts │ ├── encoder.ts │ ├── patch-generator.ts │ └── decoder.ts ├── utils.ts ├── auth.ts └── restli-client.ts ├── babel.config.js ├── tests ├── utils │ ├── urn-utils.test.js │ ├── restli-utils.test.ts │ ├── oauth-utils.test.ts │ ├── api-utils.test.ts │ ├── encoder.test.ts │ └── decoder.test.ts └── restli-client.test.ts ├── tsconfig.json ├── examples ├── package.json ├── get-profile.ts ├── README.md ├── batch-get-campaign-groups-query-tunneling.ts ├── oauth-member-auth-redirect.ts ├── create-posts.ts ├── retry-and-interceptors.ts └── crud-ad-accounts.ts ├── .release-it.json ├── .github └── workflows │ └── node.js.yml ├── .eslintrc.js ├── package.json ├── CONTRIBUTING.md ├── CHANGELOG.md ├── LICENSE.md ├── jest.config.js └── README.md /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {extends: ['@commitlint/config-conventional']} 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | *.tgz 3 | node_modules 4 | docs 5 | dist 6 | .DS_Store 7 | coverage 8 | .env -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "printWidth": 100 6 | } -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "tests", 5 | "examples" 6 | ] 7 | } -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export * from './restli-client'; 4 | export * from './auth'; 5 | export { utils } from './utils'; 6 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript' 5 | ] 6 | }; 7 | -------------------------------------------------------------------------------- /lib/utils/urn-utils.ts: -------------------------------------------------------------------------------- 1 | export function createUrnFromAttrs(type: string, id: string | number, namespace = 'li'): string { 2 | return `urn:${namespace}:${type}:${id}`; 3 | } 4 | -------------------------------------------------------------------------------- /tests/utils/urn-utils.test.js: -------------------------------------------------------------------------------- 1 | const { createUrnFromAttrs } = require('lib/utils/urn-utils'); 2 | 3 | describe('urn utils', () => { 4 | test('basic urn formatting', () => { 5 | expect(createUrnFromAttrs('developerApplication', 123)).toBe('urn:li:developerApplication:123'); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "target": "es6", 6 | "moduleResolution": "node", 7 | "sourceMap": true, 8 | "skipLibCheck": true, 9 | "outDir": "dist", 10 | "strict": false, 11 | "declaration": true, 12 | "allowJs": true, 13 | "resolveJsonModule": true 14 | }, 15 | "include": [ 16 | "lib", 17 | "examples", 18 | "tests" 19 | ], 20 | "exclude": [ 21 | "node_modules" 22 | ], 23 | "typedocOptions": { 24 | "entryPoints": ["lib/index.ts"], 25 | "out": "docs" 26 | } 27 | } -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "linkedin-api-client-examples", 3 | "version": "0.1.0", 4 | "description": "Examples for LinkedIn API JavaScript Client", 5 | "author": "LinkedIn", 6 | "license": "SEE LICENSE IN LICENSE.md", 7 | "scripts": { 8 | "preinstall": "cd .. && npm install && npm run build" 9 | }, 10 | "dependencies": { 11 | "axios-retry": "^3.3.1", 12 | "linkedin-api-client": ".." 13 | }, 14 | "devDependencies": { 15 | "@types/express": "^4.17.15", 16 | "dotenv": "^16.0.3", 17 | "express": "^4.18.2", 18 | "ts-node": "^10.9.1", 19 | "typescript": "^4.9.4" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "requireBranch": "master", 4 | "commitMessage": "chore: release v${version}", 5 | "changelog": "npx auto-changelog --stdout --commit-limit false -u --template https://raw.githubusercontent.com/release-it/release-it/master/templates/changelog-compact.hbs" 6 | }, 7 | "github": { 8 | "release": true 9 | }, 10 | "hooks": { 11 | "after:bump": "npx auto-changelog -p" 12 | }, 13 | "npm": { 14 | "publish": false 15 | }, 16 | "plugins": { 17 | "@release-it/conventional-changelog": { 18 | "preset": "angular", 19 | "infile": "CHANGELOG.md" 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import * as oauthUtils from './utils/oauth-utils'; 2 | import * as restliUtils from './utils/restli-utils'; 3 | import * as apiUtils from './utils/api-utils'; 4 | import * as urnUtils from './utils/urn-utils'; 5 | import * as patchUtils from './utils/patch-generator'; 6 | import * as queryTunnelingUtils from './utils/query-tunneling'; 7 | import * as constants from './utils/constants'; 8 | import * as encoderUtils from './utils/encoder'; 9 | import { decode, paramDecode, reducedDecode } from './utils/decoder'; 10 | 11 | export const utils = { 12 | ...oauthUtils, 13 | ...restliUtils, 14 | ...apiUtils, 15 | ...urnUtils, 16 | ...patchUtils, 17 | ...queryTunnelingUtils, 18 | ...constants, 19 | ...encoderUtils, 20 | decode, 21 | paramDecode, 22 | reducedDecode 23 | }; 24 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | workflow_dispatch: 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | matrix: 20 | node-version: [14.x, 16.x, 18.x] 21 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | cache: 'npm' 30 | - run: npm ci 31 | - run: npm run build --if-present 32 | - run: npm test 33 | -------------------------------------------------------------------------------- /tests/utils/restli-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { getCreatedEntityId } from '../../lib/utils/restli-utils'; 2 | 3 | describe('restli-utils', () => { 4 | test.each([ 5 | { 6 | description: 'Id is a string', 7 | inputHeaderIdValue: 'foobar', 8 | shouldDecode: true, 9 | expectedIdValue: 'foobar' 10 | }, 11 | { 12 | description: 'Id has special characters, decode = true', 13 | inputHeaderIdValue: 'urn%3Ali%3Atest%3Afoo bar', 14 | shouldDecode: true, 15 | expectedIdValue: 'urn:li:test:foo bar' 16 | }, 17 | { 18 | description: 'Id has special characters, decode = false', 19 | inputHeaderIdValue: 'urn%3Ali%3Atest%3Afoo bar', 20 | shouldDecode: false, 21 | expectedIdValue: 'urn%3Ali%3Atest%3Afoo bar' 22 | } 23 | ])('$description', ({ inputHeaderIdValue, shouldDecode, expectedIdValue }) => { 24 | const inputResponse: any = { 25 | headers: { 26 | 'x-restli-id': inputHeaderIdValue 27 | } 28 | }; 29 | expect(getCreatedEntityId(inputResponse, shouldDecode)).toBe(expectedIdValue); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | jest: true 6 | }, 7 | extends: 'standard-with-typescript', 8 | overrides: [ 9 | ], 10 | ignorePatterns: ['dist'], 11 | parserOptions: { 12 | ecmaVersion: 'latest', 13 | sourceType: 'module', 14 | project: ['tsconfig.json'] 15 | }, 16 | rules: { 17 | semi: ['error', 'always'], 18 | 'no-unexpected-multiline': 'off', 19 | '@typescript-eslint/semi': ['error', 'always'], 20 | 'space-before-function-paren': 'off', 21 | '@typescript-eslint/member-delimiter-style': ['error', { 22 | multiline: { 23 | delimiter: 'semi', 24 | requireLast: true 25 | } 26 | }], 27 | '@typescript-eslint/space-before-function-paren': 'off', 28 | '@typescript-eslint/explicit-function-return-type': 'warn', 29 | '@typescript-eslint/restrict-template-expressions': 'off', 30 | '@typescript-eslint/prefer-nullish-coalescing': 'off', 31 | '@typescript-eslint/restrict-plus-operands': 'off', 32 | '@typescript-eslint/strict-boolean-expressions': 'off' 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /lib/utils/oauth-utils.ts: -------------------------------------------------------------------------------- 1 | import { OAUTH_BASE_URL } from './constants'; 2 | import qs from 'qs'; 3 | 4 | /** 5 | * Generates the member authorization URL to redirect users to in order to 6 | * authorize the requested scopes for an application. 7 | */ 8 | export function generateMemberAuthorizationUrl(params: { 9 | clientId: string; 10 | redirectUrl: string; 11 | scopes: string[]; 12 | state?: string; 13 | }): string { 14 | if (!params.clientId) { 15 | throw new Error('The client ID must be specified.'); 16 | } 17 | if (!params.redirectUrl) { 18 | throw new Error('The OAuth 2.0 redirect URL must be specified.'); 19 | } 20 | if (!params.scopes?.length) { 21 | throw new Error('At least one scope must be specified'); 22 | } 23 | 24 | const queryParamString = qs.stringify( 25 | { 26 | response_type: 'code', 27 | client_id: params.clientId, 28 | redirect_uri: params.redirectUrl, 29 | scope: params.scopes.join(','), 30 | state: params.state 31 | }, 32 | { encode: false } 33 | ); 34 | return `${OAUTH_BASE_URL}/authorization?${queryParamString}`; 35 | } 36 | -------------------------------------------------------------------------------- /examples/get-profile.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Example call to fetch the member profile for the authorized member. 3 | * The 3-legged member access token should include the 'r_liteprofile' scope, which 4 | * is part of the Sign In With LinkedIn API product. 5 | */ 6 | 7 | import { RestliClient } from 'linkedin-api-client'; 8 | import dotenv from 'dotenv'; 9 | 10 | dotenv.config(); 11 | 12 | async function main(): Promise { 13 | const restliClient = new RestliClient(); 14 | restliClient.setDebugParams({ enabled: true }); 15 | const accessToken = process.env.ACCESS_TOKEN || ''; 16 | 17 | /** 18 | * Basic usage 19 | */ 20 | let response = await restliClient.get({ 21 | resourcePath: '/me', 22 | accessToken 23 | }); 24 | console.log('Basic usage:', response.data); 25 | 26 | /** 27 | * With field projection to limit fields returned 28 | */ 29 | response = await restliClient.get({ 30 | resourcePath: '/me', 31 | queryParams: { 32 | fields: 'id,firstName,lastName' 33 | }, 34 | accessToken 35 | }); 36 | console.log('With field projections:', response.data); 37 | 38 | /** 39 | * With decoration of displayImage 40 | */ 41 | response = await restliClient.get({ 42 | resourcePath: '/me', 43 | queryParams: { 44 | projection: '(id,firstName,lastName,profilePicture(displayImage~:playableStreams))' 45 | }, 46 | accessToken 47 | }); 48 | console.log('With decoration:', response.data); 49 | } 50 | 51 | main() 52 | .then(() => { 53 | console.log('Completed'); 54 | }) 55 | .catch((error) => { 56 | console.log(`Error encountered: ${error.message}`); 57 | }); 58 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | ## LinkedIn API JavaScript Client Examples 2 | 3 | This directory contains examples showing how to use the LinkedIn API JavaScript client library. 4 | 5 | ### Steps to Run 6 | 7 | 1. Navigate inside the `/examples` directory 8 | 2. Run `npm install` 9 | 3. Create a `.env` file that contains the following variables that will be used when running the examples. Only some of these variables may actually be needed to run a particular script. Check the specific example comments on more specific requirements (e.g. required scopes for the access token). 10 | ```sh 11 | ACCESS_TOKEN="your_valid_access_token" 12 | CLIENT_ID="your_app_client_id" 13 | CLIENT_SECRET="your_app_client_secret" 14 | OAUTH2_REDIRECT_URL="your_app_oauth2_redirect_url" 15 | ``` 16 | 4. Execute the desired example script: `npx ts-node {script filename}`. For example: `npx ts-node get-profile.ts` 17 | 18 | ### Example Notes 19 | 20 | | Example filename | Description | 21 | |---|---| 22 | | `oauth-member-auth-redirect.ts` | Demonstrates the member oauth redirect flow (authorization code flow) to obtain a 3-legged access token. | 23 | | `get-profile.ts` | Uses Sign In With LinkedIn v1 to fetch member profile. Also demonstrates use of field projections and decoration. | 24 | | `create-posts.ts` | Uses Sign In With LinkedIn and Share on LinkedIn to create posts. | 25 | | `crud-ad-accounts.ts` | Performs create, get, finder, partial update, and delete requests on ad accounts. | 26 | | `batch-get-campaign-groups-query-tunneling.ts` | Demonstrates a request that requires query tunneling, which is performed automatically by the client. | 27 | | `retry-and-interceptors.ts` | Adds retry logic and response interceptors to the client. | -------------------------------------------------------------------------------- /examples/batch-get-campaign-groups-query-tunneling.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Example calls to perform BATCH_GET on campaign groups and illustrate automatic 3 | * query tunneling. 4 | * 5 | * The 3-legged member access token should include the 'r_ads' scope, which 6 | * is part of the Advertising APIs product. 7 | */ 8 | 9 | import { RestliClient } from 'linkedin-api-client'; 10 | import dotenv from 'dotenv'; 11 | 12 | dotenv.config(); 13 | 14 | const API_VERSION = '202212'; 15 | const AD_CAMPAIGN_GROUPS_RESOURCE = '/adCampaignGroups'; 16 | // Set a large number of entities to fetch to require query tunneling 17 | const NUMBER_OF_ENTITIES = 300; 18 | 19 | async function main(): Promise { 20 | const restliClient = new RestliClient(); 21 | restliClient.setDebugParams({ enabled: true }); 22 | const accessToken = process.env.ACCESS_TOKEN || ''; 23 | 24 | /** 25 | * Randomly generate campaign group ids and send in a BATCH_GET request (we expect all 404 responses). 26 | * The client should automatically do query tunneling and perform a POST request. 27 | */ 28 | const campaignGroupIds = Array.from({ length: NUMBER_OF_ENTITIES }).map((_) => 29 | Math.floor(Math.random() * 99999999999999) 30 | ); 31 | const batchGetResponse = await restliClient.batchGet({ 32 | resourcePath: AD_CAMPAIGN_GROUPS_RESOURCE, 33 | ids: campaignGroupIds, 34 | accessToken, 35 | versionString: API_VERSION 36 | }); 37 | console.log( 38 | `Successfully made a BATCH_GET request on /adCampaignGroups. HTTP Method: ${batchGetResponse.config.method}]` 39 | ); 40 | } 41 | 42 | main() 43 | .then(() => { 44 | console.log('Completed'); 45 | }) 46 | .catch((error) => { 47 | console.log(`Error encountered: ${error.message}`); 48 | }); 49 | -------------------------------------------------------------------------------- /lib/utils/restli-utils.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from 'axios'; 2 | import { reducedDecode } from './decoder'; 3 | import { paramEncode } from './encoder'; 4 | 5 | /** 6 | * Miscellaneous Rest.li Utils 7 | */ 8 | 9 | import { HEADERS } from './constants'; 10 | 11 | /** 12 | * Returns the created entity id, provided the raw response. By default, the created 13 | * entity id will be the decoded value. 14 | */ 15 | export function getCreatedEntityId( 16 | /** The raw response object from a Rest.li create request. */ 17 | response: AxiosResponse, 18 | /** Flag whether to decode the created entity id. The entity is decoded by default (e.g. "urn:li:myEntity:123"), otherwise the raw, reduced encoded value is returned (e.g. "urn%3A%li%3AmyEntity%3A123"). */ 19 | decode: boolean = true 20 | ): string | string[] | Record { 21 | const reducedEncodedEntityId = response?.headers[HEADERS.CREATED_ENTITY_ID]; 22 | return decode ? reducedDecode(reducedEncodedEntityId) : reducedEncodedEntityId; 23 | } 24 | 25 | /** 26 | * This wrapper function on top of encoder.paramEncode is needed specifically to handle the 27 | * "fields" query parameter for field projections. Although Rest.li protocol version 2.0.0 should 28 | * have supported a query param string like "?fields=List(id,firstName,lastName)" it still requires 29 | * the Rest.li protocol version 1.0.0 format of "?fields=id,firstName,lastName". Thus, if "fields" 30 | * is provided as a query parameter for HTTP GET requests, it should not be encoded like all the other 31 | * parameters. 32 | */ 33 | export function encodeQueryParamsForGetRequests(queryParams: Record): string { 34 | const { fields, ...otherQueryParams } = queryParams; 35 | let encodedQueryParamString = paramEncode(otherQueryParams); 36 | if (fields) { 37 | encodedQueryParamString = [encodedQueryParamString, `fields=${fields}`].join('&'); 38 | } 39 | return encodedQueryParamString; 40 | } 41 | -------------------------------------------------------------------------------- /lib/utils/logging.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from 'axios'; 2 | 3 | export function logSuccess(response: AxiosResponse): void { 4 | console.log(`${new Date().toISOString()} [linkedin-api-client]: Success Response`); 5 | console.group(); 6 | console.log( 7 | JSON.stringify( 8 | { 9 | method: response.config?.method, 10 | url: response.config?.url, 11 | status: response.status, 12 | requestHeaders: response.config?.headers, 13 | requestData: response.config?.data, 14 | responseHeaders: response.headers, 15 | responseData: response.data 16 | }, 17 | null, 18 | 2 19 | ) 20 | ); 21 | console.groupEnd(); 22 | } 23 | 24 | export function logError(error: any): void { 25 | const dateString = new Date().toISOString(); 26 | if (error.response) { 27 | console.error(`${dateString} [linkedin-api-client]: Error Response`); 28 | console.group(); 29 | console.error( 30 | JSON.stringify( 31 | { 32 | method: error.response?.config?.method, 33 | url: error.response?.config?.url, 34 | status: error.response?.status, 35 | requestHeaders: error.response?.config?.headers, 36 | requestData: error.response?.config?.data, 37 | responseHeaders: error.response?.headers, 38 | responseData: error.response?.data 39 | }, 40 | null, 41 | 2 42 | ) 43 | ); 44 | console.groupEnd(); 45 | } else { 46 | console.error(`${dateString} [linkedin-api-client]: Other Error`); 47 | console.group(); 48 | console.error(`${error.name}: ${error.message}`); 49 | console.error( 50 | JSON.stringify( 51 | { 52 | method: error.config?.method, 53 | url: error.config?.url, 54 | requestHeaders: error.config?.headers, 55 | requestData: error.config?.data 56 | }, 57 | null, 58 | 2 59 | ) 60 | ); 61 | console.groupEnd(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/utils/oauth-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { generateMemberAuthorizationUrl } from '../../lib/utils/oauth-utils'; 2 | 3 | describe('oauth-utils', () => { 4 | test('generateMemberAuthorizationUrl basic', () => { 5 | expect( 6 | generateMemberAuthorizationUrl({ 7 | clientId: 'abc123', 8 | redirectUrl: 'https://www.linkedin.com/developers', 9 | scopes: ['r_liteprofile'] 10 | }) 11 | ).toBe( 12 | 'https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=abc123&redirect_uri=https://www.linkedin.com/developers&scope=r_liteprofile' 13 | ); 14 | }); 15 | 16 | test('generateMemberAuthorizationUrl with multiple scopes', () => { 17 | expect( 18 | generateMemberAuthorizationUrl({ 19 | clientId: 'abc123', 20 | redirectUrl: 'https://www.linkedin.com/developers', 21 | scopes: ['r_liteprofile', 'r_ads', 'r_organization'] 22 | }) 23 | ).toBe( 24 | 'https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=abc123&redirect_uri=https://www.linkedin.com/developers&scope=r_liteprofile,r_ads,r_organization' 25 | ); 26 | }); 27 | 28 | test('generateMemberAuthorizationUrl with state parameter', () => { 29 | expect( 30 | generateMemberAuthorizationUrl({ 31 | clientId: 'abc123', 32 | redirectUrl: 'https://www.linkedin.com/developers', 33 | scopes: ['r_liteprofile'], 34 | state: 'foobar' 35 | }) 36 | ).toBe( 37 | 'https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=abc123&redirect_uri=https://www.linkedin.com/developers&scope=r_liteprofile&state=foobar' 38 | ); 39 | }); 40 | 41 | test('generateMemberAuthorizationUrl missing scopes', () => { 42 | expect(() => { 43 | generateMemberAuthorizationUrl({ 44 | clientId: 'abc123', 45 | redirectUrl: 'https://www.linkedin.com/developers', 46 | scopes: [] 47 | }); 48 | }).toThrow('At least one scope must be specified'); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /lib/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const OAUTH_BASE_URL = 'https://www.linkedin.com/oauth/v2'; 2 | export const NON_VERSIONED_BASE_URL = 'https://api.linkedin.com/v2'; 3 | export const VERSIONED_BASE_URL = 'https://api.linkedin.com/rest'; 4 | 5 | export const HEADERS = { 6 | CONTENT_TYPE: 'Content-Type', 7 | CONNECTION: 'Connection', 8 | RESTLI_PROTOCOL_VERSION: 'X-RestLi-Protocol-Version', 9 | RESTLI_METHOD: 'X-RestLi-Method', 10 | CREATED_ENTITY_ID: 'x-restli-id', 11 | HTTP_METHOD_OVERRIDE: 'X-HTTP-Method-Override', 12 | LINKEDIN_VERSION: 'LinkedIn-Version', 13 | AUTHORIZATION: 'Authorization', 14 | USER_AGENT: 'user-agent' 15 | }; 16 | 17 | export const CONTENT_TYPE = { 18 | JSON: 'application/json', 19 | URL_ENCODED: 'application/x-www-form-urlencoded', 20 | MULTIPART_MIXED_WITH_BOUNDARY: (boundary: string) => `multipart/mixed; boundary=${boundary}` 21 | }; 22 | 23 | export const HTTP_METHODS = { 24 | GET: 'GET', 25 | POST: 'POST', 26 | PUT: 'PUT', 27 | DELETE: 'DELETE' 28 | }; 29 | 30 | export const RESTLI_METHODS = { 31 | GET: 'GET', 32 | BATCH_GET: 'BATCH_GET', 33 | GET_ALL: 'GET_ALL', 34 | UPDATE: 'UPDATE', 35 | BATCH_UPDATE: 'BATCH_UPDATE', 36 | PARTIAL_UPDATE: 'PARTIAL_UPDATE', 37 | BATCH_PARTIAL_UPDATE: 'BATCH_PARTIAL_UPDATE', 38 | CREATE: 'CREATE', 39 | BATCH_CREATE: 'BATCH_CREATE', 40 | DELETE: 'DELETE', 41 | BATCH_DELETE: 'BATCH_DELETE', 42 | FINDER: 'FINDER', 43 | BATCH_FINDER: 'BATCH_FINDER', 44 | ACTION: 'ACTION' 45 | }; 46 | 47 | export const RESTLI_METHOD_TO_HTTP_METHOD_MAP = { 48 | GET: 'GET', 49 | BATCH_GET: 'GET', 50 | GET_ALL: 'GET', 51 | FINDER: 'GET', 52 | BATCH_FINDER: 'GET', 53 | UPDATE: 'PUT', 54 | BATCH_UPDATE: 'PUT', 55 | CREATE: 'POST', 56 | BATCH_CREATE: 'POST', 57 | PARTIAL_UPDATE: 'POST', 58 | BATCH_PARTIAL_UPDATE: 'POST', 59 | ACTION: 'POST', 60 | DELETE: 'DELETE', 61 | BATCH_DELETE: 'DELETE' 62 | }; 63 | 64 | /** 65 | * Rest.li protocol encoding constants 66 | */ 67 | export const LIST_PREFIX = 'List('; 68 | export const LIST_SUFFIX = ')'; 69 | export const OBJ_PREFIX = '('; 70 | export const OBJ_SUFFIX = ')'; 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "linkedin-api-client", 3 | "version": "0.3.0", 4 | "description": "Official JavaScript client library for LinkedIn APIs", 5 | "main": "dist/lib/index.js", 6 | "types": "dist/lib/index.d.ts", 7 | "scripts": { 8 | "test": "jest --detectOpenHandles", 9 | "test-debug-log": "DEBUG=nock.* jest --detectOpenHandles", 10 | "@comment test": "The detectOpenHandles workaround is due to this issue: https://github.com/facebook/jest/issues/10577", 11 | "format": "prettier --write '{lib,examples,tests}/**/*.ts'", 12 | "lint": "eslint {lib,examples,tests}/**/*.ts", 13 | "clean": "rimraf dist", 14 | "build": "npm run clean && tsc --project tsconfig.build.json", 15 | "doc": "typedoc", 16 | "prepare": "husky install", 17 | "prepublishOnly": "npm ci && npm run test && npm run build", 18 | "release": "dotenv release-it" 19 | }, 20 | "author": "LinkedIn", 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/linkedin-developers/linkedin-api-js-client.git" 24 | }, 25 | "license": "SEE LICENSE IN LICENSE.md", 26 | "engines": { 27 | "node": ">=14.0.0" 28 | }, 29 | "dependencies": { 30 | "axios": "^1.1.3", 31 | "lodash": "^4.17.21", 32 | "qs": "^6.11.0" 33 | }, 34 | "lint-staged": { 35 | "{lib,tests}/**/*.ts": [ 36 | "eslint {lib,examples,tests}/**/*.ts --fix", 37 | "prettier --write '{lib,examples,tests}/**/*.ts'" 38 | ] 39 | }, 40 | "devDependencies": { 41 | "@babel/core": "^7.20.5", 42 | "@babel/preset-env": "^7.20.2", 43 | "@babel/preset-typescript": "^7.18.6", 44 | "@commitlint/cli": "^17.4.2", 45 | "@commitlint/config-conventional": "^17.4.2", 46 | "@release-it/conventional-changelog": "^5.1.1", 47 | "@types/jest": "^29.2.3", 48 | "@types/node": "^18.11.9", 49 | "@types/qs": "^6.9.7", 50 | "@typescript-eslint/eslint-plugin": "^5.46.1", 51 | "babel-jest": "^29.3.1", 52 | "dotenv-cli": "^7.0.0", 53 | "eslint": "^8.29.0", 54 | "eslint-config-standard-with-typescript": "^24.0.0", 55 | "eslint-plugin-import": "^2.26.0", 56 | "eslint-plugin-n": "^15.6.0", 57 | "eslint-plugin-promise": "^6.1.1", 58 | "husky": "^8.0.0", 59 | "jest": "^29.3.1", 60 | "lint-staged": "^11.2.5", 61 | "nock": "^13.2.9", 62 | "prettier": "^2.8.1", 63 | "release-it": "^15.6.0", 64 | "rimraf": "^3.0.2", 65 | "typedoc": "^0.23.21", 66 | "typescript": "^4.9.4" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This contributing guide is meant for internal LinkedIn maintainers. We are not currently accepting contributions from outside of LinkedIn at this time. If you are not a LinkedIn engineer, we appreciate your interest in our library and encourage you to provide bug reports or suggestions by opening an issue in the GitHub repo. 4 | 5 | ## Commit Messages 6 | 7 | Please follow [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) 8 | 9 | ## Documentation 10 | 11 | Make sure the documentation is consistent with any code changes in the same PR. 12 | 13 | ## Examples 14 | 15 | If there are code changes that might affect the example code ([/examples](examples/)), ensure each example runs correctly, or update accordingly. 16 | 17 | Note that example scripts use the built code in /dist, so if you have local changes that aren't being reflected, make sure to run `npm install` from inside the /examples directory to ensure the client code is up-to-date. 18 | 19 | ## Testing 20 | 21 | Ensure tests pass by running `npm run test`. Tests are also run in different node environments when a PR is created. 22 | 23 | ## Release 24 | 25 | ### Pre-requisites 26 | 27 | To release and publish a new version of the library, you must have Write access to the repo and be an owner on the published npm package (`npm owner ls linkedin-api-client`). 28 | 29 | ### Steps 30 | 31 | 1. Obtain a GitHub [personal access token](https://github.com/settings/tokens/new?scopes=repo&description=release-it) 32 | 2. Put the token in a `.env` file at the root of the project. For example: `GITHUB_TOKEN="ghp_7e3..."` 33 | 3. From the master branch on a clean working directory, run the following, specifying the semver increment. 34 | ```sh 35 | npm run release -- 36 | 37 | # Example 38 | npm run release -- minor 39 | ``` 40 | 1. This will have terminal prompts to confirm various release details. After confirmation, the following will happen: 41 | 1. The `package.json` version will be updated 42 | 2. The `CHANGELOG.md` will be automatically updated 43 | 3. The modified files will be committed, and the commit will be tagged with the release version 44 | 4. Commit will be pushed to the remote branch 45 | 5. A GitHub release will be automatically created based on the tagged commit 46 | 4. For now, the actual publishing of the package to NPM is done as a separate step (you may need to `npm login` first): 47 | ```sh 48 | npm publish 49 | ``` -------------------------------------------------------------------------------- /lib/utils/api-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utilities related to working with LinkedIn's APIs 3 | */ 4 | 5 | import { VERSIONED_BASE_URL, NON_VERSIONED_BASE_URL, HEADERS } from './constants'; 6 | import { encode } from './encoder'; 7 | import { version } from '../../package.json'; 8 | 9 | /** 10 | * Method to build the URL (not including query parameters) for a REST-based API call to LinkedIn 11 | */ 12 | export function buildRestliUrl( 13 | resourcePath: string, 14 | pathKeys: Record = null, 15 | versionString?: string 16 | ): string { 17 | const baseUrl = versionString ? VERSIONED_BASE_URL : NON_VERSIONED_BASE_URL; 18 | pathKeys = pathKeys || {}; 19 | 20 | const PLACEHOLDER_REGEX = /\{\w+\}/g; 21 | // Validate resourcePath and pathKeys 22 | const placeholderMatches = resourcePath.match(PLACEHOLDER_REGEX); 23 | const numPlaceholders = placeholderMatches ? placeholderMatches.length : 0; 24 | 25 | if (numPlaceholders !== Object.keys(pathKeys).length) { 26 | throw new Error( 27 | `The number of placeholders in the resourcePath (${resourcePath}) does not match the number of entries in the pathKeys argument` 28 | ); 29 | } 30 | 31 | const resourcePathWithKeys = resourcePath.replace(PLACEHOLDER_REGEX, (match) => { 32 | // match looks like "{id}", so remove the curly braces to get the placeholder 33 | const placeholder = match.substring(1, match.length - 1); 34 | if (Object.prototype.hasOwnProperty.call(pathKeys, placeholder)) { 35 | return encode(pathKeys[placeholder]); 36 | } else { 37 | throw new Error( 38 | `The placeholder ${match} was found in resourcePath, which does not have a corresponding entry in pathKeys` 39 | ); 40 | } 41 | }); 42 | 43 | return `${baseUrl}${resourcePathWithKeys}`; 44 | } 45 | 46 | export function getRestliRequestHeaders({ 47 | restliMethodType, 48 | accessToken, 49 | versionString, 50 | httpMethodOverride, 51 | contentType = 'application/json' 52 | }: { 53 | restliMethodType: string; 54 | accessToken: string; 55 | versionString?: string | null; 56 | httpMethodOverride?: string; 57 | contentType?: string; 58 | }): Record { 59 | const headers = { 60 | [HEADERS.CONNECTION]: 'Keep-Alive', 61 | [HEADERS.RESTLI_PROTOCOL_VERSION]: '2.0.0', 62 | [HEADERS.RESTLI_METHOD]: restliMethodType.toLowerCase(), 63 | [HEADERS.AUTHORIZATION]: `Bearer ${accessToken}`, 64 | [HEADERS.CONTENT_TYPE]: contentType, 65 | [HEADERS.USER_AGENT]: `linkedin-api-js-client/${version}` 66 | }; 67 | if (versionString) { 68 | headers[HEADERS.LINKEDIN_VERSION] = versionString; 69 | } 70 | if (httpMethodOverride) { 71 | headers[HEADERS.HTTP_METHOD_OVERRIDE] = httpMethodOverride.toUpperCase(); 72 | } 73 | return headers; 74 | } 75 | -------------------------------------------------------------------------------- /tests/utils/api-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { buildRestliUrl, getRestliRequestHeaders } from '../../lib/utils/api-utils'; 2 | import { version } from '../../package.json'; 3 | 4 | describe('api-utils', () => { 5 | test.each([ 6 | { 7 | resourcePath: '/adAccounts', 8 | pathKeys: null, 9 | versionString: null, 10 | expectedUrl: 'https://api.linkedin.com/v2/adAccounts' 11 | }, 12 | { 13 | resourcePath: '/adAccounts', 14 | pathKeys: null, 15 | versionString: '202209', 16 | expectedUrl: 'https://api.linkedin.com/rest/adAccounts' 17 | }, 18 | { 19 | resourcePath: '/adAccounts/{id}', 20 | pathKeys: { 21 | id: 123 22 | }, 23 | versionString: '202209', 24 | expectedUrl: 'https://api.linkedin.com/rest/adAccounts/123' 25 | }, 26 | { 27 | resourcePath: '/socialActions/{actionUrn}/comments/{commentId}', 28 | pathKeys: { 29 | actionUrn: 'urn:li:share:123', 30 | commentId: 'foobar123' 31 | }, 32 | versionString: '202209', 33 | expectedUrl: 34 | 'https://api.linkedin.com/rest/socialActions/urn%3Ali%3Ashare%3A123/comments/foobar123' 35 | }, 36 | { 37 | resourcePath: '/testResource/{complexKey}', 38 | pathKeys: { 39 | complexKey: { member: 'urn:li:member:123', account: 'urn:li:account:456' } 40 | }, 41 | expectedUrl: 42 | 'https://api.linkedin.com/v2/testResource/(member:urn%3Ali%3Amember%3A123,account:urn%3Ali%3Aaccount%3A456)' 43 | } 44 | ])('buildRestliUrl', ({ resourcePath, pathKeys, versionString, expectedUrl }) => { 45 | expect(buildRestliUrl(resourcePath, pathKeys, versionString)).toBe(expectedUrl); 46 | }); 47 | 48 | test('getRestliRequestHeaders', () => { 49 | expect( 50 | getRestliRequestHeaders({ 51 | restliMethodType: 'BATCH_CREATE', 52 | accessToken: 'ABC123' 53 | }) 54 | ).toStrictEqual({ 55 | Connection: 'Keep-Alive', 56 | 'X-RestLi-Protocol-Version': '2.0.0', 57 | 'X-RestLi-Method': 'batch_create', 58 | Authorization: 'Bearer ABC123', 59 | 'Content-Type': 'application/json', 60 | 'user-agent': `linkedin-api-js-client/${version}` 61 | }); 62 | 63 | expect( 64 | getRestliRequestHeaders({ 65 | restliMethodType: 'BATCH_CREATE', 66 | accessToken: 'ABC123', 67 | versionString: '202209', 68 | httpMethodOverride: 'get', 69 | contentType: 'multipart/form-data' 70 | }) 71 | ).toStrictEqual({ 72 | Connection: 'Keep-Alive', 73 | 'X-RestLi-Protocol-Version': '2.0.0', 74 | 'X-RestLi-Method': 'batch_create', 75 | Authorization: 'Bearer ABC123', 76 | 'Content-Type': 'multipart/form-data', 77 | 'user-agent': `linkedin-api-js-client/${version}`, 78 | 'LinkedIn-Version': '202209', 79 | 'X-HTTP-Method-Override': 'GET' 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # [0.3.0](https://github.com/linkedin-developers/linkedin-api-js-client/compare/v0.2.2...v0.3.0) (2023-02-07) 4 | 5 | ### Changelog 6 | 7 | All notable changes to this project will be documented in this file. Dates are displayed in UTC. 8 | 9 | Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). 10 | 11 | #### [v0.3.0](https://github.com/linkedin-developers/linkedin-api-js-client/compare/v0.2.2...v0.3.0) 12 | 13 | - refactor!: refactored rest.li client interfaces [`#13`](https://github.com/linkedin-developers/linkedin-api-js-client/pull/13) 14 | 15 | #### [v0.2.2](https://github.com/linkedin-developers/linkedin-api-js-client/compare/v0.2.1...v0.2.2) 16 | 17 | > 3 February 2023 18 | 19 | - chore: manual version bump to fix release error [`#12`](https://github.com/linkedin-developers/linkedin-api-js-client/pull/12) 20 | - ci: added auto-changelog [`#11`](https://github.com/linkedin-developers/linkedin-api-js-client/pull/11) 21 | - docs: updated README, fixed some errors [`#10`](https://github.com/linkedin-developers/linkedin-api-js-client/pull/10) 22 | - ci: added release-it to automate versioning/publishing [`#9`](https://github.com/linkedin-developers/linkedin-api-js-client/pull/9) 23 | - build: added commitlint to ensure standard commit message format [`#8`](https://github.com/linkedin-developers/linkedin-api-js-client/pull/8) 24 | - ci: Added github actions to automate build/tests [`#7`](https://github.com/linkedin-developers/linkedin-api-js-client/pull/7) 25 | 26 | #### v0.2.1 27 | 28 | > 9 January 2023 29 | 30 | - Added repository information in package.json [`#6`](https://github.com/linkedin-developers/linkedin-api-js-client/pull/6) 31 | - Provided more examples of CRUD requests, retry logic [`#5`](https://github.com/linkedin-developers/linkedin-api-js-client/pull/5) 32 | - Member oauth redirect example [`#4`](https://github.com/linkedin-developers/linkedin-api-js-client/pull/4) 33 | - Added examples directory and fixed parameter encoding for field projections [`#3`](https://github.com/linkedin-developers/linkedin-api-js-client/pull/3) 34 | - Added debug logs to RestliClient, renamed package, fixed entry point [`#2`](https://github.com/linkedin-developers/linkedin-api-js-client/pull/2) 35 | - Modify user-agent for client library usage tracking [`#1`](https://github.com/linkedin-developers/linkedin-api-js-client/pull/1) 36 | - Added jest testing framework with sample test [`372ca37`](https://github.com/linkedin-developers/linkedin-api-js-client/commit/372ca3704660145e29654cfa35cab7db0980c101) 37 | - Added typescript and wrote interfaces for client methods, converted from CommonJS to ES modules. Renamed library. [`a7430f2`](https://github.com/linkedin-developers/linkedin-api-js-client/commit/a7430f2299470797b0959464799a8895ef642e9f) 38 | - Added eslint configuration [`fe43780`](https://github.com/linkedin-developers/linkedin-api-js-client/commit/fe43780faa9bf38429c2b467fff873c49a64743e) -------------------------------------------------------------------------------- /tests/utils/encoder.test.ts: -------------------------------------------------------------------------------- 1 | import { encode, paramEncode, reducedEncode } from '../../lib/utils/encoder'; 2 | 3 | const example = { 4 | k1: 'v1', 5 | k2: 'value with spaces', 6 | k3: [1, 2, 3], 7 | k4: "List(value:with%reserved,chars,'')", 8 | k5: { 9 | k51: 'v51', 10 | k52: 'v52' 11 | }, 12 | k6: ['(v1,2)', '(v2,2)'], 13 | "dangerous('),:key:": 'value', 14 | emptystring: '', 15 | querystringbreaker1: '?key=value', 16 | querystringbreaker2: '&key=value&', 17 | boom: null, 18 | boom2: undefined, 19 | true: true, 20 | false: false, 21 | multibyte: '株式会社', // %E6%A0%AA%E5%BC%8F%E4%BC%9A%E7%A4%BE 22 | 株式会社: 'multibytekey', 23 | '': 'emptystringkey', 24 | emptyList: [], 25 | emptyListString: [''] 26 | }; 27 | 28 | const expected = { 29 | encode: 30 | "(k1:v1,k2:value%20with%20spaces,k3:List(1,2,3),k4:List%28value%3Awith%25reserved%2Cchars%2C%27%27%29,k5:(k51:v51,k52:v52),k6:List(%28v1%2C2%29,%28v2%2C2%29),dangerous%28%27%29%2C%3Akey%3A:value,emptystring:'',querystringbreaker1:%3Fkey%3Dvalue,querystringbreaker2:%26key%3Dvalue%26,boom:null,true:true,false:false,multibyte:%E6%A0%AA%E5%BC%8F%E4%BC%9A%E7%A4%BE,%E6%A0%AA%E5%BC%8F%E4%BC%9A%E7%A4%BE:multibytekey,'':emptystringkey,emptyList:List(),emptyListString:List(''))", 31 | reducedEncode: 32 | "(k1:v1,k2:value with spaces,k3:List(1,2,3),k4:List%28value%3Awith%reserved%2Cchars%2C%27%27%29,k5:(k51:v51,k52:v52),k6:List(%28v1%2C2%29,%28v2%2C2%29),dangerous%28%27%29%2C%3Akey%3A:value,emptystring:'',querystringbreaker1:?key=value,querystringbreaker2:&key=value&,boom:null,true:true,false:false,multibyte:株式会社,株式会社:multibytekey,'':emptystringkey,emptyList:List(),emptyListString:List(''))", 33 | paramEncode: 34 | "k1=v1&k2=value%20with%20spaces&k3=List(1,2,3)&k4=List%28value%3Awith%25reserved%2Cchars%2C%27%27%29&k5=(k51:v51,k52:v52)&k6=List(%28v1%2C2%29,%28v2%2C2%29)&dangerous%28%27%29%2C%3Akey%3A=value&emptystring=''&querystringbreaker1=%3Fkey%3Dvalue&querystringbreaker2=%26key%3Dvalue%26&boom=null&true=true&false=false&multibyte=%E6%A0%AA%E5%BC%8F%E4%BC%9A%E7%A4%BE&%E6%A0%AA%E5%BC%8F%E4%BC%9A%E7%A4%BE=multibytekey&''=emptystringkey&emptyList=List()&emptyListString=List('')", 35 | _arrayParamEncode: { 36 | array: [null, null, null], 37 | array2: [1, 2, 3], 38 | boom: null, 39 | true: true, 40 | false: false, 41 | multibyte: '株式会社', 42 | 株式会社: 'multibytekey', 43 | '': 'emptystringkey' 44 | } 45 | }; 46 | 47 | describe('restli encode', () => { 48 | test('processes correctly in each format', () => { 49 | expect(encode(example)).toBe(expected.encode); 50 | expect(reducedEncode(example)).toBe(expected.reducedEncode); 51 | expect(paramEncode(example)).toBe(expected.paramEncode); 52 | }); 53 | 54 | test('throws an error when using invalid arguments to paramEncode', () => { 55 | expect(function () { 56 | paramEncode([] as any); 57 | }).toThrow(); 58 | expect(function () { 59 | paramEncode(new Date() as any); 60 | }).toThrow(); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /examples/oauth-member-auth-redirect.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This example illustrates a basic example of the oauth authorization code flow. 3 | * 4 | * Pre-requisites: 5 | * 1. Add CLIENT_ID, CLIENT_SECRET, and OAUTH2_REDIRECT_URL variables to the examples/.env file. 6 | * 2. The associated developer app you are using should have access to r_liteprofile, which can be 7 | * obtained through requesting the self-serve Sign In With LinkedIn API product on the LinkedIn 8 | * Developer Portal. 9 | * 3. Set your developer app's OAuth redirect URL to "http://localhost:3000/oauth" 10 | * 11 | * Steps: 12 | * 1. Run script 13 | * 2. Navigate to localhost:3000 14 | * 3. Login as LinkedIn member and authorize application 15 | * 4. View member profile data 16 | */ 17 | 18 | import express from 'express'; 19 | import dotenv from 'dotenv'; 20 | import { AuthClient, RestliClient } from 'linkedin-api-client'; 21 | 22 | dotenv.config(); 23 | 24 | const app = express(); 25 | const port = 3000; 26 | 27 | // Start off with no access token 28 | let accessToken = ''; 29 | 30 | // Initialize auth and restli clients 31 | if (!(process.env.CLIENT_ID && process.env.CLIENT_SECRET && process.env.OAUTH2_REDIRECT_URL)) { 32 | throw new Error( 33 | 'The CLIENT_ID, CLIENT_SECRET, and OAUTH2_REDIRECT_URL variables must be set in the .env file.' 34 | ); 35 | } 36 | const authClient = new AuthClient({ 37 | clientId: process.env.CLIENT_ID, 38 | clientSecret: process.env.CLIENT_SECRET, 39 | redirectUrl: process.env.OAUTH2_REDIRECT_URL 40 | }); 41 | const restliClient = new RestliClient(); 42 | restliClient.setDebugParams({ enabled: true }); 43 | 44 | // Route to display profile details 45 | app.get('/', (_req, res) => { 46 | if (!accessToken) { 47 | // If no access token, have member authorize again 48 | res.redirect(authClient.generateMemberAuthorizationUrl(['r_liteprofile'])); 49 | } else { 50 | // Fetch profile details 51 | restliClient 52 | .get({ 53 | resourcePath: '/me', 54 | accessToken 55 | }) 56 | .then((response) => { 57 | res.json(response.data); 58 | }) 59 | .catch(() => { 60 | res.send('Error encountered while fetching profile.'); 61 | }); 62 | } 63 | }); 64 | 65 | // OAuth callback route handler 66 | app.get('/oauth', (req, res) => { 67 | const authCode = req.query?.code as string; 68 | if (authCode) { 69 | // Exchange auth code for an access token and redirect to main page 70 | authClient 71 | .exchangeAuthCodeForAccessToken(authCode) 72 | .then((response) => { 73 | accessToken = response.access_token; 74 | console.log(`Access token: ${accessToken}`); 75 | res.redirect('/'); 76 | }) 77 | .catch(() => { 78 | res.send('Error exchanging auth code for access token.'); 79 | }); 80 | } else { 81 | if (req.query?.error_description) { 82 | res.send(`Error: ${req.query?.error_description as string}`); 83 | } else { 84 | res.send('Expecting "code" query parameter'); 85 | } 86 | } 87 | }); 88 | 89 | app.listen(port, () => { 90 | console.log(`Navigate to example app at http://localhost:${port}`); 91 | }); 92 | -------------------------------------------------------------------------------- /examples/create-posts.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Example calls to create a post on LinkedIn. This requires a member-based token with the following 3 | * scopes (r_liteprofile, w_member_social), which is provided by the Sign in with LinkedIn and Share on LinkedIn 4 | * API products. 5 | * 6 | * The steps include: 7 | * 1. Fetching the authenticated member's profile to obtain the member's identifier (a person URN) 8 | * 2. Create a post using /ugcPosts endpoint (legacy) or /posts endpoint (new) 9 | * 10 | * To view these posts, go to linkedin.com and click Me > Posts & Activity. 11 | * 12 | * BEWARE: This will make an actual post to the main feed which is visible to anyone. 13 | */ 14 | 15 | import { RestliClient } from 'linkedin-api-client'; 16 | import dotenv from 'dotenv'; 17 | 18 | dotenv.config(); 19 | 20 | const ME_RESOURCE = '/me'; 21 | const UGC_POSTS_RESOURCE = '/ugcPosts'; 22 | const POSTS_RESOURCE = '/posts'; 23 | const API_VERSION = '202302'; 24 | 25 | async function main(): Promise { 26 | const restliClient = new RestliClient(); 27 | restliClient.setDebugParams({ enabled: true }); 28 | const accessToken = process.env.ACCESS_TOKEN || ''; 29 | 30 | const meResponse = await restliClient.get({ 31 | resourcePath: ME_RESOURCE, 32 | accessToken 33 | }); 34 | console.log(meResponse.data); 35 | 36 | /** 37 | * Calling the legacy /ugcPosts API to create a text post on behalf of the 38 | * authenticated member. 39 | */ 40 | const ugcPostsCreateResponse = await restliClient.create({ 41 | resourcePath: UGC_POSTS_RESOURCE, 42 | entity: { 43 | author: `urn:li:person:${meResponse.data.id}`, 44 | lifecycleState: 'PUBLISHED', 45 | specificContent: { 46 | 'com.linkedin.ugc.ShareContent': { 47 | shareCommentary: { 48 | text: 'Sample text post created with /ugcPosts API' 49 | }, 50 | shareMediaCategory: 'NONE' 51 | } 52 | }, 53 | visibility: { 54 | 'com.linkedin.ugc.MemberNetworkVisibility': 'PUBLIC' 55 | } 56 | }, 57 | accessToken 58 | }); 59 | // This is the created share URN 60 | console.log(ugcPostsCreateResponse.createdEntityId); 61 | 62 | /** 63 | * Calling the newer, more streamlined (and versioned) /posts API to create 64 | * a text post on behalf of the authenticated member. 65 | */ 66 | const postsCreateResponse = await restliClient.create({ 67 | resourcePath: POSTS_RESOURCE, 68 | entity: { 69 | author: `urn:li:person:${meResponse.data.id}`, 70 | lifecycleState: 'PUBLISHED', 71 | visibility: 'PUBLIC', 72 | commentary: 'Sample text post created with /posts API', 73 | distribution: { 74 | feedDistribution: 'MAIN_FEED', 75 | targetEntities: [], 76 | thirdPartyDistributionChannels: [] 77 | } 78 | }, 79 | accessToken, 80 | versionString: API_VERSION 81 | }); 82 | // This is the created share URN 83 | console.log(postsCreateResponse.createdEntityId); 84 | } 85 | 86 | main() 87 | .then(() => { 88 | console.log('Completed'); 89 | }) 90 | .catch((error) => { 91 | console.log(`Error encountered: ${error.message}`); 92 | }); 93 | -------------------------------------------------------------------------------- /examples/retry-and-interceptors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Example of configuring custom interceptors for global handling of request/responses. 3 | * This example contains: 4 | * 5 | * - A request interceptor that corrects the API request url on the second retry 6 | * - A response interceptor to log when a success or error response is received 7 | * - A response interceptor configured using axios-retry to set the retry condition, 8 | * max # of retries, retry exponential delay 9 | * 10 | * The 3-legged member access token should include the 'r_liteprofile' scope, which 11 | * is part of the Sign In With LinkedIn API product. 12 | */ 13 | 14 | import { RestliClient } from 'linkedin-api-client'; 15 | import dotenv from 'dotenv'; 16 | import axiosRetry from 'axios-retry'; 17 | 18 | dotenv.config(); 19 | 20 | async function main(): Promise { 21 | const restliClient = new RestliClient(); 22 | const accessToken = process.env.ACCESS_TOKEN || ''; 23 | 24 | /** 25 | * Configure a custom request interceptor 26 | */ 27 | restliClient.axiosInstance.interceptors.request.use(function (req) { 28 | // @ts-expect-error 29 | if (req['axios-retry'].retryCount === 2) { 30 | // On the second retry, remove the invalid query parameter so request will be successful 31 | req.url = 'https://api.linkedin.com/v2/me'; 32 | } 33 | return req; 34 | }, undefined); 35 | 36 | /** 37 | * Configure a custom response interceptor. 38 | */ 39 | restliClient.axiosInstance.interceptors.response.use( 40 | function (res) { 41 | console.log('Hit custom response interceptor.'); 42 | return res; 43 | }, 44 | async function (error) { 45 | /** 46 | * Resetting the config headers is a necessary workaround for retrying the request correctly. 47 | * See axios issue here: https://github.com/axios/axios/issues/5089 48 | */ 49 | const config = error.config; 50 | config.headers = JSON.parse(JSON.stringify(config.headers || {})); 51 | console.log( 52 | `Hit custom error response interceptor. Retry count: ${config['axios-retry'].retryCount}` 53 | ); 54 | return await Promise.reject(error); 55 | } 56 | ); 57 | 58 | /** 59 | * Use the axios-retry library to configure retry condition, exponential retry 60 | * delay, and number of retries. 61 | */ 62 | axiosRetry(restliClient.axiosInstance, { 63 | retryCondition: (error) => { 64 | const status = error.response?.status; 65 | if (status) { 66 | return status >= 400 && status < 500; 67 | } else { 68 | return false; 69 | } 70 | }, 71 | retryDelay: axiosRetry.exponentialDelay, 72 | retries: 3 73 | }); 74 | 75 | /** 76 | * Make a call that is deliberately a bad request to test out the retry logic and 77 | * request interceptors. 78 | */ 79 | const response = await restliClient.get({ 80 | resourcePath: '/me', 81 | queryParams: { 82 | invalidParam: true 83 | }, 84 | accessToken 85 | }); 86 | console.log(response.data); 87 | } 88 | 89 | main() 90 | .then(() => { 91 | console.log('Completed'); 92 | }) 93 | .catch((error) => { 94 | console.log(`Error encountered: ${error.message}`); 95 | }); 96 | -------------------------------------------------------------------------------- /examples/crud-ad-accounts.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Example calls to perform CRUD and finder operations on ad accounts using versioned APIs. 3 | * The 3-legged member access token should include the 'rw_ads' scope, which 4 | * is part of the Advertising APIs product. 5 | */ 6 | 7 | import { RestliClient } from 'linkedin-api-client'; 8 | import dotenv from 'dotenv'; 9 | 10 | dotenv.config(); 11 | 12 | const API_VERSION = '202212'; 13 | const AD_ACCOUNTS_RESOURCE = '/adAccounts'; 14 | const AD_ACCOUNTS_ENTITY_RESOURCE = '/adAccounts/{id}'; 15 | 16 | async function main(): Promise { 17 | const restliClient = new RestliClient(); 18 | restliClient.setDebugParams({ enabled: true }); 19 | const accessToken = process.env.ACCESS_TOKEN || ''; 20 | 21 | /** 22 | * Create a test ad account 23 | */ 24 | const createResponse = await restliClient.create({ 25 | resourcePath: AD_ACCOUNTS_RESOURCE, 26 | entity: { 27 | name: 'Test Ad Account', 28 | reference: 'urn:li:organization:123', 29 | status: 'DRAFT', 30 | type: 'BUSINESS', 31 | test: true 32 | }, 33 | accessToken, 34 | versionString: API_VERSION 35 | }); 36 | const adAccountId = createResponse.createdEntityId as string; 37 | console.log(`Successfully created ad account: ${adAccountId}\n`); 38 | 39 | /** 40 | * Get the created ad account 41 | */ 42 | const getResponse = await restliClient.get({ 43 | resourcePath: AD_ACCOUNTS_ENTITY_RESOURCE, 44 | pathKeys: { 45 | id: adAccountId 46 | }, 47 | accessToken, 48 | versionString: API_VERSION 49 | }); 50 | console.log(`Successfully fetched ad acccount: ${JSON.stringify(getResponse.data, null, 2)}\n`); 51 | 52 | /** 53 | * Partial update on ad account 54 | */ 55 | await restliClient.partialUpdate({ 56 | resourcePath: AD_ACCOUNTS_ENTITY_RESOURCE, 57 | pathKeys: { 58 | id: adAccountId 59 | }, 60 | patchSetObject: { 61 | name: 'Modified Test Ad Account' 62 | }, 63 | accessToken, 64 | versionString: API_VERSION 65 | }); 66 | console.log('Successfully did partial update of ad account\n'); 67 | 68 | /** 69 | * Find all ad accounts according to a specified search criteria 70 | */ 71 | const finderResponse = await restliClient.finder({ 72 | resourcePath: AD_ACCOUNTS_RESOURCE, 73 | finderName: 'search', 74 | queryParams: { 75 | search: { 76 | reference: { 77 | values: ['urn:li:organization:123'] 78 | }, 79 | name: { 80 | values: ['Modified Test Ad Account'] 81 | }, 82 | test: true 83 | } 84 | }, 85 | accessToken, 86 | versionString: API_VERSION 87 | }); 88 | console.log( 89 | `Successfully searched ad accounts: ${JSON.stringify(finderResponse.data.elements, null, 2)}\n` 90 | ); 91 | 92 | /** 93 | * Delete ad account 94 | */ 95 | await restliClient.delete({ 96 | resourcePath: AD_ACCOUNTS_ENTITY_RESOURCE, 97 | pathKeys: { 98 | id: adAccountId 99 | }, 100 | accessToken, 101 | versionString: API_VERSION 102 | }); 103 | console.log('Successfully deleted ad account\n'); 104 | } 105 | 106 | main() 107 | .then(() => { 108 | console.log('Completed'); 109 | }) 110 | .catch((error) => { 111 | console.log(`Error encountered: ${error.message}`); 112 | }); 113 | -------------------------------------------------------------------------------- /tests/utils/decoder.test.ts: -------------------------------------------------------------------------------- 1 | import { decode, paramDecode, reducedDecode } from '../../lib/utils/decoder'; 2 | import { encode, paramEncode, reducedEncode } from '../../lib/utils/encoder'; 3 | 4 | const example = { 5 | k1: 'v1', 6 | k2: 'value with spaces', 7 | k3: [1, 2, 3], 8 | k4: "List(value:with%reserved,chars,'')", 9 | k5: { 10 | k51: 'v51', 11 | k52: 'v52' 12 | }, 13 | "dangerous('),:key:": 'value', 14 | emptystring: '', 15 | emptyList: [], 16 | emptyListString: [''], 17 | 'querystri"ngbreaker1': '?key=value', 18 | querystringbreaker2: '&key=value&', 19 | boom: null, 20 | true: true, 21 | false: false, 22 | multibyte: '株式会社', // %E6%A0%AA%E5%BC%8F%E4%BC%9A%E7%A4%BE 23 | 株式会社: 'multibytekey', 24 | '': 'emptystringkey' 25 | }; 26 | 27 | const stringCoercedExample = { 28 | k1: 'v1', 29 | k2: 'value with spaces', 30 | k3: ['1', '2', '3'], 31 | k4: "List(value:with%reserved,chars,'')", 32 | k5: { 33 | k51: 'v51', 34 | k52: 'v52' 35 | }, 36 | "dangerous('),:key:": 'value', 37 | emptystring: '', 38 | emptyList: [], 39 | emptyListString: [''], 40 | 'querystri"ngbreaker1': '?key=value', 41 | querystringbreaker2: '&key=value&', 42 | boom: 'null', 43 | true: 'true', 44 | false: 'false', 45 | multibyte: '株式会社', // %E6%A0%AA%E5%BC%8F%E4%BC%9A%E7%A4%BE 46 | 株式会社: 'multibytekey', 47 | '': 'emptystringkey' 48 | }; 49 | 50 | describe('restli decode', () => { 51 | test.each([ 52 | { 53 | encodedString: 'List(1,2,(k:v),3)', 54 | decodedValue: ['1', '2', { k: 'v' }, '3'] 55 | }, 56 | { 57 | encodedString: 'List(List(1))', 58 | decodedValue: [['1']] 59 | }, 60 | { 61 | encodedString: 'List((k:List(1)))', 62 | decodedValue: [{ k: ['1'] }] 63 | }, 64 | { 65 | encodedString: 'List(%28v1%2C2%29,%28v2%2C2%29)', 66 | decodedValue: ['(v1,2)', '(v2,2)'] 67 | }, 68 | { 69 | encodedString: '', 70 | decodedValue: '' 71 | }, 72 | { 73 | encodedString: "List('')", 74 | decodedValue: [''] 75 | }, 76 | { 77 | encodedString: 'List()', 78 | decodedValue: [] 79 | }, 80 | { 81 | encodedString: '(k1:v1,k2:List(1,2,3),k3:v3)', 82 | decodedValue: { 83 | k1: 'v1', 84 | k2: ['1', '2', '3'], 85 | k3: 'v3' 86 | } 87 | }, 88 | { 89 | encodedString: '(k1:List())', 90 | decodedValue: { k1: [] } 91 | }, 92 | { 93 | encodedString: encode(example), 94 | decodedValue: stringCoercedExample 95 | } 96 | ])('decode', ({ encodedString, decodedValue }) => { 97 | expect(decode(encodedString)).toEqual(decodedValue); 98 | }); 99 | 100 | test('decodePrefixSuffixValidation', () => { 101 | // test suffix validation 102 | const unbalancedSuffixInputs = [ 103 | 'List((k1:v1)', 104 | 'List((k1:List(v1))', 105 | '(k1:List((k2:(k3:v1,k4:List((string:v2)))))' 106 | ]; 107 | unbalancedSuffixInputs.forEach((unbalancedSuffixInput) => { 108 | expect(() => { 109 | decode(unbalancedSuffixInput); 110 | }).toThrow(); 111 | }); 112 | }); 113 | 114 | test('reducedDecode', () => { 115 | const reducedEncodedStr = reducedEncode(example); 116 | 117 | expect(reducedDecode(reducedEncodedStr)).toEqual(stringCoercedExample); 118 | }); 119 | 120 | test('handles decoding empty key', () => { 121 | expect(paramDecode("''=foo")).toEqual({ 122 | '': 'foo' 123 | }); 124 | }); 125 | 126 | describe('paramEncode', () => { 127 | test('works with basic example', () => { 128 | expect(paramDecode(paramEncode(example))).toEqual(stringCoercedExample); 129 | }); 130 | 131 | test('works with empty map', () => { 132 | expect(paramDecode(paramEncode({}))).toEqual({}); 133 | }); 134 | 135 | test('ignores value without a property key', () => { 136 | expect(paramDecode('=foo')).toEqual({}); 137 | }); 138 | 139 | test('handles key with no value', () => { 140 | expect(paramDecode('foo=')).toEqual({ 141 | foo: '' 142 | }); 143 | }); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /lib/utils/query-tunneling.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utils related to query tunneling 3 | */ 4 | 5 | import _ from 'lodash'; 6 | import { getRestliRequestHeaders } from './api-utils'; 7 | import { HEADERS, HTTP_METHODS, CONTENT_TYPE, RESTLI_METHOD_TO_HTTP_METHOD_MAP } from './constants'; 8 | 9 | const MAX_QUERY_STRING_LENGTH = 4000; // 4KB max length 10 | 11 | export function isQueryTunnelingRequired(encodedQueryParamString: string) { 12 | return encodedQueryParamString && encodedQueryParamString.length > MAX_QUERY_STRING_LENGTH; 13 | } 14 | 15 | export function maybeApplyQueryTunnelingToRequestsWithoutBody({ 16 | encodedQueryParamString, 17 | urlPath, 18 | originalRestliMethod, 19 | accessToken, 20 | versionString, 21 | additionalConfig = {} 22 | }) { 23 | let requestConfig; 24 | 25 | if (isQueryTunnelingRequired(encodedQueryParamString)) { 26 | requestConfig = _.merge( 27 | { 28 | method: HTTP_METHODS.POST, 29 | url: urlPath, 30 | data: encodedQueryParamString, 31 | headers: getRestliRequestHeaders({ 32 | contentType: CONTENT_TYPE.URL_ENCODED, 33 | httpMethodOverride: HTTP_METHODS.GET, 34 | restliMethodType: originalRestliMethod, 35 | accessToken, 36 | versionString 37 | }) 38 | }, 39 | additionalConfig 40 | ); 41 | } else { 42 | const url = encodedQueryParamString ? `${urlPath}?${encodedQueryParamString}` : urlPath; 43 | requestConfig = _.merge( 44 | { 45 | method: RESTLI_METHOD_TO_HTTP_METHOD_MAP[originalRestliMethod], 46 | url, 47 | headers: getRestliRequestHeaders({ 48 | restliMethodType: originalRestliMethod, 49 | accessToken, 50 | versionString 51 | }) 52 | }, 53 | additionalConfig 54 | ); 55 | } 56 | 57 | return requestConfig; 58 | } 59 | 60 | export function maybeApplyQueryTunnelingToRequestsWithBody({ 61 | encodedQueryParamString, 62 | urlPath, 63 | originalRestliMethod, 64 | originalJSONRequestBody, 65 | accessToken, 66 | versionString, 67 | additionalConfig = {} 68 | }) { 69 | let requestConfig; 70 | const originalHttpMethod = RESTLI_METHOD_TO_HTTP_METHOD_MAP[originalRestliMethod]; 71 | 72 | if (isQueryTunnelingRequired(encodedQueryParamString)) { 73 | /** 74 | * Generate a boundary string that is not present at all in the raw request body 75 | */ 76 | let boundary = generateRandomString(); 77 | const rawRequestBodyString = encodedQueryParamString + JSON.stringify(originalJSONRequestBody); 78 | while (rawRequestBodyString.includes(boundary)) { 79 | boundary = generateRandomString(); 80 | } 81 | 82 | // Generate the multipart request body 83 | const multipartRequestBody = 84 | `--${boundary}\r\n` + 85 | `${HEADERS.CONTENT_TYPE}: ${CONTENT_TYPE.URL_ENCODED}\r\n\r\n` + 86 | `${encodedQueryParamString}\r\n` + 87 | `--${boundary}\r\n` + 88 | `${HEADERS.CONTENT_TYPE}: ${CONTENT_TYPE.JSON}\r\n\r\n` + 89 | `${JSON.stringify(originalJSONRequestBody)}\r\n` + 90 | `--${boundary}--`; 91 | requestConfig = _.merge({ 92 | method: HTTP_METHODS.POST, 93 | url: urlPath, 94 | data: multipartRequestBody, 95 | headers: getRestliRequestHeaders({ 96 | contentType: CONTENT_TYPE.MULTIPART_MIXED_WITH_BOUNDARY(boundary), 97 | httpMethodOverride: originalHttpMethod, 98 | restliMethodType: originalRestliMethod, 99 | accessToken, 100 | versionString 101 | }), 102 | additionalConfig 103 | }); 104 | } else { 105 | const url = encodedQueryParamString ? `${urlPath}?${encodedQueryParamString}` : urlPath; 106 | 107 | requestConfig = _.merge( 108 | { 109 | method: originalHttpMethod, 110 | url, 111 | headers: getRestliRequestHeaders({ 112 | restliMethodType: originalRestliMethod, 113 | accessToken, 114 | versionString 115 | }), 116 | data: originalJSONRequestBody 117 | }, 118 | additionalConfig 119 | ); 120 | } 121 | 122 | return requestConfig; 123 | } 124 | 125 | function generateRandomString() { 126 | return Math.random().toString(36).substring(2); 127 | } 128 | -------------------------------------------------------------------------------- /lib/utils/encoder.ts: -------------------------------------------------------------------------------- 1 | import { LIST_PREFIX, LIST_SUFFIX, OBJ_PREFIX, OBJ_SUFFIX } from './constants'; 2 | 3 | // rest.li special characters: 4 | const badChars = /[,()':]/g; 5 | const possible = /[,()':]/; 6 | 7 | type Primitive = string | number | boolean; 8 | type TypeOrArray = T | T[]; 9 | type TypeOrArrayOrRecord = TypeOrArray | Record; 10 | type PrimitiveOrNull = null | Primitive; 11 | type PrimitiveOrNullOrUndefined = PrimitiveOrNull | undefined; 12 | type JSONBlob = Record>; 13 | 14 | /** 15 | * Check if a parameter is object-like, assert via TS and throw Error if not object-like 16 | * @param json - unknown parameter to be checked 17 | */ 18 | function assertIsObjectNotArray(json: unknown, errorMessage: string): asserts json is JSONBlob { 19 | if (Array.isArray(json) || typeof json !== 'object' || json === null) { 20 | throw new Error(errorMessage); 21 | } 22 | } 23 | 24 | /** 25 | * Entry point to encode a JSON object to the rest.li spec with URL encoding. 26 | * 27 | * NOTES: 28 | * - `undefined` values will be removed from the passed in JSON. 29 | * - `null` values will be turned into the string 'null'. 30 | * - `true` values will be turned into the string 'true'. 31 | * - `false` values will be turned into the string 'false'. 32 | */ 33 | export function encode(value: any): string { 34 | // This will remove undefined values from an object 35 | const parsedValue: any = JSON.parse(JSON.stringify(value)); 36 | 37 | return encodeAnyType(parsedValue, false); 38 | } 39 | 40 | /** 41 | * Entry point to encode a JSON object to the rest.li spec with body encoding. 42 | */ 43 | export function reducedEncode(value: any): string { 44 | const parsedValue: any = JSON.parse(JSON.stringify(value)); 45 | 46 | return encodeAnyType(parsedValue, true); 47 | } 48 | 49 | /** 50 | * Entry point for serializing an arbitrary map of rest.li objects to querystring 51 | */ 52 | export function paramEncode(json: unknown): string { 53 | if (!json) { 54 | return ''; 55 | } 56 | 57 | const parsedJson: unknown = JSON.parse(JSON.stringify(json)); 58 | 59 | assertIsObjectNotArray(parsedJson, 'You must pass an object to the paramEncode function.'); 60 | 61 | const query = Object.keys(parsedJson).map((property) => { 62 | return `${encodePrimitive(property)}=${encodeAnyType(parsedJson[property], false)}`; 63 | }); 64 | return query.join('&'); 65 | } 66 | 67 | function isRecord(value: JSONBlob | PrimitiveOrNullOrUndefined): value is JSONBlob { 68 | return typeof value === 'object' && value !== null; 69 | } 70 | 71 | /** 72 | * Used to branch based upon value type. 73 | */ 74 | 75 | function encodeAnyType( 76 | value: PrimitiveOrNullOrUndefined[] | JSONBlob | null, 77 | reduced: boolean 78 | ): string; 79 | function encodeAnyType( 80 | value: PrimitiveOrNullOrUndefined, 81 | reduced: boolean 82 | ): PrimitiveOrNullOrUndefined; 83 | function encodeAnyType( 84 | value: JSONBlob | TypeOrArray, 85 | reduced: boolean 86 | ): string | PrimitiveOrNullOrUndefined; 87 | function encodeAnyType( 88 | value: JSONBlob | TypeOrArray, 89 | reduced: boolean 90 | ): string | PrimitiveOrNullOrUndefined { 91 | if (Array.isArray(value)) { 92 | return encodeArray(value, reduced); 93 | } else if (isRecord(value)) { 94 | return encodeObject(value, reduced); 95 | } else { 96 | return encodePrimitive(value, reduced); 97 | } 98 | } 99 | 100 | /** 101 | * Escapes an array. 102 | */ 103 | function encodeArray(value: PrimitiveOrNullOrUndefined[], reduced: boolean): string { 104 | const nested = new Array(value.length); 105 | for (let i = 0; i < value.length; i++) { 106 | nested[i] = encodeAnyType(value[i], reduced); 107 | } 108 | return `${LIST_PREFIX}${nested.join(',')}${LIST_SUFFIX}`; 109 | } 110 | 111 | /** 112 | * Escapes an object. 113 | */ 114 | function encodeObject(value: JSONBlob, reduced: boolean): string { 115 | const nested = Object.keys(value).map((property) => { 116 | return `${encodePrimitive(property, reduced)}:${encodeAnyType(value[property], reduced)}`; 117 | }); 118 | return `${OBJ_PREFIX}${nested.join(',')}${OBJ_SUFFIX}`; 119 | } 120 | 121 | /** 122 | * Escapes a primitive value. 123 | */ 124 | function encodePrimitive( 125 | value: PrimitiveOrNullOrUndefined, 126 | reduced = false 127 | ): PrimitiveOrNullOrUndefined { 128 | if (value === '') { 129 | return "''"; 130 | } else if (reduced && typeof value === 'string' && possible.test(value)) { 131 | return value.replace(badChars, escape); 132 | } else if (!reduced) { 133 | // TODO avoid casting here. encodeURIComponent type is not correct as it actually accepts null and undefined 134 | return encodeURIComponent(value as string).replace(badChars, escape); 135 | } else { 136 | return value; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /lib/utils/patch-generator.ts: -------------------------------------------------------------------------------- 1 | const isString = (val) => typeof val === 'string'; 2 | const isNumber = (val) => typeof val === 'number'; 3 | const isBoolean = (val) => typeof val === 'boolean'; 4 | const isObject = (obj) => typeof obj === 'object'; 5 | 6 | const SET = '$set'; 7 | const DELETE = '$delete'; 8 | const PATCH = 'patch'; 9 | 10 | /** 11 | * Pegasus/Restli diff generator - required for partial updates 12 | * https://github.com/linkedin/rest.li/wiki/Rest.li-Protocol#partial-update 13 | */ 14 | 15 | /** 16 | * Determines if a value is empty - null, undefined or an empty string. 17 | * 18 | * @method isEmpty 19 | * @param {*} value 20 | * @return {boolean} 21 | * @private 22 | */ 23 | function isValueEmpty(value) { 24 | return value === null || value === undefined || value === ''; 25 | } 26 | 27 | /** 28 | * Stores the value as a DELETE. 29 | * 30 | * @method storeAsDeleteOp 31 | * @param {Object} obj 32 | * @param {string| number} key 33 | * @private 34 | */ 35 | function storeAsDeleteOp(obj, key) { 36 | if (!(obj && key)) { 37 | return; 38 | } 39 | 40 | obj[DELETE] = obj[DELETE] || []; 41 | obj[DELETE].push(key); 42 | } 43 | 44 | /** 45 | * Stores the value as a SET. 46 | * 47 | * @method storeAsSetOp 48 | * @param {Object} obj 49 | * @param {string| number} key 50 | * @param {*} value 51 | * @private 52 | */ 53 | function storeAsSetOp(obj, key, value) { 54 | if (!(obj && key)) { 55 | return; 56 | } 57 | obj[SET] = obj[SET] || {}; 58 | obj[SET][key] = value; 59 | } 60 | 61 | /** 62 | * Determines if the array (modifiedArr), is indeed modified when 63 | * compared to the originalArr. The arrays are considered different, when: 64 | * 1. Sizes are different 65 | * 2. If sizes are same, at least one item of the array is different from the 66 | * corresponding item in the other array. 67 | * 68 | * @method isArrayModified 69 | * @param {Array} originalArr 70 | * @param {Array} modifiedArr 71 | * @return {boolean} 72 | * @private 73 | */ 74 | function isArrayModified(originalArr, modifiedArr) { 75 | if (!(Array.isArray(originalArr) && Array.isArray(modifiedArr))) { 76 | return false; 77 | } 78 | 79 | const oLength = originalArr.length; 80 | 81 | if (oLength !== modifiedArr.length) { 82 | return true; 83 | } 84 | 85 | for (let oIndex = 0; oIndex < oLength; oIndex = oIndex + 1) { 86 | const originalArrItem = originalArr[oIndex]; 87 | const modifiedArrItem = modifiedArr[oIndex]; 88 | 89 | if ( 90 | isString(originalArrItem) || 91 | isString(modifiedArrItem) || 92 | isNumber(originalArrItem) || 93 | isNumber(modifiedArrItem) || 94 | isBoolean(originalArrItem) || 95 | isBoolean(modifiedArrItem) 96 | ) { 97 | if (originalArrItem !== modifiedArrItem) { 98 | if (typeof originalArrItem !== typeof modifiedArrItem) { 99 | console.error('Modified changes have diffirent primitive types'); 100 | } 101 | 102 | return true; 103 | } 104 | } else { 105 | const arrDiff = generateDiff(originalArrItem, modifiedArrItem); 106 | 107 | if (arrDiff !== null) { 108 | return true; 109 | } 110 | } 111 | } 112 | 113 | return false; 114 | } 115 | 116 | /** 117 | * Returns the updated diff between two values. 118 | * 119 | * @method getUpdatedDiff 120 | * @param {*} oValue 121 | * @param {*} mValue 122 | * @param {string} oKey 123 | * @param {Object} diff 124 | * @return {Object} 125 | * @private 126 | */ 127 | function getUpdatedDiff({ oValue, mValue, oKey, diff }) { 128 | let updatedDiff = diff; 129 | 130 | if (isObject(oValue) && isObject(mValue)) { 131 | const subDiff = generateDiff(oValue, mValue); 132 | 133 | if (subDiff !== null) { 134 | updatedDiff = updatedDiff || {}; 135 | updatedDiff[oKey] = subDiff; 136 | } 137 | } else if (Array.isArray(oValue) && Array.isArray(mValue)) { 138 | if (isArrayModified(oValue, mValue)) { 139 | updatedDiff = updatedDiff || {}; 140 | storeAsSetOp(updatedDiff, oKey, mValue); 141 | } 142 | } else if (oValue !== mValue) { 143 | updatedDiff = updatedDiff || {}; 144 | storeAsSetOp(updatedDiff, oKey, mValue); 145 | } 146 | 147 | return updatedDiff; 148 | } 149 | 150 | /** 151 | * @method generateDiff 152 | * @param {Object} original 153 | * @param {Object} modified 154 | * @return {Object} 155 | * @private 156 | */ 157 | function generateDiff(original, modified) { 158 | let diff = null; 159 | 160 | let oValue; 161 | 162 | let mValue; 163 | 164 | if (!(original && modified)) { 165 | return diff; 166 | } 167 | 168 | const oKeys = Object.keys(original); 169 | 170 | oKeys.forEach((oKey) => { 171 | oValue = original[oKey]; 172 | mValue = modified[oKey]; 173 | 174 | if (!isValueEmpty(oValue)) { 175 | if (isValueEmpty(mValue)) { 176 | // Key has been removed 177 | diff = diff || {}; 178 | storeAsDeleteOp(diff, oKey); 179 | } else { 180 | // Key exists, compare the two values for diffs 181 | diff = getUpdatedDiff({ oValue, mValue, oKey, diff }); 182 | } 183 | } 184 | }); 185 | 186 | const mKeys = Object.keys(modified); 187 | 188 | mKeys.forEach((mKey) => { 189 | mValue = modified[mKey]; 190 | oValue = original[mKey]; 191 | 192 | if (!isValueEmpty(mValue) && isValueEmpty(oValue)) { 193 | // New key has been added 194 | diff = diff || {}; 195 | storeAsSetOp(diff, mKey, mValue); 196 | } 197 | }); 198 | 199 | return diff; 200 | } 201 | 202 | /** 203 | * Generates a pegasus/restli diff for two Objects. 204 | * For more information about the format, read: 205 | * https://github.com/linkedin/rest.li/wiki/Rest.li-Protocol#partial-update 206 | * 207 | * @method getDiff 208 | * @param {Object} original 209 | * @param {Object} modified 210 | * @return {Object} 211 | */ 212 | export function getPatchObject(original, modified) { 213 | return { 214 | [PATCH]: generateDiff(original, modified) 215 | }; 216 | } 217 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | LinkedIn API Development Software License Agreement 2 | ===================== 3 | 4 | This License Agreement ("**Agreement**") is a binding legal agreement between you (as an individual or entity, as applicable) and LinkedIn Corporation (“**LinkedIn**”). By downloading or using the LinkedIn API development software in this repository (“**Licensed Materials**”), you agree to be bound by the terms of this Agreement. If you do not agree to these terms, do not download or use the Licensed Materials. These Licensed Materials are being made available to you to facilitate your integration of the LinkedIn APIs into your applications, solely to enable the use cases permitted under your existing agreement with LinkedIn governing your use of the LinkedIn APIs (“**API Agreement**”). 5 | 6 | **1. Scope of the License.** 7 | 8 | * **a.** The Licensed Materials are licensed to you pursuant to the license you receive to the LinkedIn APIs (as specified in the API Agreement) and subject to the terms of this Agreement and the API Agreement. If, with respect to the Licensed Materials, there is a conflict between this Agreement and the API Agreement, this Agreement shall control. 9 | * **b.** You may not fork or copy the Licensed Materials to a GitHub public repository (except to the extent you are granted any such right pursuant to the GitHub Terms of Service). 10 | 11 | **2. Restrictions** 12 | 13 | * **a.** You may not use, modify, copy, make derivative works of, publish, distribute, rent, lease, sell, sublicense, assign, make publicly available, or otherwise transfer the Licensed Materials, except as expressly set forth above in Section 1. 14 | * **b.** You may not imply you own the Licensed Materials or that you have the authority to grant rights to the Licensed Materials to third parties. 15 | * **c.** Linkedin (and its licensors) retains its intellectual property rights in the Licensed Materials. Except as expressly set forth in Section 1, LinkedIn grants no licenses. 16 | * **d.** You will defend, hold harmless, and indemnify LinkedIn and its affiliates (and our and their respective employees, shareholders, and directors) from any claim or action brought by a third party, including all damages, liabilities, costs and expenses, including reasonable attorneys’ fees, to the extent resulting from, alleged to have resulted from, or in connection with: 1) your breach of your obligations herein; or 2) your use or distribution of any Licensed Materials, including any alleged infringement or misappropriation of any third party’s intellectual property rights based on modifications or derivative works you make to/from the Licensed Materials. 17 | 18 | **3. Open source.** This code may include open source software, which may be subject to other license terms as provided in the files. 19 | 20 | **4. Warranty Disclaimer.** LINKEDIN PROVIDES THE LICENSED MATERIALS ON AN “AS IS” AND “AS AVAILABLE” BASIS. LINKEDIN MAKES NO REPRESENTATION OR WARRANTY, WHETHER EXPRESS OR IMPLIED, ABOUT THE LICENSED MATERIALS, INCLUDING ANY REPRESENTATION THAT THE LICENSED MATERIALS WILL BE FREE OF ERRORS, BUGS OR INTERRUPTIONS, OR THAT THE LICENSED MATERIALS ARE ACCURATE, COMPLETE OR OTHERWISE VALID. TO THE FULLEST EXTENT PERMITTED BY LAW, LINKEDIN AND ITS AFFILIATES DISCLAIM ANY IMPLIED OR STATUTORY WARRANTY OR CONDITION, INCLUDING ANY IMPLIED WARRANTY OR CONDITION OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE, AVAILABILITY, SECURITY, TITLE AND/OR NON-INFRINGEMENT. YOUR USE OF THE LICENSED MATERIALS IS AT YOUR OWN DISCRETION AND RISK, AND YOU WILL BE SOLELY RESPONSIBLE FOR ANY DAMAGE THAT RESULTS FROM USE OF THE LICENSED MATERIALS OR LOSS OF DATA. NO ADVICE OR INFORMATION, WHETHER ORAL OR WRITTEN, OBTAINED BY YOU FROM US OR THROUGH OR FROM THE LICENSED MATERIALS WILL CREATE ANY WARRANTY OR CONDITION NOT EXPRESSLY STATED IN THIS AGREEMENT. 21 | 22 | **5. Limitation of Liability.** LINKEDIN SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, PUNITIVE, CONSEQUENTIAL OR EXEMPLARY DAMAGES, INCLUDING BUT NOT LIMITED TO, DAMAGES FOR LOSS OF PROFITS, REVENUE, GOODWILL, USE, DATA OR OTHER INTANGIBLE LOSSES. IN NO EVENT WILL LINKEDIN'S AGGREGATE LIABILITY TO YOU EXCEED $100. THIS LIMITATION OF LIABILITY SHALL: 23 | 24 | * **a.** APPLY REGARDLESS OF WHETHER (A) YOU BASE YOUR CLAIM ON CONTRACT, TORT, STATUTE, OR ANY OTHER LEGAL THEORY, (B) WE KNEW OR SHOULD HAVE KNOWN ABOUT THE POSSIBILITY OF SUCH DAMAGES, OR (C) THE LIMITED REMEDIES PROVIDED IN THIS SECTION FAIL OF THEIR ESSENTIAL PURPOSE; AND 25 | * **b.** NOT APPLY TO ANY DAMAGE THAT IS OTHERWISE MANDATED BY APPLICABLE LAW AND THAT CANNOT BE DISCLAIMED IN THIS AGREEMENT. 26 | 27 | **6. Termination.** This Agreement automatically terminates upon your breach of this Agreement or termination of the API Agreement. On termination, all licenses granted under this Agreement will terminate immediately and you will delete the Licensed Materials. Sections 2-7 of this Agreement survive any termination of this Agreement. LinkedIn may discontinue the availability of some or all of the Licensed Materials at any time for any reason. 28 | 29 | **7. Miscellaneous.** This Agreement will be governed by and construed in accordance with the laws of the State of California without regard to conflict of laws principles. The exclusive forum for any disputes arising out of or relating to this Agreement shall be an appropriate federal or state court sitting in the County of Santa Clara, State of California. If LinkedIn does not act to enforce a breach of this Agreement, that does not mean that LinkedIn has waived its right to enforce this Agreement. This Agreement does not create a partnership, agency relationship, or joint venture between the parties. Neither party has the power or authority to bind the other or to create any obligation or responsibility on behalf of the other. This Agreement shall bind and inure to the benefit of the parties, and any permitted successors and assigns under the API Agreement. If any provision of this Agreement is unenforceable, that provision will be modified to render it enforceable to the extent possible to give effect to the parties’ intentions and the remaining provisions will not be affected. This Agreement, together with the API Agreement, are the only agreements between you and LinkedIn regarding the Licensed Materials, and supersedes all prior agreements relating to the Licensed Materials. The API Agreement will continue to govern your use of the LinkedIn APIs, SDKs, and other technology. -------------------------------------------------------------------------------- /lib/utils/decoder.ts: -------------------------------------------------------------------------------- 1 | import { LIST_PREFIX, LIST_SUFFIX, OBJ_PREFIX, OBJ_SUFFIX } from './constants'; 2 | 3 | // rest.li special characters: ,()': 4 | const escapedChars = /(%2C|%28|%29|%27|%3A)/g; 5 | const testEscapedChars = /(%2C|%28|%29|%27|%3A)/; 6 | 7 | type StringOrStringObject = string | string[] | Record; 8 | 9 | /** 10 | * Polyfill startsWith for IE11 11 | * 12 | * @param {string} str 13 | * @param {string} search 14 | * @param {number} [pos] 15 | * @returns {boolean} 16 | */ 17 | function strStartsWith(str: string, search: string, pos = 0): boolean { 18 | return str.indexOf(search, pos) === pos; 19 | } 20 | 21 | /** 22 | * Validate that input ends with a specified suffix. 23 | * The suffix has to be a single-character string. 24 | * 25 | * @param {string} serializedrestli 26 | * @param {string} suffix a single-character string 27 | */ 28 | function validateSuffix(serializedrestli: string, suffix: string): void { 29 | if (serializedrestli[serializedrestli.length - 1] !== suffix) { 30 | throw new Error(`Input has unbalanced prefix and suffix: ${serializedrestli}`); 31 | } 32 | } 33 | 34 | /** 35 | * Find Last bracket to match, starting from pos 36 | * 37 | * @param {string} str 38 | * @param {number} [pos=0] 39 | * @returns {number} 40 | */ 41 | function findLastRightBracket(str: string, pos = 0): number { 42 | let numLeft = 0; 43 | let hasMetFirst = false; 44 | const L = '('; 45 | const R = ')'; 46 | while (pos < str.length) { 47 | const currChar = str[pos]; 48 | if (currChar === L) { 49 | numLeft++; 50 | hasMetFirst = true; 51 | } 52 | if (currChar === R) numLeft--; 53 | if (numLeft === 0 && hasMetFirst) break; 54 | pos++; 55 | } 56 | return pos; 57 | } 58 | 59 | /** 60 | * Reverse the rest.li escaping, called during the decoding. 61 | */ 62 | function restliUnescape(value: string, reduced: boolean): string { 63 | if (!reduced) { 64 | value = decodeURIComponent(value); 65 | } else if (testEscapedChars.test(value)) { 66 | value = value.replace(escapedChars, unescape); 67 | } 68 | return value === undefined || value === "''" ? '' : value; 69 | } 70 | 71 | export function paramDecode(querystring: string): Record { 72 | return querystring 73 | .split('&') 74 | .reduce(function (previous: Record, current: string) { 75 | // Short circuit if there isn't a key. 76 | if (!current.length) { 77 | return previous; 78 | } 79 | if (current.indexOf('=') === 0) { 80 | return previous; 81 | } 82 | 83 | let [key = '', value] = current.split('='); 84 | 85 | // Rest.li special-cases empty strings. 86 | if (key === "''") { 87 | key = ''; 88 | } 89 | if (value === undefined || value === '') { 90 | value = "''"; 91 | } 92 | 93 | previous[decodeURIComponent(key)] = decode(value); 94 | return previous; 95 | }, {}); 96 | } 97 | 98 | /** 99 | * Entry point to decode a URL encoded rest.li object. 100 | * 101 | * NOTES: 102 | * - The Rest.li format is lossy. All values come out of this as strings. 103 | */ 104 | export function decode(serializedrestli: string): StringOrStringObject { 105 | return internalDecode(serializedrestli, false); 106 | } 107 | 108 | /** 109 | * Entry point to decode a body encoded rest.li object. 110 | */ 111 | export function reducedDecode(serializedrestli: string): StringOrStringObject { 112 | return internalDecode(serializedrestli, true); 113 | } 114 | 115 | function internalDecode( 116 | serializedrestli: string | undefined, 117 | reduced: boolean 118 | ): StringOrStringObject { 119 | if (serializedrestli === undefined || serializedrestli === "''") { 120 | serializedrestli = ''; 121 | } 122 | if (strStartsWith(serializedrestli, LIST_PREFIX)) { 123 | validateSuffix(serializedrestli, LIST_SUFFIX); 124 | return decodeList(serializedrestli.substring(5, serializedrestli.length - 1), reduced); 125 | } else if (strStartsWith(serializedrestli, OBJ_PREFIX)) { 126 | validateSuffix(serializedrestli, OBJ_SUFFIX); 127 | return decodeObject(serializedrestli.substring(1, serializedrestli.length - 1), reduced); 128 | } else { 129 | return restliUnescape(serializedrestli, reduced); 130 | } 131 | } 132 | 133 | /** 134 | * @param {string} list e.g. 1,2,(k:v),3 135 | * @param {boolean} reduced 136 | * @returns {Array<*>} 137 | */ 138 | function decodeList(str: string, reduced = false): string[] { 139 | const retList = []; 140 | let idx = 0; 141 | while (idx < str.length) { 142 | if (strStartsWith(str, LIST_PREFIX, idx) || strStartsWith(str, OBJ_PREFIX, idx)) { 143 | const rightBracketIdx = findLastRightBracket(str, idx); 144 | retList.push( 145 | internalDecode(str.substring(idx, rightBracketIdx + 1), reduced) as string // TODO type overload _decode so we don't need this cast 146 | ); 147 | idx = rightBracketIdx + 2; // skip the next comma 148 | continue; 149 | } 150 | let endIdx = str.indexOf(',', idx); 151 | if (endIdx < 0) endIdx = str.length; 152 | retList.push(restliUnescape(str.substring(idx, endIdx), reduced)); 153 | idx = endIdx + 1; 154 | } 155 | return retList; 156 | } 157 | 158 | /** 159 | * @param {string} str e.g. k1:v1,k2:List(1,2,3),k3:v3 160 | * @param {boolean} reduced 161 | * @returns {Object} 162 | */ 163 | function decodeObject(str: string, reduced = false): Record { 164 | const retObj: Record = {}; 165 | let idx = 0; 166 | while (idx < str.length) { 167 | const colonIdx = str.indexOf(':', idx); 168 | const key = restliUnescape(str.substring(idx, colonIdx), reduced); 169 | idx = colonIdx + 1; 170 | if (str.startsWith(LIST_PREFIX, idx) || str.startsWith(OBJ_PREFIX, idx)) { 171 | const rightBracketIdx = findLastRightBracket(str, idx); 172 | retObj[key] = internalDecode(str.substring(idx, rightBracketIdx + 1), reduced) as string; // TODO type overload _decode so we don't need this cast 173 | idx = rightBracketIdx + 2; // skip the next comma 174 | continue; 175 | } 176 | let endIdx = str.indexOf(',', idx); 177 | if (endIdx < 0) endIdx = str.length; 178 | const value = restliUnescape(str.substring(idx, endIdx), reduced); 179 | retObj[key] = value; 180 | idx = endIdx + 1; 181 | } 182 | return retObj; 183 | } 184 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | module.exports = { 7 | // All imported modules in your tests should be mocked automatically 8 | // automock: false, 9 | 10 | // Stop running tests after `n` failures 11 | // bail: 0, 12 | 13 | // The directory where Jest should store its cached dependency information 14 | // cacheDirectory: "/private/var/folders/fp/2qwlxf5n2sn2v32pm6f_h27c000vxk/T/jest_m2a", 15 | 16 | // Automatically clear mock calls, instances, contexts and results before every test 17 | clearMocks: true, 18 | 19 | // Indicates whether the coverage information should be collected while executing the test 20 | collectCoverage: true, 21 | 22 | // An array of glob patterns indicating a set of files for which coverage information should be collected 23 | // collectCoverageFrom: undefined, 24 | 25 | // The directory where Jest should output its coverage files 26 | coverageDirectory: 'coverage', 27 | 28 | // An array of regexp pattern strings used to skip coverage collection 29 | // coveragePathIgnorePatterns: [ 30 | // "/node_modules/" 31 | // ], 32 | 33 | // Indicates which provider should be used to instrument code for coverage 34 | coverageProvider: 'v8', 35 | 36 | // A list of reporter names that Jest uses when writing coverage reports 37 | // coverageReporters: [ 38 | // "json", 39 | // "text", 40 | // "lcov", 41 | // "clover" 42 | // ], 43 | 44 | // An object that configures minimum threshold enforcement for coverage results 45 | // coverageThreshold: undefined, 46 | 47 | // A path to a custom dependency extractor 48 | // dependencyExtractor: undefined, 49 | 50 | // Make calling deprecated APIs throw helpful error messages 51 | // errorOnDeprecated: false, 52 | 53 | // The default configuration for fake timers 54 | // fakeTimers: { 55 | // "enableGlobally": false 56 | // }, 57 | 58 | // Force coverage collection from ignored files using an array of glob patterns 59 | // forceCoverageMatch: [], 60 | 61 | // A path to a module which exports an async function that is triggered once before all test suites 62 | // globalSetup: undefined, 63 | 64 | // A path to a module which exports an async function that is triggered once after all test suites 65 | // globalTeardown: undefined, 66 | 67 | // A set of global variables that need to be available in all test environments 68 | // globals: {}, 69 | 70 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 71 | // maxWorkers: "50%", 72 | 73 | // An array of directory names to be searched recursively up from the requiring module's location 74 | // moduleDirectories: [ 75 | // "node_modules" 76 | // ], 77 | 78 | // An array of file extensions your modules use 79 | // moduleFileExtensions: [ 80 | // "js", 81 | // "mjs", 82 | // "cjs", 83 | // "jsx", 84 | // "ts", 85 | // "tsx", 86 | // "json", 87 | // "node" 88 | // ], 89 | 90 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 91 | // moduleNameMapper: {}, 92 | 93 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 94 | // modulePathIgnorePatterns: [], 95 | 96 | // Activates notifications for test results 97 | // notify: false, 98 | 99 | // An enum that specifies notification mode. Requires { notify: true } 100 | // notifyMode: "failure-change", 101 | 102 | // A preset that is used as a base for Jest's configuration 103 | // preset: undefined, 104 | 105 | // Run tests from one or more projects 106 | // projects: undefined, 107 | 108 | // Use this configuration option to add custom reporters to Jest 109 | // reporters: undefined, 110 | 111 | // Automatically reset mock state before every test 112 | // resetMocks: false, 113 | 114 | // Reset the module registry before running each individual test 115 | // resetModules: false, 116 | 117 | // A path to a custom resolver 118 | // resolver: undefined, 119 | 120 | // Automatically restore mock state and implementation before every test 121 | // restoreMocks: false, 122 | 123 | // The root directory that Jest should scan for tests and modules within 124 | // rootDir: undefined, 125 | 126 | // A list of paths to directories that Jest should use to search for files in 127 | // roots: [ 128 | // "" 129 | // ], 130 | 131 | // Allows you to use a custom runner instead of Jest's default test runner 132 | // runner: "jest-runner", 133 | 134 | // The paths to modules that run some code to configure or set up the testing environment before each test 135 | // setupFiles: [], 136 | 137 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 138 | // setupFilesAfterEnv: [], 139 | 140 | // The number of seconds after which a test is considered as slow and reported as such in the results. 141 | // slowTestThreshold: 5, 142 | 143 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 144 | // snapshotSerializers: [], 145 | 146 | // The test environment that will be used for testing 147 | // testEnvironment: "jest-environment-node", 148 | 149 | // Options that will be passed to the testEnvironment 150 | // testEnvironmentOptions: {}, 151 | 152 | // Adds a location field to test results 153 | // testLocationInResults: false, 154 | 155 | // The glob patterns Jest uses to detect test files 156 | // testMatch: [ 157 | // "**/__tests__/**/*.[jt]s?(x)", 158 | // "**/?(*.)+(spec|test).[tj]s?(x)" 159 | // ], 160 | 161 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 162 | // testPathIgnorePatterns: [ 163 | // "/node_modules/" 164 | // ], 165 | 166 | // The regexp pattern or array of patterns that Jest uses to detect test files 167 | // testRegex: [], 168 | 169 | // This option allows the use of a custom results processor 170 | // testResultsProcessor: undefined, 171 | 172 | // This option allows use of a custom test runner 173 | // testRunner: "jest-circus/runner", 174 | 175 | // A map from regular expressions to paths to transformers 176 | // transform: undefined, 177 | 178 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 179 | // transformIgnorePatterns: [ 180 | // "/node_modules/", 181 | // "\\.pnp\\.[^\\/]+$" 182 | // ], 183 | 184 | modulePaths: [''] 185 | 186 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 187 | // unmockedModulePathPatterns: undefined, 188 | 189 | // Indicates whether each individual test should be reported during the run 190 | // verbose: undefined, 191 | 192 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 193 | // watchPathIgnorePatterns: [], 194 | 195 | // Whether to use watchman for file crawling 196 | // watchman: true, 197 | }; 198 | -------------------------------------------------------------------------------- /lib/auth.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { HEADERS, CONTENT_TYPE, HTTP_METHODS, OAUTH_BASE_URL } from './utils/constants'; 3 | import qs from 'qs'; 4 | import { generateMemberAuthorizationUrl } from './utils/oauth-utils'; 5 | 6 | export interface AccessToken2LResponse { 7 | /** The two-legged access token */ 8 | access_token: string; 9 | /** The TTL of the access token, in seconds */ 10 | expires_in: number; 11 | } 12 | 13 | export interface RefreshTokenExchangeResponse { 14 | /** The 3-legged access token */ 15 | access_token: string; 16 | /** The TTL for the access token, in seconds */ 17 | expires_in: number; 18 | /** The refresh token value */ 19 | refresh_token: string; 20 | /** The TTL for the refresh token, in seconds */ 21 | refresh_token_expires_in: number; 22 | } 23 | 24 | export interface AccessToken3LResponse { 25 | /** The 3-legged access token */ 26 | access_token: string; 27 | /** The TTL for the access token, in seconds */ 28 | expires_in: number; 29 | /** The refresh token value */ 30 | refresh_token?: string; 31 | /** The TTL for the refresh token, in seconds */ 32 | refresh_token_expires_in?: number; 33 | /** A comma-separated list of scopes authorized by the member (e.g. "r_liteprofile,r_ads") */ 34 | scope: string; 35 | } 36 | 37 | enum TokenAuthType { 38 | /** 2-legged application token */ 39 | TWO_LEGGED = '2L', 40 | /** 3-legged member token */ 41 | THREE_LEGGED = '3L', 42 | /** Enterprise member token */ 43 | ENTERPRISE = 'Enterprise_User' 44 | } 45 | 46 | enum TokenStatus { 47 | /** Token has been revoked */ 48 | REVOKED = 'revoked', 49 | /** Token has expired */ 50 | EXPIRED = 'expired', 51 | /** Token is active */ 52 | ACTIVE = 'active' 53 | } 54 | 55 | export interface IntrospectTokenResponse { 56 | /** Flag whether the token is a valid, active token. */ 57 | active: boolean; 58 | /** The auth type of the token */ 59 | auth_type: TokenAuthType; 60 | /** Epoch time in seconds, indicating when the token was authorized */ 61 | authorized_at?: number; 62 | /** Developer application client ID */ 63 | client_id?: string; 64 | /** Epoch time in seconds, indicating when this token was originally issued */ 65 | created_at: number; 66 | /** Epoch time in seconds, indicating when this token will expire */ 67 | expires_at?: number; 68 | /** A string containing a comma-separated list of scopes associated with this token. This is only returned for 3-legged member tokens. */ 69 | scope?: string; 70 | /** The token status */ 71 | status?: TokenStatus; 72 | } 73 | 74 | /** 75 | * A simple auth client for managing OAuth 2.0 authorization flows for LinkedIn APIs. 76 | */ 77 | export class AuthClient { 78 | clientId: string; 79 | clientSecret: string; 80 | redirectUrl: string; 81 | 82 | constructor(params: { 83 | /** The client ID of the developer application. */ 84 | clientId: string; 85 | /** The client secret of the developer application. */ 86 | clientSecret: string; 87 | /** The redirect URL. This URL is used in the authorization code flow (3-legged OAuth). 88 | * Users will be redirected to this URL after authorization. */ 89 | redirectUrl?: string; 90 | }) { 91 | this.clientId = params.clientId; 92 | this.clientSecret = params.clientSecret; 93 | this.redirectUrl = params.redirectUrl; 94 | } 95 | 96 | /** 97 | * Use client credential flow (2-legged OAuth) to retrieve a 2-legged access token for 98 | * accessing APIs that are not member-specific. Developer applications do not have the client 99 | * credential flow enabled by default. 100 | * 101 | * @returns A promise that resolves to the 2-legged access token details 102 | */ 103 | async getTwoLeggedAccessToken(): Promise { 104 | const response = await axios.request({ 105 | method: HTTP_METHODS.POST, 106 | url: `${OAUTH_BASE_URL}/accessToken`, 107 | data: qs.stringify({ 108 | grant_type: 'client_credentials', 109 | client_id: this.clientId, 110 | client_secret: this.clientSecret 111 | }), 112 | headers: { 113 | [HEADERS.CONTENT_TYPE]: CONTENT_TYPE.URL_ENCODED 114 | } 115 | }); 116 | return response.data; 117 | } 118 | 119 | /** 120 | * Generates the member authorization URL to direct members to. Once redirected, the member will be 121 | * presented with LinkedIn's OAuth consent page showing the OAuth scopes your application is requesting 122 | * on behalf of the user. 123 | * 124 | * @returns The member authorization URL 125 | */ 126 | generateMemberAuthorizationUrl( 127 | /** An array of OAuth scopes (3-legged member permissions) your application is requesting on behalf of the user. */ 128 | scopes: string[], 129 | /** An optional string that can be provided to test against CSRF attacks. */ 130 | state: string = undefined 131 | ): string { 132 | return generateMemberAuthorizationUrl({ 133 | clientId: this.clientId, 134 | redirectUrl: this.redirectUrl, 135 | state, 136 | scopes 137 | }); 138 | } 139 | 140 | /** 141 | * Exchanges an authorization code for a 3-legged access token. After member authorization, the browser redirects to the 142 | * provided redirect URL, setting the authorization code on the `code` query parameter. 143 | * 144 | * @returns a Promise that resolves to details of the 3-legged access token. 145 | */ 146 | async exchangeAuthCodeForAccessToken( 147 | /** The authorization code to exchange for an access token */ 148 | code: string 149 | ): Promise { 150 | const response = await axios.request({ 151 | method: HTTP_METHODS.POST, 152 | url: `${OAUTH_BASE_URL}/accessToken`, 153 | data: { 154 | grant_type: 'authorization_code', 155 | code, 156 | client_id: this.clientId, 157 | client_secret: this.clientSecret, 158 | redirect_uri: this.redirectUrl 159 | }, 160 | headers: { 161 | [HEADERS.CONTENT_TYPE]: CONTENT_TYPE.URL_ENCODED 162 | } 163 | }); 164 | return response.data; 165 | } 166 | 167 | /** 168 | * Exchanges a refresh token for a new 3-legged access token. This allows access tokens 169 | * to be refreshed without having the member reauthorize your application. 170 | * 171 | * @returns a Promise that resolves to an object containing the details of the new access token 172 | * and refresh token 173 | */ 174 | async exchangeRefreshTokenForAccessToken( 175 | /** The refresh token to exchange for an access token. */ 176 | refreshToken: string 177 | ): Promise { 178 | const response = await axios.request({ 179 | method: HTTP_METHODS.POST, 180 | url: `${OAUTH_BASE_URL}/accessToken`, 181 | data: { 182 | grant_type: 'refresh_token', 183 | refresh_token: refreshToken, 184 | client_id: this.clientId, 185 | client_secret: this.clientSecret 186 | }, 187 | headers: { 188 | [HEADERS.CONTENT_TYPE]: CONTENT_TYPE.URL_ENCODED 189 | } 190 | }); 191 | return response.data; 192 | } 193 | 194 | /** 195 | * Introspect a 2-legged, 3-legged or Enterprise access token to get information on status, 196 | * expiry, and other details. 197 | * 198 | * @returns a Promise that resolves to the token introspection details. 199 | */ 200 | async introspectAccessToken( 201 | /** A 2-legged, 3-legged or Enterprise access token. */ 202 | accessToken: string 203 | ): Promise { 204 | return await axios.request({ 205 | method: HTTP_METHODS.POST, 206 | url: `${OAUTH_BASE_URL}/introspectToken`, 207 | data: { 208 | client_id: this.clientId, 209 | client_secret: this.clientSecret, 210 | token: accessToken 211 | }, 212 | headers: { 213 | [HEADERS.CONTENT_TYPE]: CONTENT_TYPE.URL_ENCODED 214 | } 215 | }); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /tests/restli-client.test.ts: -------------------------------------------------------------------------------- 1 | import { RestliClient } from '../lib/restli-client'; 2 | import { RESTLI_METHOD_TO_HTTP_METHOD_MAP } from '../lib/utils/constants'; 3 | import nock from 'nock'; 4 | import _ from 'lodash'; 5 | 6 | const TEST_BEARER_TOKEN = 'ABC123'; 7 | const NON_VERSIONED_BASE_URL = 'https://api.linkedin.com/v2'; 8 | const VERSIONED_BASE_URL = 'https://api.linkedin.com/rest'; 9 | // 4000 characters 10 | const LONG_STRING = 11 | '421yg4h2cqta89yov4x39ojnzinhhph9y36depvp4f249j5unznzl52jlgok1bxgwt965i58cyd3afdmlxuobebizt3ju7qwrwim9pl5omz4k5dwzkqy6cni9ys7o9w32fl0ysdp4lrwji8dcxi9eqlfb0ym6ykz4r93udolzrw9eci06w55ksqs0zw47jzfx1upe7bishjxdndgp5ya5y61z78ay83xhqakvac8h5b84398o82c93bpnzjrxoggn2xqx6qyrb2dw4s9008wlwcivskni2ztjvcaq0hk2odvrmrijwyzfbf443u0g4jmorgdrqye9ee9bberkx9n7u4m16ekrapvxgkcezhbborbaa5lzjz92c1vgr44cn7olhb7yt0nsrsoug7dzj2c6mv7cady17by66me0cdj9la10o2v1x5yls9tmdp4qlyxgu2o5f83sgezs1570imkzorp7xqjlzrm4zlhq8729ljoqrj5zb2400u5cgty81el9wos2t0p1ghlv0v7izzlskgdpe0dxglbvpdi53ys392p9dp6lta8ms286r0pqvqgjepzzb5s4x5bq5mga1o1iwx2l4qn6oi3wqvr3octwb37s90h3ikw0b1imjko9i1z8b2bn05ud6df0nmkftsx2g3n32zdk8o9rgv428ifbc2n7nspyykljj4f8fc7xyhbx5aq3bwz6bca3yp8jebaxo92dbbo393cm41mjotdd2wov7agiydl6kv3gk2sa93p8j31bbne6t96gg5zamemcejj468hw1qbed4oiz5xkt4riuqsqawhb7uqgn4fa6ntonymyycgpq0zsuu66cxw011xp3sxehzgkesytivtx08pa0dtbv25xqx78ok9gc2fvockdnzkzpz46kchex2qyn742wty5d1ljsi7ffau5zpi62ntxid5px6zs2yuprc7rhq9s9j4plw0mqs21grdjmhmzgsn2ro640ezuoh0421yg4h2cqta89yov4x39ojnzinhhph9y36depvp4f249j5unznzl52jlgok1bxgwt965i58cyd3afdmlxuobebizt3ju7qwrwim9pl5omz4k5dwzkqy6cni9ys7o9w32fl0ysdp4lrwji8dcxi9eqlfb0ym6ykz4r93udolzrw9eci06w55ksqs0zw47jzfx1upe7bishjxdndgp5ya5y61z78ay83xhqakvac8h5b84398o82c93bpnzjrxoggn2xqx6qyrb2dw4s9008wlwcivskni2ztjvcaq0hk2odvrmrijwyzfbf443u0g4jmorgdrqye9ee9bberkx9n7u4m16ekrapvxgkcezhbborbaa5lzjz92c1vgr44cn7olhb7yt0nsrsoug7dzj2c6mv7cady17by66me0cdj9la10o2v1x5yls9tmdp4qlyxgu2o5f83sgezs1570imkzorp7xqjlzrm4zlhq8729ljoqrj5zb2400u5cgty81el9wos2t0p1ghlv0v7izzlskgdpe0dxglbvpdi53ys392p9dp6lta8ms286r0pqvqgjepzzb5s4x5bq5mga1o1iwx2l4qn6oi3wqvr3octwb37s90h3ikw0b1imjko9i1z8b2bn05ud6df0nmkftsx2g3n32zdk8o9rgv428ifbc2n7nspyykljj4f8fc7xyhbx5aq3bwz6bca3yp8jebaxo92dbbo393cm41mjotdd2wov7agiydl6kv3gk2sa93p8j31bbne6t96gg5zamemcejj468hw1qbed4oiz5xkt4riuqsqawhb7uqgn4fa6ntonymyycgpq0zsuu66cxw011xp3sxehzgkesytivtx08pa0dtbv25xqx78ok9gc2fvockdnzkzpz46kchex2qyn742wty5d1ljsi7ffau5zpi62ntxid5px6zs2yuprc7rhq9s9j4plw0mqs21grdjmhmzgsn2ro640ezuoh0421yg4h2cqta89yov4x39ojnzinhhph9y36depvp4f249j5unznzl52jlgok1bxgwt965i58cyd3afdmlxuobebizt3ju7qwrwim9pl5omz4k5dwzkqy6cni9ys7o9w32fl0ysdp4lrwji8dcxi9eqlfb0ym6ykz4r93udolzrw9eci06w55ksqs0zw47jzfx1upe7bishjxdndgp5ya5y61z78ay83xhqakvac8h5b84398o82c93bpnzjrxoggn2xqx6qyrb2dw4s9008wlwcivskni2ztjvcaq0hk2odvrmrijwyzfbf443u0g4jmorgdrqye9ee9bberkx9n7u4m16ekrapvxgkcezhbborbaa5lzjz92c1vgr44cn7olhb7yt0nsrsoug7dzj2c6mv7cady17by66me0cdj9la10o2v1x5yls9tmdp4qlyxgu2o5f83sgezs1570imkzorp7xqjlzrm4zlhq8729ljoqrj5zb2400u5cgty81el9wos2t0p1ghlv0v7izzlskgdpe0dxglbvpdi53ys392p9dp6lta8ms286r0pqvqgjepzzb5s4x5bq5mga1o1iwx2l4qn6oi3wqvr3octwb37s90h3ikw0b1imjko9i1z8b2bn05ud6df0nmkftsx2g3n32zdk8o9rgv428ifbc2n7nspyykljj4f8fc7xyhbx5aq3bwz6bca3yp8jebaxo92dbbo393cm41mjotdd2wov7agiydl6kv3gk2sa93p8j31bbne6t96gg5zamemcejj468hw1qbed4oiz5xkt4riuqsqawhb7uqgn4fa6ntonymyycgpq0zsuu66cxw011xp3sxehzgkesytivtx08pa0dtbv25xqx78ok9gc2fvockdnzkzpz46kchex2qyn742wty5d1ljsi7ffau5zpi62ntxid5px6zs2yuprc7rhq9s9j4plw0mqs21grdjmhmzgsn2ro640ezuoh0421yg4h2cqta89yov4x39ojnzinhhph9y36depvp4f249j5unznzl52jlgok1bxgwt965i58cyd3afdmlxuobebizt3ju7qwrwim9pl5omz4k5dwzkqy6cni9ys7o9w32fl0ysdp4lrwji8dcxi9eqlfb0ym6ykz4r93udolzrw9eci06w55ksqs0zw47jzfx1upe7bishjxdndgp5ya5y61z78ay83xhqakvac8h5b84398o82c93bpnzjrxoggn2xqx6qyrb2dw4s9008wlwcivskni2ztjvcaq0hk2odvrmrijwyzfbf443u0g4jmorgdrqye9ee9bberkx9n7u4m16ekrapvxgkcezhbborbaa5lzjz92c1vgr44cn7olhb7yt0nsrsoug7dzj2c6mv7cady17by66me0cdj9la10o2v1x5yls9tmdp4qlyxgu2o5f83sgezs1570imkzorp7xqjlzrm4zlhq8729ljoqrj5zb2400u5cgty81el9wos2t0p1ghlv0v7izzlskgdpe0dxglbvpdi53ys392p9dp6lta8ms286r0pqvqgjepzzb5s4x5bq5mga1o1iwx2l4qn6oi3wqvr3octwb37s90h3ikw0b1imjko9i1z8b2bn05ud6df0nmkftsx2g3n32zdk8o9rgv428ifbc2n7nspyykljj4f8fc7xyhbx5aq3bwz6bca3yp8jebaxo92dbbo393cm41mjotdd2wov7agiydl6kv3gk2sa93p8j31bbne6t96gg5zamemcejj468hw1qbed4oiz5xkt4riuqsqawhb7uqgn4fa6ntonymyycgpq0zsuu66cxw011xp3sxehzgkesytivtx08pa0dtbv25xqx78ok9gc2fvockdnzkzpz46kchex2qyn742wty5d1ljsi7ffau5zpi62ntxid5px6zs2yuprc7rhq9s9j4plw0mqs21grdjmhmzgsn2ro640ezuoh0'; 12 | 13 | interface TestCaseParameters { 14 | description: string; 15 | inputRequestRestliMethod: string; 16 | inputRequestOptions: any; 17 | inputResponse: { 18 | data: any; 19 | headers?: Record; 20 | status: number; 21 | isError?: boolean; 22 | }; 23 | expectedRequest: { 24 | baseUrl: string; 25 | path: string; 26 | overrideMethod?: string; 27 | body?: any; 28 | additionalHeaders?: Record; 29 | }; 30 | } 31 | 32 | describe('RestliClient', () => { 33 | /** 34 | * Tests for basic functionality of RestliClient methods, providing the 35 | * input request options and method type and expected response. 36 | */ 37 | test.each([ 38 | /** 39 | * GET Method 40 | */ 41 | { 42 | description: 'Get request for a non-versioned collection resource', 43 | inputRequestRestliMethod: 'GET', 44 | inputRequestOptions: { 45 | resourcePath: '/adAccounts/{id}', 46 | pathKeys: { 47 | id: 123 48 | }, 49 | accessToken: TEST_BEARER_TOKEN 50 | }, 51 | inputResponse: { 52 | data: { name: 'TestAdAccount' }, 53 | status: 200 54 | }, 55 | expectedRequest: { 56 | baseUrl: NON_VERSIONED_BASE_URL, 57 | path: '/adAccounts/123' 58 | } 59 | }, 60 | { 61 | description: 'Get request for a simple resource', 62 | inputRequestRestliMethod: 'GET', 63 | inputRequestOptions: { 64 | resourcePath: '/me', 65 | accessToken: TEST_BEARER_TOKEN 66 | }, 67 | inputResponse: { 68 | data: { name: 'Jojo' }, 69 | status: 200 70 | }, 71 | expectedRequest: { 72 | baseUrl: NON_VERSIONED_BASE_URL, 73 | path: '/me' 74 | } 75 | }, 76 | { 77 | description: 'Get request for versioned collection resource', 78 | inputRequestRestliMethod: 'GET', 79 | inputRequestOptions: { 80 | resourcePath: '/adAccounts/{id}', 81 | pathKeys: { 82 | id: 123 83 | }, 84 | versionString: '202209', 85 | accessToken: TEST_BEARER_TOKEN 86 | }, 87 | inputResponse: { 88 | data: { name: 'TestAdAccount' }, 89 | status: 200 90 | }, 91 | expectedRequest: { 92 | baseUrl: VERSIONED_BASE_URL, 93 | path: '/adAccounts/123', 94 | additionalHeaders: { 95 | 'linkedin-version': '202209' 96 | } 97 | } 98 | }, 99 | { 100 | description: 'Get request with complex key and query parameters', 101 | inputRequestRestliMethod: 'GET', 102 | inputRequestOptions: { 103 | resourcePath: '/accountRoles/{key}', 104 | pathKeys: { 105 | key: { member: 'urn:li:person:123', account: 'urn:li:account:234' } 106 | }, 107 | queryParams: { 108 | param1: 'foobar', 109 | param2: { prop1: 'abc', prop2: 'def' } 110 | }, 111 | accessToken: TEST_BEARER_TOKEN 112 | }, 113 | inputResponse: { 114 | data: { name: 'Steven' }, 115 | status: 200 116 | }, 117 | expectedRequest: { 118 | baseUrl: NON_VERSIONED_BASE_URL, 119 | path: '/accountRoles/(member:urn%3Ali%3Aperson%3A123,account:urn%3Ali%3Aaccount%3A234)?param1=foobar¶m2=(prop1:abc,prop2:def)' 120 | } 121 | }, 122 | { 123 | description: 'Get request with field projections', 124 | inputRequestRestliMethod: 'GET', 125 | inputRequestOptions: { 126 | resourcePath: '/me', 127 | queryParams: { 128 | fields: 'id,firstName,lastName', 129 | otherParam: [1, 2, 3] 130 | }, 131 | accessToken: TEST_BEARER_TOKEN 132 | }, 133 | inputResponse: { 134 | data: { name: 'Jojo' }, 135 | status: 200 136 | }, 137 | expectedRequest: { 138 | baseUrl: NON_VERSIONED_BASE_URL, 139 | path: '/me?otherParam=List(1,2,3)&fields=id,firstName,lastName' 140 | } 141 | }, 142 | { 143 | description: 'Get request with error response', 144 | inputRequestRestliMethod: 'GET', 145 | inputRequestOptions: { 146 | resourcePath: '/adAccounts/{id}', 147 | pathKeys: { 148 | id: 123 149 | }, 150 | accessToken: TEST_BEARER_TOKEN 151 | }, 152 | inputResponse: { 153 | data: { 154 | status: 429, 155 | code: 'QUOTA_EXCEEDED', 156 | message: 'Daily request quota exceeded' 157 | }, 158 | status: 429, 159 | isError: true 160 | }, 161 | expectedRequest: { 162 | baseUrl: NON_VERSIONED_BASE_URL, 163 | path: '/adAccounts/123' 164 | } 165 | }, 166 | { 167 | description: 'Get request with query tunneling', 168 | inputRequestRestliMethod: 'GET', 169 | inputRequestOptions: { 170 | resourcePath: '/adAccounts/{id}', 171 | pathKeys: { 172 | id: 123 173 | }, 174 | queryParams: { 175 | longParam: LONG_STRING 176 | }, 177 | accessToken: TEST_BEARER_TOKEN 178 | }, 179 | inputResponse: { 180 | data: { name: 'TestAdAccount' }, 181 | status: 200 182 | }, 183 | expectedRequest: { 184 | baseUrl: NON_VERSIONED_BASE_URL, 185 | path: '/adAccounts/123', 186 | overrideMethod: 'post', 187 | body: `longParam=${LONG_STRING}`, 188 | additionalHeaders: { 189 | 'x-http-method-override': 'GET', 190 | 'content-type': 'application/x-www-form-urlencoded' 191 | } 192 | } 193 | }, 194 | 195 | /** 196 | * BATCH_GET Method 197 | */ 198 | { 199 | description: 'Batch get request for a non-versioned collection resource', 200 | inputRequestRestliMethod: 'BATCH_GET', 201 | inputRequestOptions: { 202 | resourcePath: '/testResource', 203 | ids: [123, 456, 789], 204 | accessToken: TEST_BEARER_TOKEN 205 | }, 206 | inputResponse: { 207 | data: { 208 | results: { 209 | 123: { name: 'A' }, 210 | 456: { name: 'B' }, 211 | 789: { name: 'C' } 212 | } 213 | }, 214 | status: 200 215 | }, 216 | expectedRequest: { 217 | baseUrl: NON_VERSIONED_BASE_URL, 218 | path: '/testResource?ids=List(123,456,789)' 219 | } 220 | }, 221 | { 222 | description: 'Batch get request with complex key and query parameters', 223 | inputRequestRestliMethod: 'BATCH_GET', 224 | inputRequestOptions: { 225 | resourcePath: '/testResource', 226 | ids: [ 227 | { member: 'urn:li:person:123', account: 'urn:li:account:234' }, 228 | { member: 'urn:li:person:234', account: 'urn:li:account:345' }, 229 | { member: 'urn:li:person:345', account: 'urn:li:account:456' } 230 | ], 231 | queryParams: { 232 | param1: 'foobar', 233 | param2: { prop1: 'abc', prop2: 'def' } 234 | }, 235 | versionString: '202210', 236 | accessToken: TEST_BEARER_TOKEN 237 | }, 238 | inputResponse: { 239 | data: { 240 | results: { 241 | '(member:urn%3Ali%3Aperson%3A123,account:urn%3Ali%3Aaccount%3A234)': { name: 'A' }, 242 | '(member:urn%3Ali%3Aperson%3A234,account:urn%3Ali%3Aaccount%3A345)': { name: 'B' }, 243 | '(member:urn%3Ali%3Aperson%3A345,account:urn%3Ali%3Aaccount%3A456)': { name: 'C' } 244 | } 245 | }, 246 | status: 200 247 | }, 248 | expectedRequest: { 249 | baseUrl: VERSIONED_BASE_URL, 250 | path: '/testResource?ids=List((member:urn%3Ali%3Aperson%3A123,account:urn%3Ali%3Aaccount%3A234),(member:urn%3Ali%3Aperson%3A234,account:urn%3Ali%3Aaccount%3A345),(member:urn%3Ali%3Aperson%3A345,account:urn%3Ali%3Aaccount%3A456))¶m1=foobar¶m2=(prop1:abc,prop2:def)' 251 | } 252 | }, 253 | { 254 | description: 'Batch get request with error response', 255 | inputRequestRestliMethod: 'BATCH_GET', 256 | inputRequestOptions: { 257 | resourcePath: '/testResource', 258 | ids: [123, 456, 789], 259 | accessToken: TEST_BEARER_TOKEN 260 | }, 261 | inputResponse: { 262 | data: { 263 | status: 429, 264 | code: 'QUOTA_EXCEEDED', 265 | message: 'Daily request quota exceeded' 266 | }, 267 | status: 429, 268 | isError: true 269 | }, 270 | expectedRequest: { 271 | baseUrl: NON_VERSIONED_BASE_URL, 272 | path: '/testResource?ids=List(123,456,789)' 273 | } 274 | }, 275 | { 276 | description: 'Batch get request with query tunneling', 277 | inputRequestRestliMethod: 'BATCH_GET', 278 | inputRequestOptions: { 279 | resourcePath: '/testResource', 280 | ids: [123, 456, 789], 281 | queryParams: { 282 | longParam: LONG_STRING 283 | }, 284 | accessToken: TEST_BEARER_TOKEN 285 | }, 286 | inputResponse: { 287 | data: { 288 | results: { 289 | 123: { name: 'A' }, 290 | 456: { name: 'B' }, 291 | 789: { name: 'C' } 292 | } 293 | }, 294 | status: 200 295 | }, 296 | expectedRequest: { 297 | baseUrl: NON_VERSIONED_BASE_URL, 298 | path: '/testResource', 299 | overrideMethod: 'post', 300 | body: `ids=List(123,456,789)&longParam=${LONG_STRING}`, 301 | additionalHeaders: { 302 | 'x-http-method-override': 'GET', 303 | 'content-type': 'application/x-www-form-urlencoded' 304 | } 305 | } 306 | }, 307 | 308 | /** 309 | * GET_ALL Method 310 | */ 311 | { 312 | description: 'Get all request for a non-versioned collection resource', 313 | inputRequestRestliMethod: 'GET_ALL', 314 | inputRequestOptions: { 315 | resourcePath: '/testResource', 316 | accessToken: TEST_BEARER_TOKEN 317 | }, 318 | inputResponse: { 319 | data: { 320 | elements: [{ name: 'A' }, { name: 'B' }] 321 | }, 322 | status: 200 323 | }, 324 | expectedRequest: { 325 | baseUrl: NON_VERSIONED_BASE_URL, 326 | path: '/testResource' 327 | } 328 | }, 329 | 330 | /** 331 | * CREATE Method 332 | */ 333 | { 334 | description: 'Create request for a non-versioned resource', 335 | inputRequestRestliMethod: 'CREATE', 336 | inputRequestOptions: { 337 | resourcePath: '/testResource', 338 | entity: { 339 | name: 'TestApp1' 340 | }, 341 | accessToken: TEST_BEARER_TOKEN 342 | }, 343 | inputResponse: { 344 | data: null, 345 | status: 201, 346 | headers: { 347 | 'x-restli-id': 123 348 | } 349 | }, 350 | expectedRequest: { 351 | baseUrl: NON_VERSIONED_BASE_URL, 352 | path: '/testResource', 353 | body: { 354 | name: 'TestApp1' 355 | } 356 | } 357 | }, 358 | 359 | /** 360 | * BATCH_CREATE Method 361 | */ 362 | { 363 | description: 'Batch create request for a non-versioned resource', 364 | inputRequestRestliMethod: 'BATCH_CREATE', 365 | inputRequestOptions: { 366 | resourcePath: '/adCampaignGroups', 367 | entities: [ 368 | { 369 | account: 'urn:li:sponsoredAccount:111', 370 | name: 'Test1' 371 | }, 372 | { 373 | account: 'urn:li:sponsoredAccount:222', 374 | name: 'Test2' 375 | } 376 | ], 377 | accessToken: TEST_BEARER_TOKEN 378 | }, 379 | inputResponse: { 380 | data: { 381 | elements: [{ status: 201 }, { status: 400 }] 382 | }, 383 | status: 201 384 | }, 385 | expectedRequest: { 386 | baseUrl: NON_VERSIONED_BASE_URL, 387 | path: '/adCampaignGroups', 388 | body: { 389 | elements: [ 390 | { 391 | account: 'urn:li:sponsoredAccount:111', 392 | name: 'Test1' 393 | }, 394 | { 395 | account: 'urn:li:sponsoredAccount:222', 396 | name: 'Test2' 397 | } 398 | ] 399 | } 400 | } 401 | }, 402 | 403 | /** 404 | * PARTIAL_UPDATE Method 405 | */ 406 | { 407 | description: 'Partial update using original/modified entity', 408 | inputRequestRestliMethod: 'PARTIAL_UPDATE', 409 | inputRequestOptions: { 410 | resourcePath: '/testResource/{id}', 411 | pathKeys: { 412 | id: 123 413 | }, 414 | originalEntity: { 415 | name: 'TestApp1', 416 | organization: 'urn:li:organization:123', 417 | description: 'foobar' 418 | }, 419 | modifiedEntity: { 420 | name: 'TestApp1', 421 | organization: 'urn:li:organization:1234', 422 | description: 'foobar2' 423 | }, 424 | accessToken: TEST_BEARER_TOKEN 425 | }, 426 | inputResponse: { 427 | data: null, 428 | status: 204 429 | }, 430 | expectedRequest: { 431 | baseUrl: NON_VERSIONED_BASE_URL, 432 | path: '/testResource/123', 433 | body: { 434 | patch: { 435 | $set: { 436 | organization: 'urn:li:organization:1234', 437 | description: 'foobar2' 438 | } 439 | } 440 | } 441 | } 442 | }, 443 | { 444 | description: 'Partial update using patchSetObject', 445 | inputRequestRestliMethod: 'PARTIAL_UPDATE', 446 | inputRequestOptions: { 447 | resourcePath: '/testResource/{id}', 448 | pathKeys: { 449 | id: 123 450 | }, 451 | patchSetObject: { 452 | organization: 'urn:li:organization:123', 453 | description: 'foobar' 454 | }, 455 | accessToken: TEST_BEARER_TOKEN 456 | }, 457 | inputResponse: { 458 | data: null, 459 | status: 204 460 | }, 461 | expectedRequest: { 462 | baseUrl: NON_VERSIONED_BASE_URL, 463 | path: '/testResource/123', 464 | body: { 465 | patch: { 466 | $set: { 467 | organization: 'urn:li:organization:123', 468 | description: 'foobar' 469 | } 470 | } 471 | } 472 | } 473 | }, 474 | 475 | /** 476 | * BATCH_PARTIAL_UPDATE Method 477 | */ 478 | { 479 | description: 'Batch partial update using original/modified entities', 480 | inputRequestRestliMethod: 'BATCH_PARTIAL_UPDATE', 481 | inputRequestOptions: { 482 | resourcePath: '/testResource', 483 | ids: ['urn:li:person:123', 'urn:li:person:456'], 484 | originalEntities: [ 485 | { 486 | name: 'North', 487 | description: 'foobar' 488 | }, 489 | { 490 | description: 'foobar' 491 | } 492 | ], 493 | modifiedEntities: [ 494 | { 495 | name: 'South', 496 | description: 'foobar' 497 | }, 498 | { 499 | name: 'East', 500 | description: 'barbaz' 501 | } 502 | ], 503 | versionString: '202210', 504 | accessToken: TEST_BEARER_TOKEN 505 | }, 506 | inputResponse: { 507 | data: { 508 | results: { 509 | 'urn%3Ali%3Aperson%3A123': { status: 204 }, 510 | 'urn%3Ali%3Aperson%3A456': { status: 204 } 511 | } 512 | }, 513 | status: 200 514 | }, 515 | expectedRequest: { 516 | baseUrl: VERSIONED_BASE_URL, 517 | path: '/testResource?ids=List(urn%3Ali%3Aperson%3A123,urn%3Ali%3Aperson%3A456)', 518 | additionalHeaders: { 519 | 'linkedin-version': '202210' 520 | }, 521 | body: { 522 | entities: { 523 | 'urn%3Ali%3Aperson%3A123': { 524 | patch: { 525 | $set: { 526 | name: 'South' 527 | } 528 | } 529 | }, 530 | 'urn%3Ali%3Aperson%3A456': { 531 | patch: { 532 | $set: { 533 | name: 'East', 534 | description: 'barbaz' 535 | } 536 | } 537 | } 538 | } 539 | } 540 | } 541 | }, 542 | { 543 | description: 'Batch partial update using patchSetObjects', 544 | inputRequestRestliMethod: 'BATCH_PARTIAL_UPDATE', 545 | inputRequestOptions: { 546 | resourcePath: '/testResource', 547 | ids: ['urn:li:person:123', 'urn:li:person:456'], 548 | patchSetObjects: [ 549 | { 550 | name: 'Steven', 551 | description: 'foobar' 552 | }, 553 | { 554 | prop1: 123 555 | } 556 | ], 557 | accessToken: TEST_BEARER_TOKEN 558 | }, 559 | inputResponse: { 560 | data: { 561 | results: { 562 | 'urn%3Ali%3Aperson%3A123': { status: 204 }, 563 | 'urn%3Ali%3Aperson%3A456': { status: 204 } 564 | } 565 | }, 566 | status: 200 567 | }, 568 | expectedRequest: { 569 | baseUrl: NON_VERSIONED_BASE_URL, 570 | path: '/testResource?ids=List(urn%3Ali%3Aperson%3A123,urn%3Ali%3Aperson%3A456)', 571 | body: { 572 | entities: { 573 | 'urn%3Ali%3Aperson%3A123': { 574 | patch: { 575 | $set: { 576 | name: 'Steven', 577 | description: 'foobar' 578 | } 579 | } 580 | }, 581 | 'urn%3Ali%3Aperson%3A456': { 582 | patch: { 583 | $set: { 584 | prop1: 123 585 | } 586 | } 587 | } 588 | } 589 | } 590 | } 591 | }, 592 | 593 | /** 594 | * UPDATE Method 595 | */ 596 | { 597 | description: 'Update a simple non-versioned resource', 598 | inputRequestRestliMethod: 'UPDATE', 599 | inputRequestOptions: { 600 | resourcePath: '/testResource', 601 | entity: { 602 | name: 'Steven', 603 | description: 'foobar' 604 | }, 605 | accessToken: TEST_BEARER_TOKEN 606 | }, 607 | inputResponse: { 608 | data: null, 609 | status: 204 610 | }, 611 | expectedRequest: { 612 | baseUrl: NON_VERSIONED_BASE_URL, 613 | path: '/testResource', 614 | body: { 615 | name: 'Steven', 616 | description: 'foobar' 617 | } 618 | } 619 | }, 620 | { 621 | description: 'Update an entity on an versioned, association resource', 622 | inputRequestRestliMethod: 'UPDATE', 623 | inputRequestOptions: { 624 | resourcePath: '/testResource/{key}', 625 | pathKeys: { 626 | key: { 627 | application: 'urn:li:developerApplication:123', 628 | member: 'urn:li:member:456' 629 | } 630 | }, 631 | entity: { 632 | name: 'Steven', 633 | description: 'foobar' 634 | }, 635 | versionString: '202210', 636 | accessToken: TEST_BEARER_TOKEN 637 | }, 638 | inputResponse: { 639 | data: null, 640 | status: 204 641 | }, 642 | expectedRequest: { 643 | baseUrl: VERSIONED_BASE_URL, 644 | additionalHeaders: { 645 | 'linkedin-version': '202210' 646 | }, 647 | path: '/testResource/(application:urn%3Ali%3AdeveloperApplication%3A123,member:urn%3Ali%3Amember%3A456)', 648 | body: { 649 | name: 'Steven', 650 | description: 'foobar' 651 | } 652 | } 653 | }, 654 | 655 | /** 656 | * BATCH_UPDATE Method 657 | */ 658 | { 659 | description: 'Batch update on versioned resource', 660 | inputRequestRestliMethod: 'BATCH_UPDATE', 661 | inputRequestOptions: { 662 | resourcePath: '/testResource', 663 | ids: [ 664 | { 665 | application: 'urn:li:developerApplication:123', 666 | member: 'urn:li:member:321' 667 | }, 668 | { 669 | application: 'urn:li:developerApplication:789', 670 | member: 'urn:li:member:987' 671 | } 672 | ], 673 | entities: [ 674 | { 675 | name: 'foobar' 676 | }, 677 | { 678 | name: 'barbaz' 679 | } 680 | ], 681 | versionString: '202209', 682 | accessToken: TEST_BEARER_TOKEN 683 | }, 684 | inputResponse: { 685 | data: {}, 686 | status: 200 687 | }, 688 | expectedRequest: { 689 | baseUrl: VERSIONED_BASE_URL, 690 | additionalHeaders: { 691 | 'linkedin-version': '202209' 692 | }, 693 | path: '/testResource?ids=List((application:urn%3Ali%3AdeveloperApplication%3A123,member:urn%3Ali%3Amember%3A321),(application:urn%3Ali%3AdeveloperApplication%3A789,member:urn%3Ali%3Amember%3A987))', 694 | body: { 695 | entities: { 696 | '(application:urn%3Ali%3AdeveloperApplication%3A123,member:urn%3Ali%3Amember%3A321)': { 697 | name: 'foobar' 698 | }, 699 | '(application:urn%3Ali%3AdeveloperApplication%3A789,member:urn%3Ali%3Amember%3A987)': { 700 | name: 'barbaz' 701 | } 702 | } 703 | } 704 | } 705 | }, 706 | 707 | /** 708 | * DELETE Method 709 | */ 710 | { 711 | description: 'Delete on a simple resource', 712 | inputRequestRestliMethod: 'DELETE', 713 | inputRequestOptions: { 714 | resourcePath: '/testResource', 715 | accessToken: TEST_BEARER_TOKEN 716 | }, 717 | inputResponse: { 718 | data: {}, 719 | status: 204 720 | }, 721 | expectedRequest: { 722 | baseUrl: NON_VERSIONED_BASE_URL, 723 | path: '/testResource' 724 | } 725 | }, 726 | { 727 | description: 'Delete on a collection resource', 728 | inputRequestRestliMethod: 'DELETE', 729 | inputRequestOptions: { 730 | resourcePath: '/testResource/{id}', 731 | pathKeys: { 732 | id: 123 733 | }, 734 | accessToken: TEST_BEARER_TOKEN 735 | }, 736 | inputResponse: { 737 | data: {}, 738 | status: 204 739 | }, 740 | expectedRequest: { 741 | baseUrl: NON_VERSIONED_BASE_URL, 742 | path: '/testResource/123' 743 | } 744 | }, 745 | 746 | /** 747 | * BATCH_DELETE Method 748 | */ 749 | { 750 | description: 'Batch delete on a non-versioned resource', 751 | inputRequestRestliMethod: 'BATCH_DELETE', 752 | inputRequestOptions: { 753 | resourcePath: '/testResource', 754 | ids: ['urn:li:member:123', 'urn:li:member:456'], 755 | accessToken: TEST_BEARER_TOKEN 756 | }, 757 | inputResponse: { 758 | data: { 759 | results: {} 760 | }, 761 | status: 204 762 | }, 763 | expectedRequest: { 764 | baseUrl: NON_VERSIONED_BASE_URL, 765 | path: '/testResource?ids=List(urn%3Ali%3Amember%3A123,urn%3Ali%3Amember%3A456)' 766 | } 767 | }, 768 | 769 | /** 770 | * FINDER Method 771 | */ 772 | { 773 | description: 'Finder on a non-versioned resource', 774 | inputRequestRestliMethod: 'FINDER', 775 | inputRequestOptions: { 776 | resourcePath: '/testResource', 777 | finderName: 'search', 778 | queryParams: { 779 | search: { 780 | ids: { 781 | values: ['urn:li:entity:123', 'urn:li:entity:456'] 782 | } 783 | } 784 | }, 785 | accessToken: TEST_BEARER_TOKEN 786 | }, 787 | inputResponse: { 788 | data: { 789 | elements: [] 790 | }, 791 | status: 200 792 | }, 793 | expectedRequest: { 794 | baseUrl: NON_VERSIONED_BASE_URL, 795 | path: '/testResource?q=search&search=(ids:(values:List(urn%3Ali%3Aentity%3A123,urn%3Ali%3Aentity%3A456)))' 796 | } 797 | }, 798 | 799 | /** 800 | * BATCH_FINDER Method 801 | */ 802 | { 803 | description: 'Batch finder on a non-versioned resource', 804 | inputRequestRestliMethod: 'BATCH_FINDER', 805 | inputRequestOptions: { 806 | resourcePath: '/testResource', 807 | finderName: 'authActions', 808 | finderCriteria: { 809 | name: 'authActionsCriteria', 810 | value: [ 811 | { 812 | OrgRoleAuthAction: { 813 | actionType: 'ADMIN_READ' 814 | } 815 | }, 816 | { 817 | OrgContentAuthAction: { 818 | actionType: 'ORGANIC_SHARE_DELETE' 819 | } 820 | } 821 | ] 822 | }, 823 | accessToken: TEST_BEARER_TOKEN 824 | }, 825 | inputResponse: { 826 | data: { 827 | elements: [{ elements: [] }, { elements: [] }] 828 | }, 829 | status: 200 830 | }, 831 | expectedRequest: { 832 | baseUrl: NON_VERSIONED_BASE_URL, 833 | path: '/testResource?bq=authActions&authActionsCriteria=List((OrgRoleAuthAction:(actionType:ADMIN_READ)),(OrgContentAuthAction:(actionType:ORGANIC_SHARE_DELETE)))' 834 | } 835 | }, 836 | 837 | /** 838 | * ACTION Method 839 | */ 840 | { 841 | description: 'Action on a non-versioned resource', 842 | inputRequestRestliMethod: 'ACTION', 843 | inputRequestOptions: { 844 | resourcePath: '/testResource', 845 | actionName: 'doSomething', 846 | data: { 847 | additionalParam: 123 848 | }, 849 | accessToken: TEST_BEARER_TOKEN 850 | }, 851 | inputResponse: { 852 | data: { 853 | value: { 854 | prop1: 123 855 | } 856 | }, 857 | status: 200 858 | }, 859 | expectedRequest: { 860 | baseUrl: NON_VERSIONED_BASE_URL, 861 | path: '/testResource?action=doSomething', 862 | body: { 863 | additionalParam: 123 864 | } 865 | } 866 | } 867 | ])( 868 | '$description', 869 | async ({ 870 | inputRequestRestliMethod, 871 | inputRequestOptions, 872 | inputResponse, 873 | expectedRequest 874 | }: TestCaseParameters) => { 875 | const expectedCommonHeaders = { 876 | 'x-restli-protocol-version': '2.0.0', 877 | 'x-restli-method': inputRequestRestliMethod.toLowerCase(), 878 | authorization: `Bearer ${TEST_BEARER_TOKEN}` 879 | }; 880 | 881 | // Mock the expected http request 882 | 883 | /** 884 | * If the expected request uses a different http method than the standard 885 | * Rest.li method mapping (due to query tunneling), then use that instead. 886 | */ 887 | const httpMethod = 888 | expectedRequest.overrideMethod || 889 | RESTLI_METHOD_TO_HTTP_METHOD_MAP[inputRequestRestliMethod]; 890 | nock(expectedRequest.baseUrl, { 891 | reqheaders: { 892 | ...expectedCommonHeaders, 893 | ...expectedRequest.additionalHeaders 894 | } 895 | }) 896 | [httpMethod.toLowerCase()](expectedRequest.path, expectedRequest.body) 897 | .reply(inputResponse.status, inputResponse.data, inputResponse.headers); 898 | 899 | // Make request using LinkedIn Restli client 900 | const restliClient = new RestliClient(); 901 | const restliClientMethod = _.camelCase(inputRequestRestliMethod); 902 | if (inputResponse.isError) { 903 | // If expecting error response 904 | try { 905 | await restliClient[restliClientMethod](inputRequestOptions); 906 | } catch (error) { 907 | expect(error.response.status).toBe(inputResponse.status); 908 | expect(error.response.data).toStrictEqual(inputResponse.data); 909 | } 910 | } else { 911 | // If expecting success response 912 | const response = await restliClient[restliClientMethod](inputRequestOptions); 913 | expect(response); 914 | expect(response.data).toStrictEqual(inputResponse.data); 915 | expect(response.status).toBe(inputResponse.status); 916 | } 917 | } 918 | ); 919 | 920 | test('setDebugParams', async () => { 921 | const logMock = jest.spyOn(console, 'log').mockImplementation(() => {}); 922 | const errorMock = jest.spyOn(console, 'error').mockImplementation(() => {}); 923 | nock('https://api.linkedin.com/v2').get('/test').reply(200, 'success'); 924 | 925 | const restliClient = new RestliClient(); 926 | 927 | // Test initial state where logging is disabled 928 | await restliClient.get({ 929 | resourcePath: '/test', 930 | accessToken: 'ABC123' 931 | }); 932 | 933 | expect(logMock).not.toHaveBeenCalled(); 934 | expect(errorMock).not.toHaveBeenCalled(); 935 | 936 | // Now enable logging of all requests and check that successful requests are logged 937 | logMock.mockClear(); 938 | errorMock.mockClear(); 939 | nock('https://api.linkedin.com/v2').get('/test').reply(200, 'success'); 940 | restliClient.setDebugParams({ enabled: true, logSuccessResponses: true }); 941 | 942 | await restliClient.get({ 943 | resourcePath: '/test', 944 | accessToken: 'ABC123' 945 | }); 946 | 947 | expect(logMock).toHaveBeenCalled(); 948 | expect(errorMock).not.toHaveBeenCalled(); 949 | 950 | // Now check that error requests are logged 951 | logMock.mockClear(); 952 | errorMock.mockClear(); 953 | nock('https://api.linkedin.com/v2').get('/test').reply(500, 'error'); 954 | 955 | try { 956 | await restliClient.get({ 957 | resourcePath: '/test', 958 | accessToken: 'ABC123' 959 | }); 960 | fail('RestliClient should have thrown an error.'); 961 | } catch (error) { 962 | expect(logMock).not.toHaveBeenCalled(); 963 | expect(errorMock).toHaveBeenCalled(); 964 | } 965 | 966 | logMock.mockRestore(); 967 | errorMock.mockRestore(); 968 | }); 969 | }); 970 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LinkedIn API JavaScript Client 2 | 3 | ## Overview 4 | 5 | This library provides a thin JavaScript client for making requests to LinkedIn APIs, utilizing the [Axios](https://axios-http.com/docs/intro) HTTP client library and written in TypeScript. LinkedIn's APIs are built on the [Rest.li](https://linkedin.github.io/rest.li/) framework with additional LinkedIn-specific constraints, which results in a robust yet complex protocol that can be challenging to implement correctly. 6 | 7 | This library helps reduce this complexity by formatting requests correctly, providing proper request headers, and providing interfaces to develop against for responses. The library also provides an auth client for inspecting, generating, and refreshing access tokens, along with other helpful utilities. 8 | 9 | This library is intended for use within a Node.js server application. API requests from browser environments are not supported by LinkedIn APIs due to the CORS policy. 10 | 11 | > :warning: This API client library is currently in beta and is subject to change. It may contain bugs, errors, or other issues that we are working to resolve. Use of this library is at your own risk. Please use caution when using it in production environments and be prepared for the possibility of unexpected behavior. We welcome any feedback or reports of issues that you may encounter while using this library. 12 | 13 | ### Features 14 | 15 | - Generic support for all Rest.li methods used in LinkedIn APIs 16 | - Supports Rest.li protocol version 2.0.0 17 | - Provide typescript interfaces for request options/response payloads 18 | - Built-in parameter encoding 19 | - Partial update patch generation utilities 20 | - LinkedIn URN utilities 21 | - Supports versioned APIs 22 | - Automatic query tunneling of requests 23 | - 2-legged and 3-legged OAuth2 support 24 | 25 | ### Table of Contents 26 | - [Installation](#installation) 27 | - [Getting Started](#getting-started) 28 | - [Pre-requisites](#pre-requisites) 29 | - [Simple API request example](#simple-api-request-example) 30 | - [More Examples](#more-examples) 31 | - [RestliClient](#restliclient) 32 | - [Constructor](#constructor) 33 | - [Properties](#properties) 34 | - [Methods](#methods) 35 | - [`get()`](#getparams) 36 | - [`getAll()`](#getallparams) 37 | - [`finder()`](#finderparams) 38 | - [`batchFinder()`](#batchfinderparams) 39 | - [`create()`](#createparams) 40 | - [`batchCreate()`](#batchcreateparams) 41 | - [`update()`](#updateparams) 42 | - [`batchUpdate()`](#batchupdateparams) 43 | - [`partialUpdate()`](#partialupdateparams) 44 | - [`batchPartialUpdate()`](#batchpartialupdateparams) 45 | - [`delete()`](#deleteparams) 46 | - [`batchDelete()`](#batchdeleteparams) 47 | - [`action()`](#actionparams) 48 | - [`setDebugParams()`](#setdebugparamsparams) 49 | - [AuthClient](#authclient) 50 | - [Constructor](#constructor-1) 51 | - [Methods](#methods-1) 52 | - [`generateMemberAuthorizationUrl()`](#generatememberauthorizationurlscopes-state) 53 | - [`exchangeAuthCodeForAccessToken()`](#exchangeauthcodeforaccesstokencode) 54 | - [`exchangeRefreshTokenForAccessToken()`](#exchangerefreshtokenforaccesstokenrefreshtoken) 55 | - [`getTwoLeggedAccessToken()`](#gettwoleggedaccesstoken) 56 | - [`introspectAccessToken()`](#introspectaccesstokenaccesstoken) 57 | - [List of dependencies](#list-of-dependencies) 58 | 59 | ## Installation 60 | 61 | Using npm: 62 | 63 | ```sh 64 | npm install linkedin-api-client 65 | ``` 66 | Using yarn: 67 | 68 | ```sh 69 | yarn add linkedin-api-client 70 | ``` 71 | 72 | ## Getting Started 73 | 74 | ### Pre-requisites 75 | 76 | 1. Create or use an existing developer application from the [LinkedIn Developer Portal](https://www.linkedin.com/developers/apps/) 77 | 2. Request access to the Sign In With LinkedIn API product. This is a self-serve product that will be provisioned immediately to your application. 78 | 3. Generate a 3-legged access token using the Developer Portal [token generator tool](https://www.linkedin.com/developers/tools/oauth/token-generator), selecting the r_liteprofile scope. 79 | 80 | ### Simple API Request Example 81 | 82 | Here is an example of using the client to make a simple GET request to [fetch the current user's profile](https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin#retrieving-member-profiles). This requires a 3-legged access token with the "r_liteprofile" scope, which is included with the Sign In With LinkedIn API product. 83 | 84 | ```js 85 | const { RestliClient } = require('linkedin-api-client'); 86 | 87 | const restliClient = new RestliClient(); 88 | 89 | restliClient.get({ 90 | resourcePath: '/me', 91 | accessToken: 92 | }).then(response => { 93 | const profile = response.data; 94 | }); 95 | ``` 96 | 97 | ### Finder Request Example 98 | 99 | Here is a more non-trivial example to [find ad accounts](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads/account-structure/create-and-manage-accounts?#search-for-accounts) by some search critiera. This requires a 3-legged access token with the "r_ads" scope, which is included with the Advertising APIs product. 100 | 101 | We provide the value of the "search" query parameter object, and the client will handle the correct URL-encoding. This is a versioned API call, so we provide the version string in the "YYYYMM" format. 102 | 103 | ```js 104 | const { RestliClient } = require('linkedin-api-client'); 105 | 106 | const restliClient = new RestliClient(); 107 | 108 | restliClient.finder({ 109 | resourcePath: '/adAccounts', 110 | finderName: 'search', 111 | queryParams: { 112 | search: { 113 | status: { 114 | values: ['ACTIVE', 'DRAFT'] 115 | }, 116 | reference: { 117 | values: ['urn:li:organization:123'] 118 | }, 119 | test: true 120 | } 121 | }, 122 | versionString: '202212', 123 | accessToken: 124 | }).then(response => { 125 | const adAccounts = response.data.elements; 126 | const total = response.data.paging.total; 127 | }); 128 | ``` 129 | 130 | ### More Examples 131 | 132 | There are more examples of using the client in [/examples](examples/) directory. 133 | 134 | ## `RestliClient` 135 | 136 | The RestliClient class defines instance methods for all the Rest.li methods which are used by LinkedIn APIs. Rest.li defines a standard set of methods that can operate on a resource, each of which maps to an HTTP method. Depending on the resource, some Rest.li methods are not applicable or not implemented. Read the API docs to determine what Rest.li method is applicable and the applicable request parameters. 137 | 138 | ### Constructor 139 | 140 | An instance of the API client must be created before using. 141 | 142 | ```js 143 | const { RestliClient } = require('linkedin-api-client'); 144 | 145 | const restliClient = new RestliClient(config); 146 | ``` 147 | 148 | | Parameter | Type | Required? | Description | 149 | |---|---|---|---| 150 | | `config` | Object : [AxiosRequestConfig](https://axios-http.com/docs/req_config) | No | An initial, optional config that used to configure the axios instance (e.g. default timeout). | 151 | 152 | ### Properties 153 | 154 | | Property | Description | 155 | |---|---| 156 | | `axiosInstance` | The axios instance used for making http requests. This is exposed to allow for additional configuration (e.g. adding custom request/response interceptors). | 157 | 158 | ### Methods 159 | 160 | #### Base Request Options 161 | 162 | All methods of the API client require passing in a request options object, all of which extend the following BaseRequestOptions object: 163 | 164 | | Parameter | Type | Required? | Description | 165 | |---|---|---|---| 166 | | `BaseRequestOptions.resourcePath` | String | Yes |

The resource path after the base URL, beginning with a forward slash. If the path contains keys, add curly-brace placeholders for the keys and specify the path key-value map in the `pathKeys` argument.

Examples:

  • `resourcePath: "/me"`
  • `resourcePath: "/adAccounts/{id}"`
  • `resourcePath: "/socialActions/{actionUrn}/comments/{commentId}"`
  • `resourcePath: "/campaignConversions/{key}`
| 167 | | `BaseRequestOptions.pathKeys` | Object | No |

If there are path keys that are part of the `resourcePath` argument, the key placeholders must be specified in the provided `pathKeys` map. The path key values can be strings, numbers, or objects, and these will be properly encoded.

Examples:

  • `pathKeys: {"id": 123"}`
  • `pathKeys: {"actionUrn":"urn:li:share:123","commentId":987`}
  • `pathKeys: {"key": {"campaign": "urn:li:sponsoredCampaign:123", "conversion": "urn:lla:llaPartnerConversion:456"}}`
| 168 | | `BaseRequestOptions.queryParams` | Object | No | A map of query parameters. The parameter keys and values will be correctly encoded by this method, so these should not be encoded. | 169 | | `BaseRequestOptions.accessToken` | String | Yes | The access token that should provide the application access to the specified API | 170 | | `BaseRequestOptions.versionString` | String | No | An optional version string of the format "YYYYMM" or "YYYYMM.RR". If specified, the version header will be passed and the request will use the versioned APIs base URL | 171 | | `BaseRequestOptions.additionalConfig` | Object : [AxiosRequestConfig](https://axios-http.com/docs/req_config) | No | An optional Axios request config object that will be merged into the request config. This will override any properties the client method sets and any properties passed in during the RestliClient instantiation, which may cause unexpected errors. Query params should not be passed here--instead they should be set in the `queryParams` proeprty for proper Rest.li encoding. | 172 | 173 | #### Base Response Object 174 | 175 | All methods of the API client return a Promise that resolves to a response object that extends [AxiosResponse](https://axios-http.com/docs/res_schema). This client provides more detailed interfaces of the specific response data payload that is useful for static type-checking and IDE auto-completion. 176 | 177 | #### `get(params)` 178 | 179 | Makes a Rest.li GET request to fetch the specified entity on a resource. This method will perform query tunneling if necessary. 180 | 181 | **Request Parameters:** 182 | 183 | | Parameter | Type | Required? | Description | 184 | |---|---|---|---| 185 | | `params` | Object extends [BaseRequestOptions](#base-request-options) | Yes | Standard request options | 186 | 187 | **Returns `Promise`** 188 | 189 | | Field | Type | Description | 190 | |---|---|---| 191 | | `response` | Object extends [AxiosResponse](https://axios-http.com/docs/res_schema) | Axios response object | 192 | | `response.data` | Object | The Rest.li entity | 193 | 194 | **Example:** 195 | ```js 196 | restliClient.get({ 197 | resourcePath: '/adAccounts/{id}', 198 | pathKeys: { 199 | id: 123 200 | }, 201 | queryParams: { 202 | fields: 'id,name' 203 | }, 204 | accessToken: MY_ACCESS_TOKEN, 205 | versionString: '202210' 206 | }).then(response => { 207 | const entity = response.data; 208 | }); 209 | ``` 210 | 211 | #### `batchGet(params)` 212 | 213 | Makes a Rest.li BATCH_GET request to fetch multiple entities on a resource. This method will perform query tunneling if necessary. 214 | 215 | **Request Parameters:** 216 | 217 | | Parameter | Type | Required? | Description | 218 | |---|---|---|---| 219 | | `params` | Object extends [BaseRequestOptions](#base-request-options) | Yes | Standard request options | 220 | | `params.ids` | String[] \|\| Number[] \|\| Object[] | Yes | The list of ids to fetch on the resource. | 221 | 222 | **Returns `Promise`** 223 | 224 | | Field | Type | Description | 225 | |---|---|---| 226 | | `response` | Object extends [AxiosResponse](https://axios-http.com/docs/res_schema) | Axios response object | 227 | | `response.data.results` | Object | A map of entities that were successfully retrieved, with the key being the encoded entity id, and the value being the entity. | 228 | | `response.data.errors` | Object | A map containing entities that could not be successfully fetched, with the key being the encoded entity id, and the value being the error response. | 229 | | `response.data.statuses` | Object | A map of entities and status code, with the key being the encoded entity id, and the value being the status code number value. | 230 | 231 | **Example:** 232 | ```js 233 | restliClient.batchGet({ 234 | resourcePath: '/adCampaignGroups', 235 | id: [123, 456, 789], 236 | accessToken: MY_ACCESS_TOKEN, 237 | versionString: '202210' 238 | }).then(response => { 239 | const entity = response.data.results; 240 | }); 241 | ``` 242 | 243 | #### `getAll(params)` 244 | 245 | Makes a Rest.li GET_ALL request to fetch all entities on a resource. 246 | 247 | **Request Parameters:** 248 | 249 | | Parameter | Type | Required? | Description | 250 | |---|---|---|---| 251 | | `params` | Object extends [BaseRequestOptions](#base-request-options) | Yes | Standard request options | 252 | 253 | **Returns `Promise`** 254 | 255 | | Field | Type | Description | 256 | |---|---|---| 257 | | `response` | Object extends [AxiosResponse](https://axios-http.com/docs/res_schema) | Axios response object | 258 | | `response.data.elements` | Object[] | The list of Rest.li entities | 259 | | `response.data.paging` | Object | Paging metadata object 260 | 261 | **Example:** 262 | ```js 263 | restliClient.getAll({ 264 | resourcePath: '/fieldsOfStudy', 265 | queryParams: { 266 | start: 0, 267 | count: 15 268 | }, 269 | accessToken: MY_ACCESS_TOKEN, 270 | versionString: '202210' 271 | }).then(response => { 272 | const elements = response.data.elements; 273 | const total = response.data.paging.total; 274 | }); 275 | ``` 276 | 277 | #### `finder(params)` 278 | 279 | Makes a Rest.li FINDER request to find entities by some specified criteria. 280 | 281 | **Request Parameters:** 282 | 283 | | Parameter | Type | Required? | Description | 284 | |---|---|---|---| 285 | | `params` | Object extends [BaseRequestOptions](#base-request-options) | Yes | Standard request options | 286 | | `params.finderName` | String | Yes | The Rest.li finder name. This will be included in the request query parameters. | 287 | 288 | **Returns `Promise`** 289 | 290 | | Field | Type | Description | 291 | |---|---|---| 292 | | `response` | Object extends [AxiosResponse](https://axios-http.com/docs/res_schema) | Axios response object | 293 | | `response.data.elements` | Object[] | The list of entities found based on the search criteria. | 294 | | `response.data.paging` | Object | Paging metadata object | 295 | 296 | **Example:** 297 | ```js 298 | restliClient.finder({ 299 | resourcePath: '/adAccounts', 300 | finderName: 'search' 301 | queryParams: { 302 | search: { 303 | status: { 304 | values: ['DRAFT', 'ACTIVE', 'REMOVED'] 305 | }, 306 | reference: { 307 | values: ['urn:li:organization:123'] 308 | }, 309 | test: false 310 | } 311 | }, 312 | accessToken: MY_ACCESS_TOKEN, 313 | versionString: '202210' 314 | }).then(response => { 315 | const elements = response.data.elements; 316 | const total = response.data.paging.total; 317 | }); 318 | ``` 319 | 320 | #### `batchFinder(params)` 321 | 322 | Makes a Rest.li BATCH_FINDER request to find entities by multiple sets of criteria. 323 | 324 | **Request Parameters:** 325 | 326 | | Parameter | Type | Required? | Description | 327 | |---|---|---|---| 328 | | `params` | Object extends [BaseRequestOptions](#base-request-options) | Yes | Standard request options | 329 | | `params.finderName` | String | Yes | The Rest.li batch finder name. This will be included in the request query parameters. | 330 | | `params.finderCriteria` | Object | Yes | An object with `name` and `value` properties, representing the required batch finder criteria information. `name` should be the batch finder criteria parameter name, and `value` should be a list of finder param objects. The batch finder results are correspondingly ordered according to this list. The batch finder criteria will be encoded and added to the request query parameters. | 331 | 332 | **Returns `Promise`** 333 | 334 | | Field | Type | Description | 335 | |---|---|---| 336 | | `response` | Object extends [AxiosResponse](https://axios-http.com/docs/res_schema) | Axios response object | 337 | | `response.data.elements` | Object[] | An array of finder search results in the same order as the array of search criteria provided to the batch finder. | 338 | | `response.data.elements[].elements` | Object[] | An array of entities found based on the corresponding search criteria. | 339 | | `response.data.elements[].paging` | Object | Paging metadata object | 340 | | `response.data.elements[].metadata` | Object | Optional finder results metadata object | 341 | | `response.data.elements[].error` | Object | Error response object if this finder request encountered an error. | 342 | | `response.data.elements[].isError` | boolean | Flag indicating whether the finder request encountered an error. | 343 | 344 | **Example:** 345 | ```js 346 | restliClient.batchFinder({ 347 | resource: '/organizationAuthorizations', 348 | finderName: 'authorizationActionsAndImpersonator' 349 | finderCriteria: { 350 | name: 'authorizationActions', 351 | value: [ 352 | { 353 | 'OrganizationRoleAuthorizationAction': { 354 | actionType: 'ADMINISTRATOR_READ' 355 | } 356 | }, 357 | { 358 | 'OrganizationContentAuthorizationAction': { 359 | actionType: 'ORGANIC_SHARE_DELETE' 360 | } 361 | } 362 | ] 363 | }, 364 | accessToken: MY_ACCESS_TOKEN, 365 | versionString: '202210' 366 | }).then(response => { 367 | const allFinderResults = response.data.elements; 368 | }); 369 | ``` 370 | 371 | #### `create(params)` 372 | 373 | Makes a Rest.li CREATE request to create a new entity on the resource. 374 | 375 | **Request Parameters:** 376 | 377 | | Parameter | Type | Required? | Description | 378 | |---|---|---|---| 379 | | `params` | Object extends [BaseRequestOptions](#base-request-options) | Yes | Standard request options | 380 | | `params.entity` | Object | Yes | The value of the entity to create | 381 | 382 | **Returns `Promise`** 383 | 384 | | Field | Type | Description | 385 | |---|---|---| 386 | | `response` | Object extends [AxiosResponse](https://axios-http.com/docs/res_schema) | Axios response object | 387 | | `response.createdEntityId` | String \|\| Number | The id of the created entity | 388 | 389 | **Example:** 390 | ```js 391 | restliClient.create({ 392 | resourcePath: '/adAccountsV2', 393 | entity: { 394 | name: 'Test Ad Account', 395 | type: 'BUSINESS', 396 | test: true 397 | }, 398 | accessToken: MY_ACCESS_TOKEN 399 | }).then(response => { 400 | const createdEntityId = response.createdEntityId; 401 | }); 402 | ``` 403 | 404 | #### `batchCreate(params)` 405 | 406 | Makes a Rest.li BATCH_CREATE request to create multiple entities in a single call. 407 | 408 | **Request Parameters:** 409 | 410 | | Parameter | Type | Required? | Description | 411 | |---|---|---|---| 412 | | `params` | Object extends [BaseRequestOptions](#base-request-options) | Yes | Standard request options | 413 | | `params.entities` | Object[] | Yes | The values of the entities to create | 414 | 415 | **Returns `Promise`** 416 | 417 | | Field | Type | Description | 418 | |---|---|---| 419 | | `response` | Object extends [AxiosResponse](https://axios-http.com/docs/res_schema) | Axios response object | 420 | | `response.data.elements` | Object[] | A list of entity creation response data in the same order as the entities provided in the batch create request. | 421 | | `response.data.elements[].status` | Number | The response status when creating the entity. | 422 | | `response.data.elements[].id` | String \|\| Number | The encoded id of the newly-created entity, if creation was successful. | 423 | | `response.data.elements[].error` | Object | The error object details when creating an entity, if creation failed. | 424 | 425 | **Example:** 426 | ```js 427 | restliClient.batchCreate({ 428 | resourcePath: '/adCampaignGroups', 429 | entities: [ 430 | { 431 | account: 'urn:li:sponsoredAccount:111', 432 | name: 'CampaignGroupTest1', 433 | status: 'DRAFT' 434 | }, 435 | { 436 | account: 'urn:li:sponsoredAccount:222', 437 | name: 'CampaignGroupTest2', 438 | status: 'DRAFT' 439 | } 440 | ], 441 | versionString: '202209', 442 | accessToken: MY_ACCESS_TOKEN 443 | }).then(response => { 444 | const createdElementsInfo = response.data.elements; 445 | }); 446 | ``` 447 | 448 | #### `update(params)` 449 | 450 | Makes a Rest.li UPDATE request to update an entity (overwriting the entire entity). 451 | 452 | **Request Parameters:** 453 | 454 | | Parameter | Type | Required? | Description | 455 | |---|---|---|---| 456 | | `params` | Object extends [BaseRequestOptions](#base-request-options) | Yes | Standard request options | 457 | | `params.entity` | Object | Yes | The value of the entity with updated values. | 458 | 459 | **Returns `Promise`** 460 | 461 | | Field | Type | Description | 462 | |---|---|---| 463 | | `response` | Object extends [AxiosResponse](https://axios-http.com/docs/res_schema) | Axios response object | 464 | 465 | **Example:** 466 | ```js 467 | restliClient.update({ 468 | resourcePath: '/adAccountUsers/{key}', 469 | pathKeys: { 470 | key: { 471 | account: 'urn:li:sponsoredAccount:123', 472 | user: 'urn:li:person:foobar' 473 | } 474 | }, 475 | entity: { 476 | account: 'urn:li:sponsoredAccount:123', 477 | user: 'urn:li:person:foobar', 478 | role: 'VIEWER' 479 | }, 480 | versionString: '202210', 481 | accessToken: MY_ACCESS_TOKEN 482 | }).then(response => { 483 | const status = response.status; 484 | }); 485 | ``` 486 | 487 | #### `batchUpdate(params)` 488 | 489 | Makes a Rest.li BATCH_UPDATE request to update multiple entities in a single call. 490 | 491 | **Request Parameters:** 492 | 493 | | Parameter | Type | Required? | Description | 494 | |---|---|---|---| 495 | | `params` | Object extends [BaseRequestOptions](#base-request-options) | Yes | Standard request options | 496 | | `params.ids` | String[] \|\| Number[] \|\| Object[] | Yes | The list of entity ids to update | 497 | | `params.entities` | Object[] | Yes | The list of values of entities with updated values. | 498 | 499 | **Returns `Promise`** 500 | 501 | | Field | Type | Description | 502 | |---|---|---| 503 | | `response` | Object extends [AxiosResponse](https://axios-http.com/docs/res_schema) | Axios response object | 504 | 505 | **Example:** 506 | ```js 507 | restliClient.batchUpdate({ 508 | resourcePath: '/campaignConversions', 509 | ids: [ 510 | { campaign: 'urn:li:sponsoredCampaign:123', conversion: 'urn:lla:llaPartnerConversion:456' }, 511 | { campaign: 'urn:li:sponsoredCampaign:123', conversion: 'urn:lla:llaPartnerConversion:789' } 512 | ], 513 | entities: [ 514 | { campaign: 'urn:li:sponsoredCampaign:123', conversion: 'urn:lla:llaPartnerConversion:456' }, 515 | { campaign: 'urn:li:sponsoredCampaign:123', conversion: 'urn:lla:llaPartnerConversion:789' } 516 | ] 517 | accessToken: MY_ACCESS_TOKEN 518 | }).then(response => { 519 | const results = response.data.results; 520 | }); 521 | ``` 522 | 523 | #### `partialUpdate(params)` 524 | 525 | Makes a Rest.li PARTIAL_UPDATE request to update part of an entity. One can either directly pass the patch object to send in the request, or one can pass the full original and modified entity objects, with the method computing the correct patch object. 526 | 527 | When an entity has nested fields that can be modified, passing in the original and modified entities may produce a complex patch object that is a technically correct format for the Rest.li framework, but may not be supported for most LinkedIn APIs which mainly support partial update of only top-level fields on an entity. In these cases it is better to specify `patchSetObject` directly. 528 | 529 | **Request Parameters:** 530 | 531 | | Parameter | Type | Required? | Description | 532 | |---|---|---|---| 533 | | `params` | Object extends [BaseRequestOptions](#base-request-options) | Yes | Standard request options | 534 | | `params.patchSetObject` | Object | No | The value of the entity with only the modified fields present. If specified, this will be directly sent as the patch object. | 535 | | `params.originalEntity` | Object | No | The value of the original entity. If specified and `patchSetObject` is not provided, this will be used in conjunction with `modifiedEntity` to compute the patch object. | 536 | | `params.modifiedEntity` | Object | No | The value of the modified entity. If specified and `patchSetObject` is not provided, this will be used in conjunction with `originalEntity` to compute the patch object. | 537 | 538 | **Returns `Promise`** 539 | 540 | | Field | Type | Description | 541 | |---|---|---| 542 | | `response` | Object extends [AxiosResponse](https://axios-http.com/docs/res_schema) | Axios response object | 543 | 544 | **Example:** 545 | ```js 546 | restliClient.partialUpdate({ 547 | resourcePath: '/adAccounts/{id}', 548 | pathKeys: { 549 | id: 123 550 | }, 551 | patchSetObject: { 552 | name: 'TestAdAccountModified', 553 | reference: 'urn:li:organization:456' 554 | }, 555 | versionString: '202210', 556 | accessToken: MY_ACCESS_TOKEN 557 | }).then(response => { 558 | const status = response.status; 559 | }); 560 | ``` 561 | 562 | #### `batchPartialUpdate(params)` 563 | 564 | Makes a Rest.li BATCH_PARTIAL_UPDATE request to partially update multiple entites at once. 565 | 566 | **Request Parameters:** 567 | 568 | | Parameter | Type | Required? | Description | 569 | |---|---|---|---| 570 | | `params` | Object extends [BaseRequestOptions](#base-request-options) | Yes | Standard request options | 571 | | `params.ids` | String[] \|\| Number[] \|\| Object[] | Yes | The list of entity ids to update | 572 | | `params.patchSetObjects` | Object[] | No | The list of values of the entities with only the modified fields present. If specified, this will be directly sent as the patch object | 573 | | `params.originalEntities` | Object[] | No | The list of values of the original entities. If specified and `patchSetObjects` is not provided, this will be used in conjunction with `modifiedEntities` to compute patch object for each entity. | 574 | | `params.modifiedEntities` | Object[] | No | The list of values of the modified entities. If specified and `patchSetObjects` is not provided, this will be used in conjunction with `originalEntities` to compute the patch object for each entity. | 575 | 576 | **Returns `Promise`** 577 | 578 | | Field | Type | Description | 579 | |---|---|---| 580 | | `response` | Object extends [AxiosResponse](https://axios-http.com/docs/res_schema) | Axios response object | 581 | | `response.data.results` | Object | A map where the key is the encoded entity id and the value is an object containing the corresponding response status. | 582 | | `response.data.errors` | Object | A map where the keys are the encoded entity ids that failed to be updated, and the values include the error response. | 583 | 584 | **Example:** 585 | ```js 586 | restliClient.batchPartialUpdate({ 587 | resourcePath: '/adCampaignGroups', 588 | id: [123, 456], 589 | patchSetObjects: [ 590 | { status: 'ACTIVE' }, 591 | { 592 | runSchedule: { 593 | start: 1678029270721, 594 | end: 1679029270721 595 | } 596 | } 597 | }, 598 | versionString: '202210', 599 | accessToken: MY_ACCESS_TOKEN 600 | }).then(response => { 601 | const results = response.data.results; 602 | }); 603 | ``` 604 | 605 | #### `delete(params)` 606 | 607 | Makes a Rest.li DELETE request to delete an entity. 608 | 609 | **Request Parameters:** 610 | 611 | | Parameter | Type | Required? | Description | 612 | |---|---|---|---| 613 | | `params` | Object extends [BaseRequestOptions](#base-request-options) | Yes | Standard request options | 614 | 615 | **Returns `Promise`** 616 | 617 | | Field | Type | Description | 618 | |---|---|---| 619 | | `response` | Object extends [AxiosResponse](https://axios-http.com/docs/res_schema) | Axios response object | 620 | 621 | **Example:** 622 | ```js 623 | restliClient.delete({ 624 | resourcePath: '/adAccounts/{id}', 625 | pathKeys: { 626 | id: 123 627 | }, 628 | versionString: '202210', 629 | accessToken: MY_ACCESS_TOKEN 630 | }).then(response => { 631 | const status = response.status; 632 | }); 633 | ``` 634 | 635 | #### `batchDelete(params)` 636 | 637 | Makes a Rest.li BATCH_DELETE request to delete multiple entities at once. 638 | 639 | **Request Parameters:** 640 | 641 | | Parameter | Type | Required? | Description | 642 | |---|---|---|---| 643 | | `params` | Object extends [BaseRequestOptions](#base-request-options) | Yes | Standard request options | 644 | | `params.ids` | String[] \|\| Number[] \|\| Object[] | Yes | The ids of the entities to delete. | 645 | 646 | **Returns `Promise`** 647 | 648 | | Field | Type | Description | 649 | |---|---|---| 650 | | `response` | Object extends [AxiosResponse](https://axios-http.com/docs/res_schema) | Axios response object | 651 | | `response.data.results` | Object | A map where the keys are the encoded entity ids that were successfully deleted, and the values are the delete results, which include the status code. | 652 | | `response.data.errors` | Object | A map where the keys are the encoded entity ids that failed to be deleted, and the values include the error response. | 653 | 654 | **Example:** 655 | ```js 656 | restliClient.batchDelete({ 657 | resourcePath: '/adAccounts', 658 | ids: [123, 456], 659 | versionString: '202210', 660 | accessToken: MY_ACCESS_TOKEN 661 | }).then(response => { 662 | const results = response.data.results; 663 | }); 664 | ``` 665 | 666 | #### `action(params)` 667 | 668 | Makes a Rest.li ACTION request to perform an action on a specified resource. 669 | 670 | **Request Parameters:** 671 | 672 | | Parameter | Type | Required? | Description | 673 | |---|---|---|---| 674 | | `params` | Object extends [BaseRequestOptions](#base-request-options) | Yes | Standard request options | 675 | | `params.actionName` | String | Yes | The Rest.li action name | 676 | | `params.data` | Object | No | The request body data to pass to the action. | 677 | 678 | **Returns `Promise`** 679 | 680 | | Field | Type | Description | 681 | |---|---|---| 682 | | `response` | Object extends [AxiosResponse](https://axios-http.com/docs/res_schema) | Axios response object | 683 | | `response.data.value` | String \|\| Number \|\| Object | The action response value. | 684 | 685 | **Example:** 686 | ```js 687 | restliClient.action({ 688 | resourcePath: '/testResource', 689 | actionName: 'doSomething', 690 | accessToken: MY_ACCESS_TOKEN 691 | }).then(response => { 692 | const result = response.data.value; 693 | }); 694 | ``` 695 | 696 | #### `setDebugParams(params)` 697 | 698 | Configures debug logging for troubleshooting requests. 699 | 700 | **Request Parameters:** 701 | 702 | | Parameter | Type | Required? | Description | 703 | |---|---|---|---| 704 | | `params` | Object | Yes | Parameters object | 705 | | `params.enabled` | boolean | No | Flag whether to enable debug logging. By default this is false. | 706 | | `params.logSuccessResponses` | boolean | No | Flag whether to log successful responses. By default this is false and only errors are logged. | 707 | 708 | ## `AuthClient` 709 | 710 | While we recommend using any of several popular, open-source libraries for robustly managing OAuth 2.0 authentication, we provide a basic Auth Client as a convenience for testing APIs and getting started. 711 | 712 | ### Constructor 713 | 714 | ```js 715 | const { AuthClient } = require('linkedin-api-client'); 716 | 717 | const authClient = new AuthClient(params); 718 | ``` 719 | 720 | | Parameter | Type | Required? | Description | 721 | |---|---|---|---| 722 | | `params` | Object | Yes | | 723 | | `params.clientId` | String | Yes | Client ID of your developer application. This can be found on your application auth settings page in the Developer Portal. | 724 | | `params.clientSecret` | String | Yes | Client secret of your developer application. This can be found on your application auth settings page in the Developer Portal. | 725 | | `params.redirectUrl` | String | No | If your integration will be using the authorization code flow to obtain 3-legged access tokens, this should be provided. This redirect URL must match one of the redirect URLs configured in the app auth settings page in the Developer Portal. | 726 | 727 | ### Methods 728 | 729 | #### `generateMemberAuthorizationUrl(scopes, state)` 730 | 731 | Generates the member authorization URL to direct members to. Once redirected, the member will be presented with LinkedIn's OAuth consent page showing the OAuth scopes your application is requesting on behalf of the user. 732 | 733 | **Parameters:** 734 | 735 | | Parameter | Type | Required? | Description | 736 | |---|---|---|---| 737 | | `scopes` | String[] | Yes | An array of OAuth scopes (3-legged member permissions) your application is requesting on behalf of the user. | 738 | | `state` | String | No | An optional string that can be provided to test against CSRF attacks. | 739 | 740 | **Returns `memberAuthorizationUrl`** 741 | 742 | | Field | Type | Description | 743 | |---|---|---| 744 | | `memberAuthorizationUrl` | String | The member authorization URL | 745 | 746 | 747 | #### `exchangeAuthCodeForAccessToken(code)` 748 | 749 | Exchanges an authorization code for a 3-legged access token. After member authorization, the browser redirects to the provided redirect URL, setting the authorization code on the `code` query parameter. 750 | 751 | **Parameters:** 752 | 753 | | Parameter | Type | Required? | Description | 754 | |---|---|---|---| 755 | | `code` | String | Yes | The authorization code to exchange for an access token | 756 | 757 | **Returns `Promise`** 758 | 759 | | Field | Type | Description | 760 | |---|---|---| 761 | | `tokenDetails` | Object | Token details object | 762 | | `tokenDetails.access_token` | String | The 3-legged access token | 763 | | `tokenDetails.expires_in` | Number | The TTL of the access token, in seconds | 764 | | `tokenDetails.refresh_token` | String | The refresh token value. This is only present if refresh tokens are enabled for the application. | 765 | | `tokenDetails.refresh_token_expires_in` | Number | The TTL of the refresh token, in seconds. This is only present if refresh tokens are enabled for the application. | 766 | | `tokenDetails.scope` | String | A comma-separated list of scopes authorized by the member (e.g. "r_liteprofile,r_ads") | 767 | 768 | #### `exchangeRefreshTokenForAccessToken(refreshToken)` 769 | 770 | Exchanges a refresh token for a new 3-legged access token. This allows access tokens to be refreshed without having the member reauthorize your application. 771 | 772 | **Parameters:** 773 | 774 | | Parameter | Type | Required? | Description | 775 | |---|---|---|---| 776 | | `refreshToken` | String | Yes | The refresh token to exchange for an access token | 777 | 778 | **Returns `Promise`** 779 | 780 | | Field | Type | Description | 781 | |---|---|---| 782 | | `tokenDetails` | Object | Token details object | 783 | | `tokenDetails.access_token` | String | The 3-legged access token | 784 | | `tokenDetails.expires_in` | Number | The TTL of the access token, in seconds | 785 | | `tokenDetails.refresh_token` | String | The refresh token value | 786 | | `tokenDetails.refresh_token_expires_in` | Number | The TTL of the refresh token, in seconds | 787 | 788 | 789 | #### `getTwoLeggedAccessToken()` 790 | 791 | Use client credential flow (2-legged OAuth) to retrieve a 2-legged access token for accessing APIs that are not member-specific. Developer applications do not have the client credential flow enabled by default. 792 | 793 | **Returns `Promise`** 794 | 795 | | Field | Type | Description | 796 | |---|---|---| 797 | | `tokenDetails` | Object | Token details object | 798 | | `tokenDetails.access_token` | String | The 2-legged access token | 799 | | `tokenDetails.expires_in` | Number | The TTL of the access token, in seconds | 800 | 801 | #### `introspectAccessToken(accessToken)` 802 | 803 | Introspect a 2-legged, 3-legged or Enterprise access token to get information on status, expiry, and other details. 804 | 805 | **Parameters:** 806 | 807 | | Parameter | Type | Required? | Description | 808 | |---|---|---|---| 809 | | `accessToken` | String | Yes | A 2-legged, 3-legged or Enterprise access token. | 810 | 811 | **Returns `Promise`** 812 | 813 | | Field | Type | Description | 814 | |---|---|---| 815 | | `tokenDetails` | Object | Token introspection details object | 816 | | `tokenDetails.active` | String | Flag whether the token is a valid, active token. | 817 | | `tokenDetails.auth_type` | String | The auth type of the token ("2L", "3L" or "Enterprise_User") | 818 | | `tokenDetails.authorized_at` | String | Epoch time in seconds, indicating when the token was authorized | 819 | | `tokenDetails.client_id` | Number | Developer application client ID | 820 | | `tokenDetails.created_at` | String | Epoch time in seconds, indicating when this token was originally issued | 821 | | `tokenDetails.expires_at` | Number | Epoch time in seconds, indicating when this token will expire | 822 | | `tokenDetails.scope` | String | A string containing a comma-separated list of scopes associated with this token. This is only returned for 3-legged member tokens. | 823 | | `tokenDetails.status` | String | The token status, which is an enum string with values "revoked", "expired" or "active" | 824 | 825 | --- 826 | 827 | ## List of dependencies 828 | 829 | The following table is a list of production dependencies. 830 | 831 | | Component Name | License | Linked | Modified | 832 | |---|---|---|---| 833 | | [axios](https://github.com/axios/axios) | MIT | Static | No | 834 | | [lodash](https://github.com/lodash/lodash) | MIT | Static | No | 835 | | [qs](https://github.com/ljharb/qs) | BSD-3-Clause | Static | No | -------------------------------------------------------------------------------- /lib/restli-client.ts: -------------------------------------------------------------------------------- 1 | import axios, { 2 | AxiosInstance, 3 | AxiosRequestConfig, 4 | AxiosResponse, 5 | CreateAxiosDefaults 6 | } from 'axios'; 7 | import { HTTP_METHODS, RESTLI_METHODS } from './utils/constants'; 8 | import { getPatchObject } from './utils/patch-generator'; 9 | import { encode, paramEncode } from './utils/encoder'; 10 | import { getCreatedEntityId, encodeQueryParamsForGetRequests } from './utils/restli-utils'; 11 | import { buildRestliUrl, getRestliRequestHeaders } from './utils/api-utils'; 12 | import { 13 | maybeApplyQueryTunnelingToRequestsWithoutBody, 14 | maybeApplyQueryTunnelingToRequestsWithBody 15 | } from './utils/query-tunneling'; 16 | import _ from 'lodash'; 17 | import { logSuccess, logError } from './utils/logging'; 18 | 19 | /** 20 | * Type Definitions 21 | */ 22 | 23 | export interface LIRestliRequestOptionsBase { 24 | /** 25 | * The resource path after the base URL, beginning with a forward slash. If the path contains keys, 26 | * add curly-brace placeholders for the keys and specify the path key-value map in the `pathKeys` argument. 27 | */ 28 | resourcePath: string; 29 | /** The access token that should provide the application access to the specified API */ 30 | accessToken: string; 31 | /** 32 | * If there are path keys that are part of the `resourcePath` argument, the key placeholders must be specified in 33 | * the provided `pathKeys` map. The path key values can be strings, numbers, or objects, and these 34 | * will be properly encoded. 35 | */ 36 | pathKeys?: Record; 37 | /** A map of query parameters, whose keys/values should not be encoded */ 38 | queryParams?: Record; 39 | /** optional version string of the format "YYYYMM" or "YYYYMM.RR". If specified, the version header will be passed and the request will use the versioned APIs base URL. */ 40 | versionString?: string | null; 41 | /** optional Axios request config object that will be merged into the request config. This will override any properties the client method sets, which may cause unexpected errors. Query params should not be passed here--instead they should be set in the queryParams property for proper Rest.li encoding. */ 42 | additionalConfig?: AxiosRequestConfig; 43 | } 44 | 45 | /** 46 | * A Rest.li entity 47 | */ 48 | export type RestliEntity = Record; 49 | 50 | /** 51 | * A Rest.li entity id or key. The id can be a string, number, or complex key. The id should not be encoded, as the client method will perform the correct encoding. 52 | */ 53 | export type RestliEntityId = string | number | Record; 54 | 55 | /** 56 | * An encoded entity id 57 | */ 58 | export type EncodedEntityId = string | number; 59 | 60 | /** 61 | * Paging metadata object 62 | */ 63 | export interface PagingObject { 64 | /** Start index of returned entities list (zero-based index) */ 65 | start: number; 66 | /** Number of entities returned */ 67 | count: number; 68 | /** Total number of entities */ 69 | total?: number; 70 | } 71 | 72 | /** 73 | * Request Options Interfaces 74 | */ 75 | 76 | export interface LIGetRequestOptions extends LIRestliRequestOptionsBase { 77 | /** The id or key of the entity to fetch. For simple resources, this should not be specified. */ 78 | id?: RestliEntityId | null; 79 | } 80 | 81 | export interface LIBatchGetRequestOptions extends LIRestliRequestOptionsBase { 82 | /** The list of ids to fetch on the resource. */ 83 | ids: RestliEntityId[]; 84 | } 85 | 86 | export interface LIGetAllRequestOptions extends LIRestliRequestOptionsBase {} 87 | 88 | export interface LICreateRequestOptions extends LIRestliRequestOptionsBase { 89 | /** A JSON serialized value of the entity to create */ 90 | entity: RestliEntity; 91 | } 92 | 93 | export interface LIBatchCreateRequestOptions extends LIRestliRequestOptionsBase { 94 | /** A list of JSON serialized entity values to create */ 95 | entities: RestliEntity[]; 96 | } 97 | 98 | export interface LIPartialUpdateRequestOptions extends LIRestliRequestOptionsBase { 99 | /** The id or key of the entity to update. For simple resources, this is not specified. */ 100 | id?: RestliEntityId | null; 101 | /** The JSON-serialized value of the entity with only the modified fields present. If specified, this will be directly sent as the patch object. */ 102 | patchSetObject?: RestliEntity; 103 | /** The JSON-serialized value of the original entity. If specified and patchSetObject is not provided, this will be used in conjunction with modifiedEntity to compute the patch object. */ 104 | originalEntity?: RestliEntity; 105 | /** The JSON-serialized value of the modified entity. If specified and patchSetObject is not provided, this will be used in conjunction with originalEntity to compute the patch object. */ 106 | modifiedEntity?: RestliEntity; 107 | } 108 | 109 | export interface LIBatchPartialUpdateRequestOptions extends LIRestliRequestOptionsBase { 110 | /** A list entity ids to update. */ 111 | ids: RestliEntityId[]; 112 | /** A list of JSON-serialized values of the entities with only the modified fields present. If specified, this will be directly sent as the patch object. */ 113 | patchSetObjects?: RestliEntity[]; 114 | /** A list of JSON-serialized values of the original entities. If specified and patchSetObjects is not provided, this will be used in conjunction with modifiedEntities to compute patch object for each entity. */ 115 | originalEntities?: RestliEntity[]; 116 | /** A list of JSON-serialized values of the modified entities. If specified and patchSetObjects is not provided, this will be used in conjunction with originalEntities to compute the patch object for each entity. */ 117 | modifiedEntities?: RestliEntity[]; 118 | } 119 | 120 | export interface LIUpdateRequestOptions extends LIRestliRequestOptionsBase { 121 | /** The id or key of the entity to update. For simple resources, this is not specified. */ 122 | id?: RestliEntityId | null; 123 | /** The JSON-serialized value of the entity with updated values. */ 124 | entity: RestliEntity; 125 | } 126 | 127 | export interface LIBatchUpdateRequestOptions extends LIRestliRequestOptionsBase { 128 | /** The list of entity ids to update. This should match with the corresponding entity object in the entities field. */ 129 | ids: RestliEntityId[]; 130 | /** The list of JSON-serialized values of entities with updated values. */ 131 | entities: RestliEntity[]; 132 | } 133 | 134 | export interface LIDeleteRequestOptions extends LIRestliRequestOptionsBase { 135 | /** The id or key of the entity to delete. For simple resources, this is not specified. */ 136 | id?: RestliEntityId | null; 137 | } 138 | 139 | export interface LIBatchDeleteRequestOptions extends LIRestliRequestOptionsBase { 140 | /** A list of entity ids to delete. */ 141 | ids: RestliEntityId[]; 142 | } 143 | 144 | export interface LIFinderRequestOptions extends LIRestliRequestOptionsBase { 145 | /** The Rest.li finder name */ 146 | finderName: string; 147 | } 148 | 149 | export interface BatchFinderCriteria { 150 | /** The batch finder crtieria parameter name. */ 151 | name: string; 152 | /** A list of finder param objects. batch finder results are correspondingly ordered according to this list. */ 153 | value: Array>; 154 | } 155 | 156 | export interface LIBatchFinderRequestOptions extends LIRestliRequestOptionsBase { 157 | /** 158 | * The Rest.li batch finder name (the value of the "bq" parameter). This will be added to the request 159 | * query parameters. 160 | */ 161 | finderName: string; 162 | /** 163 | * An object representing the batch finder criteria information. This will be encoded and added to the 164 | * request query parameters. 165 | */ 166 | finderCriteria: BatchFinderCriteria; 167 | } 168 | 169 | export interface LIActionRequestOptions extends LIRestliRequestOptionsBase { 170 | /** The Rest.li action name */ 171 | actionName: string; 172 | /** The request body data to pass to the action. */ 173 | data?: Record | null; 174 | } 175 | 176 | /** 177 | * Response Interfaces 178 | */ 179 | 180 | export interface LIGetResponse extends AxiosResponse { 181 | /** The entity that was fetched */ 182 | data: RestliEntity; 183 | } 184 | 185 | export interface LIBatchGetResponse extends AxiosResponse { 186 | data: { 187 | /** A map containing entities that could not be successfully fetched and their associated error responses */ 188 | errors: Record; 189 | /** A map of entities that were successfully retrieved */ 190 | results: Record; 191 | /** A map of entities and the corresponding status code */ 192 | statuses?: Record; 193 | }; 194 | } 195 | 196 | export interface LICollectionResponse extends AxiosResponse { 197 | data: { 198 | /** List of entities returned in the response. */ 199 | elements: RestliEntity[]; 200 | paging?: PagingObject; 201 | /** Optional response metadata. */ 202 | metadata?: any; 203 | }; 204 | } 205 | 206 | export interface LICreateResponse extends AxiosResponse { 207 | /** The decoded, created entity id */ 208 | createdEntityId: string | string[] | Record; 209 | } 210 | 211 | export interface LIBatchCreateResponse extends AxiosResponse { 212 | data: { 213 | /** A list of entity creation response data in the same order as the entities provided in the batch create request. */ 214 | elements: Array<{ 215 | /** The response status when creating the entity. */ 216 | status: number; 217 | /** The id of the newly-created entity, if creation was successful. */ 218 | id?: string; 219 | /** Error details when creating an entity, if creation failed. */ 220 | error?: any; 221 | }>; 222 | }; 223 | } 224 | 225 | export interface LIPartialUpdateResponse extends AxiosResponse {} 226 | 227 | export interface LIUpdateResponse extends AxiosResponse {} 228 | 229 | export interface LIBatchUpdateResponse extends AxiosResponse { 230 | data: { 231 | /** A map where the keys are the encoded entity ids that were successfully updated, and the values are the update results, which include the status code. */ 232 | results: Record< 233 | EncodedEntityId, 234 | { 235 | status: number; 236 | } 237 | >; 238 | /** A map where the keys are the encoded entity ids that failed to be updated, and the values include the error response. */ 239 | errors: Record; 240 | }; 241 | } 242 | 243 | export interface LIDeleteResponse extends AxiosResponse {} 244 | 245 | export interface LIBatchDeleteResponse extends AxiosResponse { 246 | data: { 247 | /** A map where the keys are the encoded entity ids that were successfully deleted, and the values are the delete results, which include the status code. */ 248 | results: Record< 249 | EncodedEntityId, 250 | { 251 | status: number; 252 | } 253 | >; 254 | /** A map where the keys are the encoded entity ids that failed to be deleted, and the values include the error response. */ 255 | errors: Record; 256 | }; 257 | } 258 | 259 | export interface LIBatchFinderResponse extends AxiosResponse { 260 | data: { 261 | /** An array of finder search results in the same order as the array of search criteria provided to the batch finder. */ 262 | elements: Array<{ 263 | /** An array of entities found based on the corresponding search critieria. */ 264 | elements: RestliEntity[]; 265 | paging?: PagingObject; 266 | metadata?: any; 267 | error?: any; 268 | /** Flag indicating whether the finder request encountered an error. */ 269 | isError?: boolean; 270 | }>; 271 | }; 272 | } 273 | 274 | export interface LIActionResponse extends AxiosResponse { 275 | data: { 276 | /** The action response value. */ 277 | value: boolean | string | number | Record; 278 | }; 279 | } 280 | 281 | export class RestliClient { 282 | axiosInstance: AxiosInstance; 283 | #debugEnabled = false; 284 | #logSuccessResponses = false; 285 | 286 | constructor(config: CreateAxiosDefaults = {}) { 287 | this.axiosInstance = axios.create(config); 288 | 289 | this.axiosInstance.interceptors.response.use( 290 | (response) => { 291 | if (this.#debugEnabled && this.#logSuccessResponses) { 292 | logSuccess(response); 293 | } 294 | return response; 295 | }, 296 | async (error) => { 297 | if (this.#debugEnabled) { 298 | logError(error); 299 | } 300 | return await Promise.reject(error); 301 | } 302 | ); 303 | } 304 | 305 | /** 306 | * Set debug logging parameters for the client. 307 | */ 308 | setDebugParams({ 309 | /** Flag whether to enable debug logging of request responses */ 310 | enabled = false, 311 | /** Flag whether to log successful responses */ 312 | logSuccessResponses = false 313 | }): void { 314 | this.#debugEnabled = enabled; 315 | this.#logSuccessResponses = logSuccessResponses; 316 | } 317 | 318 | /** 319 | * Makes a Rest.li GET request to fetch the specified entity on a resource. This method 320 | * will perform query tunneling if necessary. 321 | * 322 | * @example 323 | * ```ts 324 | * client.get({ 325 | * resourcePath: '/adAccounts/{id}', 326 | * pathKeys: { 327 | * id: 123 328 | * }, 329 | * queryParams: { 330 | * fields: 'id,name' 331 | * }, 332 | * accessToken: 'ABC123', 333 | * versionString: '202210' 334 | * }).then(response => { 335 | * const entity = response.data; 336 | * }); 337 | * ``` 338 | * 339 | * @returns a Promise that resolves to the response object containing the entity. 340 | */ 341 | async get({ 342 | resourcePath, 343 | accessToken, 344 | pathKeys = {}, 345 | queryParams = {}, 346 | versionString = null, 347 | additionalConfig = {} 348 | }: LIGetRequestOptions): Promise { 349 | const encodedQueryParamString = encodeQueryParamsForGetRequests(queryParams); 350 | const urlPath = buildRestliUrl(resourcePath, pathKeys, versionString); 351 | 352 | const requestConfig = maybeApplyQueryTunnelingToRequestsWithoutBody({ 353 | encodedQueryParamString, 354 | urlPath, 355 | originalRestliMethod: RESTLI_METHODS.GET, 356 | accessToken, 357 | versionString, 358 | additionalConfig 359 | }); 360 | 361 | return await this.axiosInstance.request(requestConfig); 362 | } 363 | 364 | /** 365 | * Makes a Rest.li BATCH_GET request to fetch multiple entities on a resource. This method 366 | * will perform query tunneling if necessary. 367 | * 368 | * @example 369 | * ```ts 370 | * client.batchGet({ 371 | * resourcePath: '/adCampaignGroups', 372 | * ids: [123, 456, 789], 373 | * accessToken: 'ABC123', 374 | * versionString: '202210' 375 | * }).then(response => { 376 | * const entities = response.data.results; 377 | * }) 378 | * ``` 379 | */ 380 | async batchGet({ 381 | resourcePath, 382 | ids, 383 | pathKeys = {}, 384 | queryParams = {}, 385 | versionString = null, 386 | accessToken, 387 | additionalConfig = {} 388 | }: LIBatchGetRequestOptions): Promise { 389 | const encodedQueryParamString = encodeQueryParamsForGetRequests({ 390 | ids, 391 | ...queryParams 392 | }); 393 | const urlPath = buildRestliUrl(resourcePath, pathKeys, versionString); 394 | 395 | const requestConfig = maybeApplyQueryTunnelingToRequestsWithoutBody({ 396 | encodedQueryParamString, 397 | urlPath, 398 | originalRestliMethod: RESTLI_METHODS.BATCH_GET, 399 | accessToken, 400 | versionString, 401 | additionalConfig 402 | }); 403 | 404 | return await this.axiosInstance.request(requestConfig); 405 | } 406 | 407 | /** 408 | * Makes a Rest.li GET_ALL request to fetch all entities on a resource. This method 409 | * will perform query tunneling if necessary. 410 | * 411 | * @example 412 | * ```ts 413 | * client.getAll({ 414 | * resourcePath: '/fieldsOfStudy', 415 | * queryParams: { 416 | * start: 0, 417 | * count: 15 418 | * }, 419 | * accessToken: 'ABC123' 420 | * }).then(response => { 421 | * const entities = response.data.elements; 422 | * }) 423 | * ``` 424 | */ 425 | async getAll({ 426 | resourcePath, 427 | accessToken, 428 | pathKeys = {}, 429 | queryParams = {}, 430 | versionString = null, 431 | additionalConfig = {} 432 | }: LIGetAllRequestOptions): Promise { 433 | const urlPath = buildRestliUrl(resourcePath, pathKeys, versionString); 434 | const encodedQueryParamString = encodeQueryParamsForGetRequests(queryParams); 435 | 436 | const requestConfig = maybeApplyQueryTunnelingToRequestsWithoutBody({ 437 | encodedQueryParamString, 438 | urlPath, 439 | originalRestliMethod: RESTLI_METHODS.GET_ALL, 440 | accessToken, 441 | versionString, 442 | additionalConfig 443 | }); 444 | 445 | return await this.axiosInstance.request(requestConfig); 446 | } 447 | 448 | /** 449 | * Makes a Rest.li FINDER request to find entities by some specified criteria. This method 450 | * will perform query tunneling if necessary. 451 | * 452 | * @example 453 | * ```ts 454 | * restliClient.finder({ 455 | * resourcePath: '/adAccounts', 456 | * finderName: 'search', 457 | * queryParams: { 458 | * search: { 459 | * status: { 460 | * values: ['DRAFT', 'ACTIVE', 'REMOVED'] 461 | * } 462 | * } 463 | * }, 464 | * accessToken: 'ABC123', 465 | * versionString: '202210' 466 | * }).then(response => { 467 | * const elements = response.data.elements; 468 | * const total = response.data.paging.total; 469 | * }); 470 | * ``` 471 | */ 472 | async finder({ 473 | resourcePath, 474 | finderName, 475 | pathKeys = {}, 476 | queryParams = {}, 477 | versionString = null, 478 | accessToken, 479 | additionalConfig = {} 480 | }: LIFinderRequestOptions): Promise { 481 | const urlPath = buildRestliUrl(resourcePath, pathKeys, versionString); 482 | const encodedQueryParamString = encodeQueryParamsForGetRequests({ 483 | q: finderName, 484 | ...queryParams 485 | }); 486 | 487 | const requestConfig = maybeApplyQueryTunnelingToRequestsWithoutBody({ 488 | encodedQueryParamString, 489 | urlPath, 490 | originalRestliMethod: RESTLI_METHODS.FINDER, 491 | accessToken, 492 | versionString, 493 | additionalConfig 494 | }); 495 | 496 | return await this.axiosInstance.request(requestConfig); 497 | } 498 | 499 | /** 500 | * Makes a Rest.li BATCH_FINDER request to find entities by multiple sets of 501 | * criteria. This method will perform query tunneling if necessary. 502 | * 503 | * @example 504 | * ```ts 505 | * restliClient.batchFinder({ 506 | * resourcePath: '/organizationAuthorizations', 507 | * finderName: 'authorizationActionsAndImpersonator', 508 | * finderCriteria: { 509 | * name: 'authorizationActions', 510 | * value: [ 511 | * { 512 | * 'OrganizationRoleAuthorizationAction': { 513 | * actionType: 'ADMINISTRATOR_READ' 514 | * } 515 | * }, 516 | * { 517 | * 'OrganizationContentAuthorizationAction': { 518 | * actionType: 'ORGANIC_SHARE_DELETE' 519 | * } 520 | * } 521 | * ] 522 | * }, 523 | * accessToken: 'ABC123', 524 | * versionString: '202210' 525 | * }).then(response => { 526 | * const allFinderResults = response.data.elements; 527 | * }); 528 | * ``` 529 | */ 530 | async batchFinder({ 531 | resourcePath, 532 | finderName, 533 | finderCriteria, 534 | pathKeys = {}, 535 | queryParams = {}, 536 | versionString = null, 537 | accessToken, 538 | additionalConfig = {} 539 | }: LIBatchFinderRequestOptions): Promise { 540 | const urlPath = buildRestliUrl(resourcePath, pathKeys, versionString); 541 | const encodedQueryParamString = encodeQueryParamsForGetRequests({ 542 | bq: finderName, 543 | [finderCriteria.name]: finderCriteria.value, 544 | ...queryParams 545 | }); 546 | 547 | const requestConfig = maybeApplyQueryTunnelingToRequestsWithoutBody({ 548 | encodedQueryParamString, 549 | urlPath, 550 | originalRestliMethod: RESTLI_METHODS.BATCH_FINDER, 551 | accessToken, 552 | versionString, 553 | additionalConfig 554 | }); 555 | 556 | return await this.axiosInstance.request(requestConfig); 557 | } 558 | 559 | /** 560 | * Makes a Rest.li CREATE request to create a new entity on the resource. 561 | * 562 | * @example 563 | * ```ts 564 | * client.create({ 565 | * resourcePath: '/adAccountsV2', 566 | * entity: { 567 | * name: 'Test Ad Account', 568 | * type: 'BUSINESS', 569 | * test: true 570 | * }, 571 | * accessToken: 'ABC123' 572 | * }).then(response => { 573 | * const createdId = response.createdEntityId; 574 | * }) 575 | * ``` 576 | */ 577 | async create({ 578 | resourcePath, 579 | entity, 580 | pathKeys = {}, 581 | queryParams = {}, 582 | versionString = null, 583 | accessToken, 584 | additionalConfig = {} 585 | }: LICreateRequestOptions): Promise { 586 | const urlPath = buildRestliUrl(resourcePath, pathKeys, versionString); 587 | const encodedQueryParamString = paramEncode(queryParams); 588 | const requestConfig = _.merge( 589 | { 590 | method: HTTP_METHODS.POST, 591 | url: encodedQueryParamString ? `${urlPath}?${encodedQueryParamString}` : urlPath, 592 | data: entity, 593 | headers: getRestliRequestHeaders({ 594 | restliMethodType: RESTLI_METHODS.CREATE, 595 | accessToken, 596 | versionString 597 | }) 598 | }, 599 | additionalConfig 600 | ); 601 | 602 | const originalResponse = await this.axiosInstance.request(requestConfig); 603 | return { 604 | ...originalResponse, 605 | createdEntityId: getCreatedEntityId(originalResponse, true) 606 | }; 607 | } 608 | 609 | /** 610 | * Makes a Rest.li BATCH_CREATE request to create multiple entities in 611 | * a single call. 612 | * 613 | * @example 614 | * ```ts 615 | * client.batchCreate({ 616 | * resourcePath: '/adCampaignGroups', 617 | * entities: [ 618 | * { 619 | * account: 'urn:li:sponsoredAccount:111', 620 | * name: 'CampaignGroupTest1', 621 | * status: 'DRAFT' 622 | * }, 623 | * { 624 | * account: 'urn:li:sponsoredAccount:222', 625 | * name: 'CampaignGroupTest2', 626 | * status: 'DRAFT' 627 | * } 628 | * ], 629 | * versionString: '202209', 630 | * accessToken: 'ABC123' 631 | * }).then(response => { 632 | * const createdElementsInfo = response.data.elements; 633 | * }); 634 | * ``` 635 | */ 636 | async batchCreate({ 637 | resourcePath, 638 | entities, 639 | pathKeys = {}, 640 | queryParams = {}, 641 | versionString = null, 642 | accessToken, 643 | additionalConfig = {} 644 | }: LIBatchCreateRequestOptions): Promise { 645 | const urlPath = buildRestliUrl(resourcePath, pathKeys, versionString); 646 | const encodedQueryParamString = paramEncode(queryParams); 647 | const requestConfig = _.merge( 648 | { 649 | method: HTTP_METHODS.POST, 650 | url: encodedQueryParamString ? `${urlPath}?${encodedQueryParamString}` : urlPath, 651 | data: { 652 | elements: entities 653 | }, 654 | headers: getRestliRequestHeaders({ 655 | restliMethodType: RESTLI_METHODS.BATCH_CREATE, 656 | accessToken, 657 | versionString 658 | }) 659 | }, 660 | additionalConfig 661 | ); 662 | return await this.axiosInstance.request(requestConfig); 663 | } 664 | 665 | /** 666 | * Makes a Rest.li PARTIAL_UPDATE request to update part of an entity. One can either 667 | * pass the full original and modified entity objects, with the method computing the correct 668 | * patch object, or one can directly pass the patch object to send in the request. 669 | * 670 | * When an entity has nested fields that can be modified, passing in the original and modified 671 | * entities may produce a complex patch object that is a technically correct format for the Rest.li 672 | * framework, but may not be supported for most LinkedIn APIs which mainly support partial 673 | * update of only top-level fields on an entity. In these cases it is better to specify `patchSetObject` 674 | * directly. 675 | * 676 | * This method will perform query tunneling if necessary. 677 | * 678 | * @example 679 | * ```ts 680 | * client.partialUpdate({ 681 | * resourcePath: '/adAccounts/{id}', 682 | * pathKeys: { 683 | * id: 123 684 | * }, 685 | * patchSetObject: { 686 | * name: 'TestAdAccountModified', 687 | * reference: 'urn:li:organization:456' 688 | * }, 689 | * versionString: '202209', 690 | * accessToken: 'ABC123' 691 | * }).then(response => { 692 | * ... 693 | * }); 694 | * ``` 695 | */ 696 | async partialUpdate({ 697 | resourcePath, 698 | patchSetObject, 699 | originalEntity, 700 | modifiedEntity, 701 | pathKeys = {}, 702 | queryParams = {}, 703 | versionString = null, 704 | accessToken, 705 | additionalConfig = {} 706 | }: LIPartialUpdateRequestOptions): Promise { 707 | const encodedQueryParamString = paramEncode(queryParams); 708 | const urlPath = buildRestliUrl(resourcePath, pathKeys, versionString); 709 | 710 | let patchData; 711 | if (patchSetObject) { 712 | if (typeof patchSetObject === 'object' && Object.keys(patchSetObject).length === 0) { 713 | throw new Error('patchSetObject must be an object with at least one key-value pair'); 714 | } 715 | patchData = { patch: { $set: patchSetObject } }; 716 | } else if (originalEntity && modifiedEntity) { 717 | patchData = getPatchObject(originalEntity, modifiedEntity); 718 | if (!patchData || Object.keys(patchData).length === 0) { 719 | throw new Error('There must be a difference between originalEntity and modifiedEntity'); 720 | } 721 | } else { 722 | throw new Error( 723 | 'Either patchSetObject or originalEntity and modifiedEntity properties must be present' 724 | ); 725 | } 726 | 727 | const requestConfig = maybeApplyQueryTunnelingToRequestsWithBody({ 728 | encodedQueryParamString, 729 | urlPath, 730 | originalRestliMethod: RESTLI_METHODS.PARTIAL_UPDATE, 731 | originalJSONRequestBody: patchData, 732 | accessToken, 733 | versionString, 734 | additionalConfig 735 | }); 736 | 737 | return await this.axiosInstance.request(requestConfig); 738 | } 739 | 740 | /** 741 | * Makes a Rest.li BATCH_PARTIAL_UPDATE request to partially update multiple entites at 742 | * once. This method will perform query tunneling if necessary. 743 | * 744 | * @example 745 | * ```ts 746 | * client.batchPartialUpdate({ 747 | * resourcePath: '/adCampaignGroups', 748 | * ids: [123, 456], 749 | * patchSetObjects: [ 750 | * { status: 'ACTIVE' }, 751 | * { 752 | * runSchedule: { 753 | * start: 1678029270721, 754 | * end: 1679029270721 755 | * } 756 | * } 757 | * ], 758 | * versionString: '202209', 759 | * accessToken: 'ABC123' 760 | * }).then(response => { 761 | * const results = response.data.results; 762 | * }) 763 | * ``` 764 | */ 765 | async batchPartialUpdate({ 766 | resourcePath, 767 | ids, 768 | originalEntities, 769 | modifiedEntities, 770 | patchSetObjects, 771 | pathKeys = {}, 772 | queryParams = {}, 773 | versionString = null, 774 | accessToken, 775 | additionalConfig = {} 776 | }: LIBatchPartialUpdateRequestOptions): Promise { 777 | const urlPath = buildRestliUrl(resourcePath, pathKeys, versionString); 778 | 779 | if (patchSetObjects) { 780 | if (ids.length !== patchSetObjects.length) { 781 | throw new Error('The fields { ids, patchSetObjects } must be arrays with the same length'); 782 | } 783 | } else if (originalEntities && modifiedEntities) { 784 | if ( 785 | ids.length !== originalEntities.length && 786 | originalEntities.length !== modifiedEntities.length 787 | ) { 788 | throw new Error( 789 | 'The fields { ids, originalEntities, modifiedEntities } must be arrays with the same length' 790 | ); 791 | } 792 | } else { 793 | throw new Error( 794 | 'Either { patchSetObjects } or { originalEntities, modifiedEntities } need to be provided as input parameters' 795 | ); 796 | } 797 | 798 | const encodedQueryParamString = paramEncode({ 799 | ids, 800 | ...queryParams 801 | }); 802 | let entities; 803 | 804 | if (patchSetObjects) { 805 | entities = ids.reduce((prev, curr, index) => { 806 | const encodedEntityId = encode(curr); 807 | prev[encodedEntityId] = { 808 | patch: { $set: patchSetObjects[index] } 809 | }; 810 | return prev; 811 | }, {}); 812 | } else if (originalEntities && modifiedEntities) { 813 | entities = ids.reduce((prev, curr, index) => { 814 | const encodedEntityId = encode(curr); 815 | prev[encodedEntityId] = getPatchObject(originalEntities[index], modifiedEntities[index]); 816 | return prev; 817 | }, {}); 818 | } 819 | 820 | const requestConfig = maybeApplyQueryTunnelingToRequestsWithBody({ 821 | encodedQueryParamString, 822 | urlPath, 823 | originalRestliMethod: RESTLI_METHODS.BATCH_PARTIAL_UPDATE, 824 | originalJSONRequestBody: { 825 | entities 826 | }, 827 | accessToken, 828 | versionString, 829 | additionalConfig 830 | }); 831 | 832 | return await this.axiosInstance.request(requestConfig); 833 | } 834 | 835 | /** 836 | * Makes a Rest.li UPDATE request to update an entity (overwriting the entire entity). 837 | * This method will perform query tunneling if necessary. 838 | * 839 | * @example 840 | * ```ts 841 | * client.update({ 842 | * resourcePath: '/adAccountUsers/{accountUserKey}', 843 | * pathKeys: { 844 | * accountUserKey: { 845 | * account: 'urn:li:sponsoredAccount:123', 846 | * user: 'urn:li:person:foobar' 847 | * } 848 | * }, 849 | * entity: { 850 | * account: 'urn:li:sponsoredAccount:123', 851 | * user: 'urn:li:person:foobar', 852 | * role: 'VIEWER' 853 | * }, 854 | * versionString: '202209', 855 | * accessToken: 'ABC123' 856 | * }).then(response => { 857 | * ... 858 | * }); 859 | * ``` 860 | */ 861 | async update({ 862 | resourcePath, 863 | entity, 864 | pathKeys = {}, 865 | queryParams = {}, 866 | versionString = null, 867 | accessToken, 868 | additionalConfig = {} 869 | }: LIUpdateRequestOptions): Promise { 870 | const urlPath = buildRestliUrl(resourcePath, pathKeys, versionString); 871 | const encodedQueryParamString = paramEncode(queryParams); 872 | 873 | const requestConfig = maybeApplyQueryTunnelingToRequestsWithBody({ 874 | encodedQueryParamString, 875 | urlPath, 876 | originalRestliMethod: RESTLI_METHODS.UPDATE, 877 | originalJSONRequestBody: entity, 878 | accessToken, 879 | versionString, 880 | additionalConfig 881 | }); 882 | 883 | return await this.axiosInstance.request(requestConfig); 884 | } 885 | 886 | /** 887 | * Makes a Rest.li BATCH_UPDATE request to update multiple entities in a single call. 888 | * This method will perform query tunneling if necessary. 889 | * 890 | * @example 891 | * ```ts 892 | * client.batchUpdate({ 893 | * resourcePath: '/campaignConversions', 894 | * ids: [ 895 | * { campaign: 'urn:li:sponsoredCampaign:123', conversion: 'urn:lla:llaPartnerConversion:456' }, 896 | * { campaign: 'urn:li:sponsoredCampaign:123', conversion: 'urn:lla:llaPartnerConversion:789' } 897 | * ], 898 | * entities: [ 899 | * { campaign: 'urn:li:sponsoredCampaign:123', conversion: 'urn:lla:llaPartnerConversion:456' }, 900 | * { campaign: 'urn:li:sponsoredCampaign:123', conversion: 'urn:lla:llaPartnerConversion:789' } 901 | * ], 902 | * accessToken: 'ABC123' 903 | * }).then(response => { 904 | * const results = response.data.results; 905 | * }) 906 | * ``` 907 | */ 908 | async batchUpdate({ 909 | resourcePath, 910 | ids, 911 | entities, 912 | pathKeys = {}, 913 | queryParams = {}, 914 | versionString = null, 915 | accessToken, 916 | additionalConfig = {} 917 | }: LIBatchUpdateRequestOptions): Promise { 918 | const urlPath = buildRestliUrl(resourcePath, pathKeys, versionString); 919 | const encodedQueryParamString = paramEncode({ 920 | ids, 921 | ...queryParams 922 | }); 923 | // This as any[] workaround is due to this issue: https://github.com/microsoft/TypeScript/issues/36390 924 | const entitiesObject = (ids as any[]).reduce((entitiesObject, currId, index) => { 925 | entitiesObject[encode(currId)] = entities[index]; 926 | return entitiesObject; 927 | }, {}); 928 | 929 | const requestConfig = maybeApplyQueryTunnelingToRequestsWithBody({ 930 | encodedQueryParamString, 931 | urlPath, 932 | originalRestliMethod: RESTLI_METHODS.BATCH_UPDATE, 933 | originalJSONRequestBody: { 934 | entities: entitiesObject 935 | }, 936 | accessToken, 937 | versionString, 938 | additionalConfig 939 | }); 940 | 941 | return await this.axiosInstance.request(requestConfig); 942 | } 943 | 944 | /** 945 | * Makes a Rest.li DELETE request to delete an entity 946 | * 947 | * @sample 948 | * ```ts 949 | * restliClient.delete({ 950 | * resourcePath: '/adAccounts/{id}', 951 | * pathKeys: { 952 | * id: 123 953 | * }, 954 | * versionString: '202210', 955 | * accessToken: 'ABC123' 956 | * }).then(response => { 957 | * const status = response.status; 958 | * }); 959 | * ``` 960 | */ 961 | async delete({ 962 | resourcePath, 963 | pathKeys = {}, 964 | queryParams = {}, 965 | versionString = null, 966 | accessToken, 967 | additionalConfig = {} 968 | }: LIDeleteRequestOptions): Promise { 969 | const urlPath = buildRestliUrl(resourcePath, pathKeys, versionString); 970 | const encodedQueryParamString = paramEncode(queryParams); 971 | 972 | const requestConfig = maybeApplyQueryTunnelingToRequestsWithoutBody({ 973 | encodedQueryParamString, 974 | urlPath, 975 | originalRestliMethod: RESTLI_METHODS.DELETE, 976 | accessToken, 977 | versionString, 978 | additionalConfig 979 | }); 980 | 981 | return await this.axiosInstance.request(requestConfig); 982 | } 983 | 984 | /** 985 | * Makes a Rest.li BATCH_DELETE request to delete multiple entities at once. 986 | * 987 | * @sample 988 | * ```ts 989 | * restliClient.batchDelete({ 990 | * resourcePath: '/adAccounts', 991 | * ids: [123, 456], 992 | * versionString: '202210', 993 | * accessToken: 'ABC123' 994 | * }).then(response => { 995 | * const results = response.data.results; 996 | * }); 997 | * ``` 998 | */ 999 | async batchDelete({ 1000 | resourcePath, 1001 | ids, 1002 | pathKeys = {}, 1003 | queryParams = {}, 1004 | versionString = null, 1005 | accessToken, 1006 | additionalConfig = {} 1007 | }: LIBatchDeleteRequestOptions): Promise { 1008 | const urlPath = buildRestliUrl(resourcePath, pathKeys, versionString); 1009 | const encodedQueryParamString = paramEncode({ 1010 | ids, 1011 | ...queryParams 1012 | }); 1013 | 1014 | const requestConfig = maybeApplyQueryTunnelingToRequestsWithoutBody({ 1015 | encodedQueryParamString, 1016 | urlPath, 1017 | originalRestliMethod: RESTLI_METHODS.BATCH_DELETE, 1018 | accessToken, 1019 | versionString, 1020 | additionalConfig 1021 | }); 1022 | 1023 | return await this.axiosInstance.request(requestConfig); 1024 | } 1025 | 1026 | /** 1027 | * Makes a Rest.li ACTION request to perform an action on a specified resource 1028 | * 1029 | * @example 1030 | * ```ts 1031 | * restliClient.action({ 1032 | * resource: 'testResource', 1033 | * actionName: 'doSomething' 1034 | * data: { 1035 | * additionalParam: 123 1036 | * }, 1037 | * accessToken: 'ABC123' 1038 | * }).then(response => { 1039 | * const result = response.data.value; 1040 | * }) 1041 | * ``` 1042 | */ 1043 | async action({ 1044 | resourcePath, 1045 | actionName, 1046 | data = null, 1047 | pathKeys = {}, 1048 | queryParams = {}, 1049 | versionString = null, 1050 | accessToken, 1051 | additionalConfig 1052 | }: LIActionRequestOptions): Promise { 1053 | const urlPath = buildRestliUrl(resourcePath, pathKeys, versionString); 1054 | const encodedQueryParamString = paramEncode({ 1055 | action: actionName, 1056 | ...queryParams 1057 | }); 1058 | const requestConfig = _.merge({ 1059 | method: HTTP_METHODS.POST, 1060 | url: `${urlPath}?${encodedQueryParamString}`, 1061 | data, 1062 | headers: getRestliRequestHeaders({ 1063 | restliMethodType: RESTLI_METHODS.ACTION, 1064 | accessToken, 1065 | versionString 1066 | }), 1067 | additionalConfig 1068 | }); 1069 | return await this.axiosInstance.request(requestConfig); 1070 | } 1071 | } 1072 | --------------------------------------------------------------------------------