diff --git a/package-lock.json b/package-lock.json index 25ac4ff..2f6f9cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,21 @@ { "name": "@ecomshft/ebay-api", - "version": "5.1.0", + "version": "5.1.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@ecomshft/ebay-api", - "version": "5.1.0", + "version": "5.1.3", "license": "MIT", "dependencies": { "axios": "^0.21.4", + "bluebird": "^3.7.2", "debug": "^2.1.1", "fast-xml-parser": "^3.19.0", "nanoevents": "^2.0.0", - "qs": "^6.8.0" + "qs": "^6.8.0", + "redis": "^3.1.1" }, "devDependencies": { "@rollup/plugin-commonjs": "^15.1.0", @@ -882,6 +884,11 @@ "node": ">=8" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1570,6 +1577,14 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/detect-indent": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", @@ -4297,6 +4312,48 @@ "node": ">=8" } }, + "node_modules/redis": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", + "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", + "dependencies": { + "denque": "^1.5.0", + "redis-commands": "^1.7.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-redis" + } + }, + "node_modules/redis-commands": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", + "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==" + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/release-zalgo": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", @@ -6021,6 +6078,11 @@ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "dev": true }, + "bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -6563,6 +6625,11 @@ "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", "dev": true }, + "denque": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==" + }, "detect-indent": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", @@ -8625,6 +8692,35 @@ "strip-indent": "^3.0.0" } }, + "redis": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", + "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", + "requires": { + "denque": "^1.5.0", + "redis-commands": "^1.7.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0" + } + }, + "redis-commands": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", + "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==" + }, + "redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=" + }, + "redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "requires": { + "redis-errors": "^1.0.0" + } + }, "release-zalgo": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", diff --git a/package.json b/package.json index 017233b..1af400c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@ecomshft/ebay-api", "author": "Daniil Tomilow (original author)", - "version": "5.1.2", + "version": "5.3.1", "description": "Modified version of eBay TypeScript/JavaScript API for Node and Browser", "browser": "./lib/ebay-api.min.js", "main": "./lib/index.js", @@ -20,10 +20,12 @@ }, "dependencies": { "axios": "^0.21.4", + "bluebird": "^3.7.2", "debug": "^2.1.1", "fast-xml-parser": "^3.19.0", "nanoevents": "^2.0.0", - "qs": "^6.8.0" + "qs": "^6.8.0", + "redis": "^3.1.1" }, "publishConfig": { "registry": "https://npm.pkg.github.com" diff --git a/src/api/restful/index.ts b/src/api/restful/index.ts index 8c1af56..b616892 100644 --- a/src/api/restful/index.ts +++ b/src/api/restful/index.ts @@ -1,51 +1,57 @@ -import Api from '../'; -import Auth from '../../auth'; -import {EBayInvalidAccessToken, handleEBayError} from '../../errors'; -import {IEBayApiRequest} from '../../request'; -import {AppConfig} from '../../types'; +import Api from "../"; +import Auth from "../../auth"; +import { EBayInvalidAccessToken, handleEBayError } from "../../errors"; +import { IEBayApiRequest } from "../../request"; +import { AppConfig } from "../../types"; export const defaultApiHeaders: Record = { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-cache', + "Content-Type": "application/json", + "Cache-Control": "no-cache", // @ts-ignore - ...(typeof window === 'undefined' ? { - 'Accept-Encoding': 'application/gzip' - } : {}) + ...(typeof window === "undefined" + ? { + "Accept-Encoding": "application/gzip", + } + : {}), }; const additionalHeaders: Record = { - marketplaceId: 'X-EBAY-C-MARKETPLACE-ID', - endUserCtx: 'X-EBAY-C-ENDUSERCTX', - acceptLanguage: 'Accept-Language', - contentLanguage: 'Content-Language', + marketplaceId: "X-EBAY-C-MARKETPLACE-ID", + endUserCtx: "X-EBAY-C-ENDUSERCTX", + acceptLanguage: "Accept-Language", + contentLanguage: "Content-Language", }; export type ApiConfig = { - subdomain?: string - useIaf?: boolean - apiVersion?: string - basePath?: string - schema?: string - sandbox?: boolean - tld?: string - headers?: Record -} + subdomain?: string; + useIaf?: boolean; + apiVersion?: string; + basePath?: string; + schema?: string; + sandbox?: boolean; + tld?: string; + headers?: Record; +}; export type ApiRequest = { - method: keyof IEBayApiRequest, - url: string, - config?: any, // AxiosConfig - data?: any, -} + method: keyof IEBayApiRequest; + url: string; + config?: any; // AxiosConfig + data?: any; +}; export interface IRestful { - new(config: AppConfig, req?: IEBayApiRequest, auth?: Auth, apiConfig?: ApiConfig): Restful; + new ( + config: AppConfig, + req?: IEBayApiRequest, + auth?: Auth, + apiConfig?: ApiConfig + ): Restful; id: string; } export default abstract class Restful extends Api { - public readonly apiConfig: Required; constructor( @@ -58,12 +64,17 @@ export default abstract class Restful extends Api { this.apiConfig = { ...this.getApiConfig(), - ...apiConfig + ...apiConfig, }; } - public static buildServerUrl(schema: string, subdomain: string, sandbox: boolean, tld: string) { - return `${schema}${subdomain}.${sandbox ? 'sandbox.' : ''}${tld}`; + public static buildServerUrl( + schema: string, + subdomain: string, + sandbox: boolean, + tld: string + ) { + return `${schema}${subdomain}.${sandbox ? "sandbox." : ""}${tld}`; } abstract get basePath(): string; @@ -76,19 +87,30 @@ export default abstract class Restful extends Api { } get schema() { - return 'https://'; + return "https://"; } get subdomain() { - return 'api'; + return "api"; } get apiVersionPath() { - return ''; + return ""; } - public getServerUrl({schema, subdomain, apiVersion, basePath, sandbox, tld}: Required): string { - return Restful.buildServerUrl(schema, subdomain, sandbox, tld) + apiVersion + basePath; + public getServerUrl({ + schema, + subdomain, + apiVersion, + basePath, + sandbox, + tld, + }: Required): string { + return ( + Restful.buildServerUrl(schema, subdomain, sandbox, tld) + + apiVersion + + basePath + ); } public getApiConfig(): Required { @@ -99,8 +121,8 @@ export default abstract class Restful extends Api { basePath: this.basePath, schema: this.schema, sandbox: this.config.sandbox, - tld: 'ebay.com', - headers: {} + tld: "ebay.com", + headers: {}, }; } @@ -121,59 +143,90 @@ export default abstract class Restful extends Api { * Use "apix" subdomain */ get apix() { - return this.api({subdomain: 'apix'}); + return this.api({ subdomain: "apix" }); } /** * Use "apiz" subdomain */ get apiz() { - return this.api({subdomain: 'apiz'}); + return this.api({ subdomain: "apiz" }); } - public async get(url: string, config: any = {}, apiConfig?: ApiConfig) { - return this.doRequest({method: 'get', url, config}, apiConfig); + public async get( + url: string, + config: any = {}, + apiConfig?: ApiConfig, + requestHeaders?: boolean + ) { + return this.doRequest( + { method: "get", url, config: { ...config, requestHeaders } }, + apiConfig + ); } public async delete(url: string, config: any = {}, apiConfig?: ApiConfig) { - return this.doRequest({method: 'delete', url, config}, apiConfig); + return this.doRequest({ method: "delete", url, config }, apiConfig); } - public async post(url: string, data?: any, config: any = {}, apiConfig?: ApiConfig) { - return this.doRequest({method: 'post', url, data, config}, apiConfig); + public async post( + url: string, + data?: any, + config: any = {}, + apiConfig?: ApiConfig, + requestHeaders?: boolean + ) { + return this.doRequest( + { method: "post", url, data, config: { ...config, requestHeaders } }, + apiConfig + ); } - public async put(url: string, data?: any, config: any = {}, apiConfig?: ApiConfig) { - return this.doRequest({method: 'put', url, data, config}, apiConfig); + public async put( + url: string, + data?: any, + config: any = {}, + apiConfig?: ApiConfig, + requestHeaders?: boolean + ) { + return this.doRequest( + { method: "put", url, data, config: { ...config, requestHeaders } }, + apiConfig + ); } get additionalHeaders() { - return Object.keys(additionalHeaders) - // @ts-ignore - .filter(key => typeof this.config[key] !== 'undefined') - .reduce((headers: any, key) => { + return ( + Object.keys(additionalHeaders) // @ts-ignore - headers[additionalHeaders[key]] = this.config[key]; - return headers; - }, {}); + .filter((key) => typeof this.config[key] !== "undefined") + .reduce((headers: any, key) => { + // @ts-ignore + headers[additionalHeaders[key]] = this.config[key]; + return headers; + }, {}) + ); } - public async enrichRequestConfig(config: any = {}, apiConfig: Required = this.apiConfig) { + public async enrichRequestConfig( + config: any = {}, + apiConfig: Required = this.apiConfig + ) { const authHeader = await this.auth.getHeaderAuthorization(apiConfig.useIaf); const headers = { ...defaultApiHeaders, ...this.additionalHeaders, ...authHeader, - ...apiConfig.headers + ...apiConfig.headers, }; return { ...config, headers: { ...(config.headers || {}), - ...headers - } + ...headers, + }, }; } @@ -199,17 +252,20 @@ export default abstract class Restful extends Api { return true; } - return error?.meta?.res?.status === 401 && this.apiConfig.basePath === '/post-order/v2'; + return ( + error?.meta?.res?.status === 401 && + this.apiConfig.basePath === "/post-order/v2" + ); } private async request( apiRequest: ApiRequest, apiConfig: ApiConfig = this.apiConfig, - refreshToken = false, + refreshToken = false ): Promise { - const {url, method, data, config} = apiRequest; - - const apiCfg: Required = {...this.apiConfig, ...apiConfig}; + const { url, method, data, config } = apiRequest; + const { requestHeaders } = config; + const apiCfg: Required = { ...this.apiConfig, ...apiConfig }; const endpoint = this.getServerUrl(apiCfg) + url; try { @@ -219,7 +275,12 @@ export default abstract class Restful extends Api { const enrichedConfig = await this.enrichRequestConfig(config, apiCfg); - const args = ['get', 'delete'].includes(method) ? [enrichedConfig] : [data, enrichedConfig]; + let args = ["get", "delete"].includes(method) + ? [enrichedConfig] + : [data, enrichedConfig]; + if (["post", "get", "put"].includes(method) && requestHeaders) { + args.push(true); + } // @ts-ignore return await this.req[method](endpoint, ...args); } catch (ex) { diff --git a/src/api/restful/sell/feed/index.ts b/src/api/restful/sell/feed/index.ts index 6b7b7f7..7d8b282 100644 --- a/src/api/restful/sell/feed/index.ts +++ b/src/api/restful/sell/feed/index.ts @@ -1,16 +1,15 @@ -import Restful from '../../'; -import {multipartHeader} from '../../../../request'; -import {SellFeedParams} from '../../../../types'; +import Restful from "../../"; +import { multipartHeader } from "../../../../request"; +import { SellFeedParams } from "../../../../types"; /** * The Feed API lets sellers upload input files, download reports and files including their status, filter reports using URI parameters, and retrieve customer service metrics task details. */ export default class Feed extends Restful { - - static id = 'Feed'; + static id = "Feed"; get basePath(): string { - return '/sell/feed/v1'; + return "/sell/feed/v1"; } /** @@ -24,13 +23,13 @@ export default class Feed extends Restful { * @param scheduleId The schedule ID associated with the order task. */ public getOrderTasks({ - dateRange, - feedType, - limit, - lookBackDays, - offset, - scheduleId - }: SellFeedParams = {}) { + dateRange, + feedType, + limit, + lookBackDays, + offset, + scheduleId, + }: SellFeedParams = {}) { return this.get(`/order_task`, { params: { date_range: dateRange, @@ -38,8 +37,64 @@ export default class Feed extends Restful { limit, look_back_days: lookBackDays, offset, - schedule_id: scheduleId - } + schedule_id: scheduleId, + }, + }); + } + + /** + * This method creates an inventory task with filter criteria for the order report. + * + * @param data The CreateInvetoryTaskrequest + * @param marketPlaceId The market place ID of the inventory task. + */ + public createInventoryTask(data: any, marketPlaceId?: string) { + return this.post( + `/inventory_task`, + data, + { + headers: { + "X-EBAY-C-MARKETPLACE-ID": marketPlaceId ?? "EBAY_US", + }, + }, + undefined, + true + ); + } + /** + * This method gets an inventory. + * + * @param taskId The CreateInvetoryTaskrequest + */ + public getInventoryTask(taskId: string) { + taskId = encodeURIComponent(taskId); + return this.get(`/inventory_task/${taskId}`); + } + + /** + * This method searches for multiple tasks of a specific feed type, and includes date filters and pagination. + * + * @param data An object containing the following parameters: + */ + // feed_type=string& + // schedule_id=string& + // look_back_days=integer& + // date_range=string& + // limit=integer& + // offset=integer + + public getInventoryTasks(data: { + feed_type: string; + schedule_id: string; + look_back_days: number; + date_range: string; + limit: number; + offset: number; + }) { + return this.get(`/inventory_task`, { + params: { + ...data, + }, }); } @@ -48,8 +103,18 @@ export default class Feed extends Restful { * * @param data The CreateOrderTaskRequest */ - public createOrderTask(data: any) { - return this.post(`/order_task`, data); + public createOrderTask(data: any, marketplaceId?: string) { + return this.post( + `/order_task`, + data, + { + headers: { + "X-EBAY-C-MARKETPLACE-ID": marketplaceId ?? "EBAY_US", + }, + }, + undefined, + true + ); } /** @@ -57,7 +122,7 @@ export default class Feed extends Restful { * * @param taskId The ID of the task. This ID is generated when the task was created by the createOrderTask method. */ - public getOrderTask(taskId: string,) { + public getOrderTask(taskId: string) { taskId = encodeURIComponent(taskId); return this.get(`/order_task/${taskId}`); @@ -70,17 +135,13 @@ export default class Feed extends Restful { * @param limit The maximum number of schedules that can be returned on each page of the paginated response. * @param offset The number of schedules to skip in the result set before returning the first schedule in the paginated response. */ - public getSchedules({ - feedType, - limit, - offset, - }: SellFeedParams = {}) { + public getSchedules({ feedType, limit, offset }: SellFeedParams = {}) { return this.get(`/schedule`, { params: { feed_type: feedType, limit, - offset - } + offset, + }, }); } @@ -152,16 +213,16 @@ export default class Feed extends Restful { * @param offset The number of schedules to skip in the result set before returning the first schedule in the paginated response. */ public getScheduleTemplates({ - feedType, - limit, - offset, - }: SellFeedParams = {}) { + feedType, + limit, + offset, + }: SellFeedParams = {}) { return this.get(`/schedule_template`, { params: { feed_type: feedType, limit, - offset - } + offset, + }, }); } @@ -176,13 +237,13 @@ export default class Feed extends Restful { * @param scheduleId The schedule ID associated with the task. */ public getTasks({ - dateRange, - feedType, - limit, - lookBackDays, - offset, - scheduleId - }: SellFeedParams = {}) { + dateRange, + feedType, + limit, + lookBackDays, + offset, + scheduleId, + }: SellFeedParams = {}) { return this.get(`/task`, { params: { date_range: dateRange, @@ -190,8 +251,8 @@ export default class Feed extends Restful { limit, look_back_days: lookBackDays, offset, - schedule_id: scheduleId - } + schedule_id: scheduleId, + }, }); } @@ -221,7 +282,9 @@ export default class Feed extends Restful { */ public getResultFile(taskId: string) { taskId = encodeURIComponent(taskId); - return this.get(`/task/${taskId}/download_result_file`); + return this.get(`/task/${taskId}/download_result_file`, { + responseType: "arraybuffer", + }); } /** @@ -258,12 +321,12 @@ export default class Feed extends Restful { * @param scheduleId The schedule ID associated with the task. */ public getCustomerServiceMetricTasks({ - dateRange, - feedType, - limit, - lookBackDays, - offset, - }: SellFeedParams = {}) { + dateRange, + feedType, + limit, + lookBackDays, + offset, + }: SellFeedParams = {}) { return this.get(`/customer_service_metric_task`, { params: { date_range: dateRange, @@ -271,7 +334,7 @@ export default class Feed extends Restful { limit, look_back_days: lookBackDays, offset, - } + }, }); } @@ -284,8 +347,8 @@ export default class Feed extends Restful { public createCustomerServiceMetricTask(acceptLanguage: string, data: any) { return this.post(`/customer_service_metric_task`, data, { headers: { - 'accept-language': acceptLanguage - } + "accept-language": acceptLanguage, + }, }); } diff --git a/src/api/restful/sell/inventory/index.ts b/src/api/restful/sell/inventory/index.ts index c8c41c6..d02993f 100644 --- a/src/api/restful/sell/inventory/index.ts +++ b/src/api/restful/sell/inventory/index.ts @@ -1,4 +1,4 @@ -import Restful from '../../'; +import Restful from "../../"; import { BulkEbayOfferDetailsWithKeys, BulkInventoryItem, @@ -15,18 +15,17 @@ import { PublishByInventoryItemGroupRequest, SellInventoryItem, WithdrawByInventoryItemGroupRequest, -} from '../../../../types'; +} from "../../../../types"; /** * The Inventory API is used to create and manage inventory, and then to publish and manage this inventory on an eBay * marketplace. */ export default class Inventory extends Restful { - - static id = 'Inventory'; + static id = "Inventory"; get basePath(): string { - return '/sell/inventory/v1'; + return "/sell/inventory/v1"; } /** @@ -70,9 +69,9 @@ export default class Inventory extends Restful { * @param offset The value passed in this query parameter sets the page number to retrieve. */ public getInventoryLocations({ - limit, - offset, - }: { limit?: number; offset?: number } = {}) { + limit, + offset, + }: { limit?: number; offset?: number } = {}) { return this.get(`/location`, { params: { limit, @@ -162,9 +161,9 @@ export default class Inventory extends Restful { * @param offset The value passed in this query parameter sets the page number to retrieve. */ public getInventoryItems({ - limit, - offset, - }: { limit?: number; offset?: number } = {}) { + limit, + offset, + }: { limit?: number; offset?: number } = {}) { return this.get(`/inventory_item`, { params: { limit, @@ -246,12 +245,12 @@ export default class Inventory extends Restful { * @param offset The value passed in this query parameter sets the page number to retrieve. */ public getOffers({ - sku, - marketplaceId, - format, - limit, - offset, - }: { + sku, + marketplaceId, + format, + limit, + offset + }: { sku?: string; marketplaceId?: string; format?: string; diff --git a/src/auth/oAuth2.ts b/src/auth/oAuth2.ts index 4b630bc..5febb6c 100644 --- a/src/auth/oAuth2.ts +++ b/src/auth/oAuth2.ts @@ -1,20 +1,21 @@ -import debug from 'debug'; -import Base from '../api/base'; -import {Scope} from '../types'; +import debug from "debug"; +import Base from "../api/base"; +import { Scope } from "../types"; +import { getAccessToken, setAccessToken } from "./redisClient"; -const log = debug('ebay:oauth2'); +const log = debug("ebay:oauth2"); export type Token = { - access_token: string, - expires_in: number, // default 2 hours - token_type: string + access_token: string; + expires_in: number; // default 2 hours + token_type: string; }; export type ClientToken = Token; export type AuthToken = Token & { - refresh_token: string, - refresh_token_expires_in: number + refresh_token: string; + refresh_token_expires_in: number; }; /** @@ -26,32 +27,40 @@ export type AuthToken = Token & { export default class OAuth2 extends Base { // If all the calls in our application require just an Application access token we can use this endpoint public static readonly IDENTITY_ENDPOINT: Record = { - production: 'https://api.ebay.com/identity/v1/oauth2/token', - sandbox: 'https://api.sandbox.ebay.com/identity/v1/oauth2/token' + production: "https://api.ebay.com/identity/v1/oauth2/token", + sandbox: "https://api.sandbox.ebay.com/identity/v1/oauth2/token", }; public static readonly AUTHORIZE_ENDPOINT: Record = { - production: 'https://auth.ebay.com/oauth2/authorize', - sandbox: 'https://auth.sandbox.ebay.com/oauth2/authorize' + production: "https://auth.ebay.com/oauth2/authorize", + sandbox: "https://auth.sandbox.ebay.com/oauth2/authorize", }; - public static readonly defaultScopes: Scope = ['https://api.ebay.com/oauth/api_scope']; + public static readonly defaultScopes: Scope = [ + "https://api.ebay.com/oauth/api_scope", + ]; public static generateAuthUrl( sandbox: boolean, appId: string, ruName: string, scope: string[], - state = '' + state = "" ): string { return [ - sandbox ? OAuth2.AUTHORIZE_ENDPOINT.sandbox : OAuth2.AUTHORIZE_ENDPOINT.production, - '?client_id=', encodeURIComponent(appId), - '&redirect_uri=', encodeURIComponent(ruName), - '&response_type=code', - '&state=', encodeURIComponent(state), - '&scope=', encodeURIComponent(scope.join(' ')) - ].join(''); + sandbox + ? OAuth2.AUTHORIZE_ENDPOINT.sandbox + : OAuth2.AUTHORIZE_ENDPOINT.production, + "?client_id=", + encodeURIComponent(appId), + "&redirect_uri=", + encodeURIComponent(ruName), + "&response_type=code", + "&state=", + encodeURIComponent(state), + "&scope=", + encodeURIComponent(scope.join(" ")), + ].join(""); } private scope: Scope = this.config.scope || OAuth2.defaultScopes; @@ -59,7 +68,9 @@ export default class OAuth2 extends Base { private _authToken?: AuthToken; get identityEndpoint() { - return this.config.sandbox ? OAuth2.IDENTITY_ENDPOINT.sandbox : OAuth2.IDENTITY_ENDPOINT.production + return this.config.sandbox + ? OAuth2.IDENTITY_ENDPOINT.sandbox + : OAuth2.IDENTITY_ENDPOINT.production; } /** @@ -71,12 +82,12 @@ export default class OAuth2 extends Base { } public getUserAccessToken(): string | null { - return this._authToken?.access_token ?? null + return this._authToken?.access_token ?? null; } public async getApplicationAccessToken(): Promise { if (this._clientToken) { - log('Return existing application access token: ', this._clientToken); + log("Return existing application access token: ", this._clientToken); return this._clientToken.access_token; } @@ -105,25 +116,29 @@ export default class OAuth2 extends Base { */ public async mintApplicationAccessToken(): Promise { if (!this.config.appId) { - throw new Error('Missing App ID (Client Id)'); + throw new Error("Missing App ID (Client Id)"); } if (!this.config.certId) { - throw new Error('Missing Cert Id (Client Secret)'); + throw new Error("Missing Cert Id (Client Secret)"); } try { - return await this.req.postForm(this.identityEndpoint, { - scope: this.scope.join(' '), - grant_type: 'client_credentials' - }, { - auth: { - username: this.config.appId, - password: this.config.certId + return await this.req.postForm( + this.identityEndpoint, + { + scope: this.scope.join(" "), + grant_type: "client_credentials", + }, + { + auth: { + username: this.config.appId, + password: this.config.certId, + }, } - }); + ); } catch (error) { - log('Failed to mint application token', error); + log("Failed to mint application token", error); throw error; } } @@ -132,19 +147,22 @@ export default class OAuth2 extends Base { * Client credentials grant flow. */ public async obtainApplicationAccessToken(): Promise { - log('Obtain a new application access token with scope: ', this.scope.join(',')); + log( + "Obtain a new application access token with scope: ", + this.scope.join(",") + ); try { const token = await this.mintApplicationAccessToken(); - log('Obtained a new application access token:', token); + log("Obtained a new application access token:", token); this.setClientToken(token); - this.emit('refreshClientToken', token); + this.emit("refreshClientToken", token); return token; } catch (error) { - log('Failed to obtain application token', error); + log("Failed to obtain application token", error); throw error; } } @@ -156,14 +174,24 @@ export default class OAuth2 extends Base { * @param scope the scopes * @param state state parameter returned in the redirect URL */ - public generateAuthUrl(ruName?: string, scope: string[] = this.scope, state = ''): string { + public generateAuthUrl( + ruName?: string, + scope: string[] = this.scope, + state = "" + ): string { ruName = ruName || this.config.ruName; if (!ruName) { - throw new Error('RuName is required.'); + throw new Error("RuName is required."); } - return OAuth2.generateAuthUrl(this.config.sandbox, this.config.appId, ruName, scope, state); + return OAuth2.generateAuthUrl( + this.config.sandbox, + this.config.appId, + ruName, + scope, + state + ); } /** @@ -176,21 +204,25 @@ export default class OAuth2 extends Base { */ public async mintUserAccessToken(code: string, ruName = this.config.ruName) { try { - const token = await this.req.postForm(this.identityEndpoint, { - grant_type: 'authorization_code', - code, - redirect_uri: ruName - }, { - auth: { - username: this.config.appId, - password: this.config.certId + const token = await this.req.postForm( + this.identityEndpoint, + { + grant_type: "authorization_code", + code, + redirect_uri: ruName, + }, + { + auth: { + username: this.config.appId, + password: this.config.certId, + }, } - }); + ); - log('User Access Token', token); + log("User Access Token", token); return token; } catch (error) { - log('Failed to get the token', error); + log("Failed to get the token", error); throw error; } } @@ -207,40 +239,74 @@ export default class OAuth2 extends Base { return await this.mintUserAccessToken(code, ruName); } + public async initialize(refreshToken: string) { + this.setCredentials({ + expires_in: 7200, + refresh_token_expires_in: 47304000, + token_type: "User Access Token", + refresh_token: refreshToken, + access_token:"", + }) + await this.refreshToken(); + } /** * Authorization code grant flow. */ public async refreshUserAccessToken(): Promise { if (!this._authToken || !this._authToken.refresh_token) { - log('Tried to refresh user access token before it was set.'); - throw new Error('Failed to refresh the user access token. Token or refresh_token is not set.'); + log("Tried to refresh user access token before it was set."); + throw new Error( + "Failed to refresh the user access token. Token or refresh_token is not set." + ); } + let storedAccessToken = await getAccessToken( + this._authToken.refresh_token + ); + if ( + storedAccessToken && + storedAccessToken !== this._authToken.access_token + ) { + const credentials = { + ...this._authToken, + access_token: storedAccessToken, + }; + this.setCredentials(credentials); //set this as the new access token + return credentials; + } + try { - const token = await this.req.postForm(this.identityEndpoint, { - grant_type: 'refresh_token', - refresh_token: this._authToken.refresh_token, - scope: this.scope.join(' ') - }, { - auth: { - username: this.config.appId, - password: this.config.certId + const token = await this.req.postForm( + this.identityEndpoint, + { + grant_type: "refresh_token", + refresh_token: this._authToken.refresh_token, + scope: this.scope.join(" "), + }, + { + auth: { + username: this.config.appId, + password: this.config.certId, + }, } - }); + ); - log('Successfully refreshed token', token); + log("Successfully refreshed token", token); const refreshedToken = { ...this._authToken, - ...token + ...token, }; - this.setCredentials(refreshedToken); - this.emit('refreshAuthToken', refreshedToken); + await setAccessToken( + this._authToken.refresh_token, + refreshedToken.access_token + ); //store the refreshed token in redis + this.emit("refreshAuthToken", refreshedToken); return refreshedToken; } catch (error) { - log('Failed to refresh the token', error); + log("Failed to refresh the token", error); throw error; } } @@ -254,34 +320,34 @@ export default class OAuth2 extends Base { */ public async obtainToken(code: string): Promise { const token = await this.getToken(code); - log('Obtain user access token', token); - this.setCredentials(token) + log("Obtain user access token", token); + this.setCredentials(token); - return token + return token; } public getCredentials(): AuthToken | ClientToken | null { if (this._authToken) { return { - ...this._authToken + ...this._authToken, }; } else if (this._clientToken) { return { - ...this._clientToken - } + ...this._clientToken, + }; } return null; } public setCredentials(authToken: AuthToken | string) { - if (typeof authToken === 'string') { + if (typeof authToken === "string") { this._authToken = { - refresh_token: '', + refresh_token: "", expires_in: 7200, refresh_token_expires_in: 47304000, - token_type: 'User Access Token', - access_token: authToken + token_type: "User Access Token", + access_token: authToken, }; } else { this._authToken = authToken; @@ -298,6 +364,8 @@ export default class OAuth2 extends Base { return await this.obtainApplicationAccessToken(); } - throw new Error('Missing credentials. To refresh a token an application access token or user access token must be already set.'); + throw new Error( + "Missing credentials. To refresh a token an application access token or user access token must be already set." + ); } } diff --git a/src/auth/redisClient.ts b/src/auth/redisClient.ts new file mode 100644 index 0000000..d87525c --- /dev/null +++ b/src/auth/redisClient.ts @@ -0,0 +1,82 @@ +const redis = require("redis"); +const crypto = require("crypto"); +const bluebird = require("bluebird"); +const cryptojs = require("crypto-js"); + +const HOUR_IN_SECONDS = 3600; + +// Adding promises to redis +// https://stackoverflow.com/a/54844935 + +// https://cloud.google.com/community/tutorials/nodejs-redis-on-appengine +const client = (() => { + if (!process.env.EBAY_ACCESS_TOKEN_SECRET) { + throw new Error("EBAY_ACCESS_TOKEN_SECRET is not set"); + } + bluebird.promisifyAll(redis); + if (process.env.REDIS_PORT) { + const authParams: { + [index: string]: any; + } = {}; + if (process.env.REDIS_KEY) { + authParams.auth_pass = process.env.REDIS_KEY; + authParams.return_buffers = true; + } + return redis.createClient( + process.env.REDIS_PORT, + process.env.REDIS_HOST, + authParams + ); + } else { + return redis.createClient(); + } +})(); + +const hash = (text: any) => + crypto.createHash("sha256").update(text).digest("hex"); +// https://github.com/brix/crypto-js#plain-text-encryption +const encryptAccessToken = (accessToken: any) => + cryptojs.AES.encrypt( + accessToken, + process.env.EBAY_ACCESS_TOKEN_SECRET + ).toString(); + +const decryptAccessToken = (encryptedToken: any) => + cryptojs.AES.decrypt( + encryptedToken, + process.env.EBAY_ACCESS_TOKEN_SECRET + ).toString(cryptojs.enc.Utf8); + +export const setAccessToken = async (refreshToken: any, accessToken: any) => { + if (!client) { + return; + } + const hashedRefreshToken = hash(refreshToken); + const encryptedAccessToken = encryptAccessToken(accessToken); + // Auto remove it after an hour -> https://redis.io/commands/set + await client.setAsync( + hashedRefreshToken, + encryptedAccessToken, + "EX", + 2 * (HOUR_IN_SECONDS - 120) //set expiry to be after 1 hour 58 minutes + ); +}; + +export const getAccessToken = async (refreshToken: any) => { + if (!client) { + return; + } + const hashedRefreshToken = hash(refreshToken); + const encryptedToken = await client.getAsync(hashedRefreshToken); + if (encryptedToken) { + return decryptAccessToken(encryptedToken); + } +}; + +export const clearAccessToken = async (refreshToken: any) => { + if (!client) { + return; + } + const hashedRefreshToken = hash(refreshToken); + await client.del(hashedRefreshToken); +}; diff --git a/src/enums/restfulEnums.ts b/src/enums/restfulEnums.ts index 47f1ea1..8452fee 100644 --- a/src/enums/restfulEnums.ts +++ b/src/enums/restfulEnums.ts @@ -551,7 +551,7 @@ export enum Condition { } export enum Locale { - en_US = 'en_US', + en_US = 'en-US', en_CA = 'en_CA', fr_CA = 'fr_CA', en_GB = 'en_GB', diff --git a/src/request.ts b/src/request.ts index 0bd3194..6e16675 100644 --- a/src/request.ts +++ b/src/request.ts @@ -1,31 +1,46 @@ -import axios, {AxiosInstance, AxiosRequestConfig} from 'axios'; -import debug from 'debug'; -import qs from 'qs'; +import axios, { AxiosInstance, AxiosRequestConfig } from "axios"; +import debug from "debug"; +import qs from "qs"; -const log = debug('ebay:request'); +const log = debug("ebay:request"); export const defaultGlobalHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'X-Requested-With, Origin, Content-Type, X-Auth-Token', - 'Access-Control-Allow-Methods': 'GET, PUT, POST, DELETE' -} + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "X-Requested-With, Origin, Content-Type, X-Auth-Token", + "Access-Control-Allow-Methods": "GET, PUT, POST, DELETE", +}; export const multipartHeader = { - 'Content-Type': 'multipart/form-data' -} + "Content-Type": "multipart/form-data", +}; export interface IEBayApiRequest { readonly instance: T; - get(url: string, config?: C): Promise; + get( + url: string, + config?: C, + requestHeaders?: boolean + ): Promise; delete(url: string, config?: C): Promise; - post(url: string, data?: any, config?: C): Promise; + post( + url: string, + data?: any, + config?: C, + requestHeaders?: boolean + ): Promise; postForm(url: string, data?: any, config?: C): Promise; - put(url: string, data?: any, config?: C): Promise; + put( + url: string, + data?: any, + config?: C, + requestHeaders?: boolean + ): Promise; } export class AxiosRequest implements IEBayApiRequest { @@ -34,35 +49,72 @@ export class AxiosRequest implements IEBayApiRequest { constructor(config: AxiosRequestConfig = {}) { this.instance = axios.create({ headers: { - ...defaultGlobalHeaders + ...defaultGlobalHeaders, }, - ...config + ...config, }); } - public get(url: string, config?: AxiosRequestConfig): Promise { - log('get: ' + url, config); - return this.instance.get(url, config).then(({data}: any) => data); + public get( + url: string, + config?: AxiosRequestConfig, + requestHeaders?: boolean + ): Promise { + log("get: " + url, config); + return this.instance.get(url, config).then(({ data, headers }: any) => { + if (requestHeaders) { + return { data, headers }; + } + return data; + }); } - public post(url: string, payload?: any, config?: AxiosRequestConfig): Promise { - log('post: ' + url, {payload, config}); - return this.instance.post(url, payload, config).then(({data}: any) => data); + public post( + url: string, + payload?: any, + config?: AxiosRequestConfig, + requestHeaders?: boolean + ): Promise { + log("post: " + url, { payload, config }); + return this.instance + .post(url, payload, config) + .then(({ data, headers }: any) => { + if (requestHeaders) { + return { data, headers }; + } + return data; + }); } public delete(url: string, config?: AxiosRequestConfig): Promise { - log('delete: ' + url, config); - return this.instance.delete(url, config).then(({data}: any) => data); + log("delete: " + url, config); + return this.instance.delete(url, config).then(({ data }: any) => data); } - public put(url: string, payload?: any, config?: AxiosRequestConfig): Promise { - log('put: ' + url, {payload, config}); - return this.instance.put(url, payload, config).then(({data} : any) => data); + public put( + url: string, + payload?: any, + config?: AxiosRequestConfig, + requestHeaders?: boolean + ): Promise { + log("put: " + url, { payload, config }); + return this.instance + .put(url, payload, config) + .then(({ data, headers }: any) => { + if (requestHeaders) { + return { data, headers }; + } + return data; + }); } - public postForm(url: string, payload?: any, config?: AxiosRequestConfig): Promise { - log('postForm: ' + url); + public postForm( + url: string, + payload?: any, + config?: AxiosRequestConfig + ): Promise { + log("postForm: " + url); const body = qs.stringify(payload); - return this.instance.post(url, body, config).then(({data}: any) => data); + return this.instance.post(url, body, config).then(({ data }: any) => data); } }