diff --git a/package-lock.json b/package-lock.json index 58ac37c82..bdb78887c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -865,6 +865,12 @@ "normalize-path": "^2.1.1" } }, + "arg": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.1.tgz", + "integrity": "sha512-SlmP3fEA88MBv0PypnXZ8ZfJhwmDeIE3SP71j37AiXQBXYosPV0x6uISAaHYSlSVhmHOVkomen0tbGk6Anlebw==", + "dev": true + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -1827,6 +1833,12 @@ "integrity": "sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I=", "dev": true }, + "diff": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.1.tgz", + "integrity": "sha512-s2+XdvhPCOF01LRQBC8hf4vhbVmI2CGS5aZnxLJlT5FtdhPCDFq80q++zK2KlrVorVDdL5BOGZ/VfLrVtYNF+Q==", + "dev": true + }, "diff-sequences": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-24.9.0.tgz", @@ -3402,6 +3414,11 @@ "sshpk": "^1.7.0" } }, + "http-status-codes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-1.3.2.tgz", + "integrity": "sha512-nDUtj0ltIt08tGi2VWSpSzNNFye0v3YSe9lX3lIqLTuVvvRiYCvs4QQBSHo0eomFYw1wlUuofurUAlTm+vHnXg==" + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -5826,6 +5843,12 @@ "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==", "dev": true }, + "setup-polly-jest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/setup-polly-jest/-/setup-polly-jest-0.6.0.tgz", + "integrity": "sha512-NCbPkXX+/aTYBjrTw1tigLHWQCSJTPk/I7yuEcw6yvKm+SrlNJJjJBu3r6BGPoIq8ke9oDubP6TTRnD7Oq7XAQ==", + "dev": true + }, "shebang-command": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", @@ -6368,6 +6391,11 @@ "punycode": "^2.1.0" } }, + "ts-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ts-error/-/ts-error-1.0.3.tgz", + "integrity": "sha512-WZQJcLpzfwAEqHokn+gpFdv6aGogM5MEHKko0ykA3t3gULEbzQPPh3wPVNSpEZMPYu+7RtIkCq1DOADIN8OB3A==" + }, "ts-jest": { "version": "24.1.0", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-24.1.0.tgz", @@ -6403,6 +6431,19 @@ } } }, + "ts-node": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.4.1.tgz", + "integrity": "sha512-5LpRN+mTiCs7lI5EtbXmF/HfMeCjzt7DH9CZwtkr6SywStrNQC723wG+aOWFiLNn7zT3kD/RnFqi3ZUfr4l5Qw==", + "dev": true, + "requires": { + "arg": "^4.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "source-map-support": "^0.5.6", + "yn": "^3.0.0" + } + }, "tsconfigs": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/tsconfigs/-/tsconfigs-4.0.1.tgz", @@ -6797,6 +6838,12 @@ "camelcase": "^5.0.0", "decamelize": "^1.2.0" } + }, + "yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true } } } diff --git a/package.json b/package.json index cf5b6ca7d..395842bff 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "tsc", "lint": "eslint --ext .js,.ts src test", "test": "jest", - "test:passthrough": "cross-env POLLY_MODE=passthrough npm test -- -t 'polly:passthrough'" + "test:passthrough": "cross-env POLLY_MODE=passthrough npm test -- -t 'polly:passthrough'", + "dev:refresh-token": "ts-node test/refresh-token.ts" }, "keywords": [], "author": "", @@ -16,9 +17,11 @@ "client-oauth2": "4.2.5", "cross-fetch": "3.0.4", "fp-ts": "2.1.0", + "http-status-codes": "1.3.2", "io-ts": "2.0.1", "io-ts-types": "0.5.1", - "lodash": "4.17.15" + "lodash": "4.17.15", + "ts-error": "1.0.3" }, "devDependencies": { "@pollyjs/adapter-node-http": "2.6.3", @@ -40,7 +43,9 @@ "eslint-plugin-prettier": "3.1.1", "jest": "24.9.0", "prettier": "1.18.2", + "setup-polly-jest": "0.6.0", "ts-jest": "24.1.0", + "ts-node": "8.4.1", "tsconfigs": "4.0.1", "typescript": "3.6.4" } diff --git a/src/constants.ts b/src/constants.ts index 41a50a108..44da394bb 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,5 @@ import { name, version } from '../package.json' export const USER_AGENT = `${name}/${version}` + +export const JSON_CONTENT_TYPE = 'application/json' diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 000000000..69c0bdc47 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,79 @@ +/** + * Copyright (C) 2019, Scale Leap + */ + +import { ExtendableError } from 'ts-error' + +export interface ErrorObject { + code: string + details: string + requestId: string +} + +export class NullError extends ExtendableError { + public constructor(resource: string) { + super(`Response result is null for "${resource}".`) + } +} + +export class InvalidProgramStateError extends ExtendableError { + public constructor(additionalDetails?: string) { + super( + [ + 'This program state should never happen.', + 'If you encountered this error, please report it asap.', + additionalDetails, + ].join(' '), + ) + } +} + +export class SnapshotDownloadError extends ExtendableError { + public constructor(snapshotId: string, snapshotStatus: string) { + super( + [ + 'Snapshot must have status equal to SUCCESS before downloading.', + `Got snapshot ${snapshotId} with status ${snapshotStatus} instead.`, + ].join(' '), + ) + } +} + +export class GenericError extends ExtendableError { + public code: string + + public requestId: string + + public constructor(err: ErrorObject) { + super(err.details) + this.code = err.code + this.requestId = err.requestId + } +} + +export class UnauthorizedError extends GenericError {} + +export class BadRequestError extends GenericError {} + +export class UnprocessableEntityError extends GenericError {} + +export class ResourceNotFoundError extends GenericError {} + +export class NotAcceptableError extends GenericError {} + +export function apiErrorFactory(err: ErrorObject): GenericError { + switch (err.code) { + case 'UNAUTHORIZED': + return new UnauthorizedError(err) + case 'NOT_FOUND': + return new ResourceNotFoundError(err) + case '400': + return new BadRequestError(err) + case '406': + return new NotAcceptableError(err) + case '422': + return new UnprocessableEntityError(err) + default: + return new GenericError(err) + } +} diff --git a/src/gunzip.ts b/src/gunzip.ts new file mode 100644 index 000000000..b026d0678 --- /dev/null +++ b/src/gunzip.ts @@ -0,0 +1,12 @@ +import { gunzip } from 'zlib' + +export default (buffer: Buffer): Promise => { + return new Promise((resolve, reject) => { + return gunzip(buffer, (err, uncompressed) => { + if (err) { + return reject(err) + } + return resolve(uncompressed) + }) + }) +} diff --git a/src/http-client.ts b/src/http-client.ts new file mode 100644 index 000000000..66abcdb95 --- /dev/null +++ b/src/http-client.ts @@ -0,0 +1,202 @@ +import fetch, { Headers } from 'cross-fetch' +import HttpStatus from 'http-status-codes' + +import { USER_AGENT, JSON_CONTENT_TYPE } from './constants' +import { apiErrorFactory, NullError, InvalidProgramStateError } from './errors' +import gunzip from './gunzip' + +export interface HttpClientAuth { + authorizationToken: string + clientId: string + scope: number +} + +enum HttpMethod { + GET, + POST, + PUT, + DELETE, +} + +type RequestBody = object | object[] + +interface HttpClientRequestParams { + method: HttpMethod + uri: string + body?: RequestInit['body'] + headers?: Headers +} + +export class HttpClient { + private get headers(): Headers { + const headers = new Headers({ + 'Content-Type': JSON_CONTENT_TYPE, + Accept: JSON_CONTENT_TYPE, + Authorization: `Bearer ${this.auth.authorizationToken}`, + 'Amazon-Advertising-API-ClientId': this.auth.clientId, + 'User-Agent': USER_AGENT, + }) + + if (this.auth.scope) { + headers.append('Amazon-Advertising-API-Scope', this.auth.scope.toString()) + } + + // https://advertising.amazon.com/API/docs/v2/reference/bidding/bid_controls + if (this.sandbox) { + headers.append('BIDDING_CONTROLS_ON', 'true') + + // prevent gzip in sandbox/dev for nock to catch uncompressed response + headers.append('Accept-Encoding', JSON_CONTENT_TYPE) + } + + return headers + } + + public readonly httpStatus = HttpStatus + + public constructor( + private readonly uri: string, + private readonly auth: HttpClientAuth, + private readonly sandbox = false, + ) {} + + private fetch(uri: string, req?: RequestInit): Promise { + return fetch(uri, req) + } + + private async request(params: HttpClientRequestParams): Promise { + const req: RequestInit = { + redirect: 'manual', + method: HttpMethod[params.method], + headers: params.headers, + body: params.body, + } + + return this.fetch(params.uri, req) + } + + private async handleApiResponse(res: Response): Promise { + const { status } = res + const text = await res.text() + + if (status === this.httpStatus.OK && !text) { + throw new NullError(res.url) + } + + if (status >= this.httpStatus.BAD_REQUEST) { + // We have a response body, so it *might* be a documented response + if (text) { + const json = JSON.parse(text) + + // Documented API Error + // https://advertising.amazon.com/API/docs/v2/guides/developer_notes#Error-response + if (json && json.code) { + throw apiErrorFactory(json) + } + + throw new InvalidProgramStateError(JSON.stringify(res)) + } else { + // We don't have a body, so it's an unpredictable error, but let's try to structure it + // anyways for completeness sake + throw apiErrorFactory({ + code: status.toString(), + details: res.statusText, + requestId: res.headers.get('x-amz-request-id') || res.headers.get('x-amz-rid') || '', + }) + } + } + + if (status < this.httpStatus.MULTIPLE_CHOICES && text) { + return JSON.parse(text) + } + + if (status >= this.httpStatus.MULTIPLE_CHOICES && status < this.httpStatus.BAD_REQUEST) { + return JSON.parse('null') + } + + throw new InvalidProgramStateError(res.statusText) + } + + private apiUri(resource: string): string { + return `${this.uri}/${resource}` + } + + private async apiRequest(params: HttpClientRequestParams): Promise { + return this.handleApiResponse( + await this.request({ + ...params, + uri: this.apiUri(params.uri), + headers: this.headers, + }), + ) + } + + public async get(resource: string): Promise { + return this.apiRequest({ + method: HttpMethod.GET, + uri: resource, + }) + } + + public async put(resource: string, body: RequestBody): Promise { + return this.apiRequest({ + method: HttpMethod.PUT, + uri: resource, + body: JSON.stringify(body), + }) + } + + public async post(resource: string, body: RequestBody): Promise { + return this.apiRequest({ + method: HttpMethod.POST, + uri: resource, + body: JSON.stringify(body), + }) + } + + public async delete(resource: string): Promise { + return this.apiRequest({ + method: HttpMethod.DELETE, + uri: resource, + }) + } + + public async download(resource: string): Promise { + const res = await this.request({ + method: HttpMethod.GET, + uri: this.apiUri(resource), + headers: this.headers, + }) + + // checks for common errors, we don't care about the result, as we expect it to throw + // if any failures are detected + await this.handleApiResponse(res.clone()) + + const location: string | null = res.headers.get('Location') + if (res.status !== this.httpStatus.TEMPORARY_REDIRECT || !location) { + throw new InvalidProgramStateError(['Expected a signed URL.', res.statusText].join(' ')) + } + + const download = await this.fetch(location) + + if (download.status !== this.httpStatus.OK) { + throw new InvalidProgramStateError(`Expected OK HTTP status, but got: ${res.statusText}`) + } + + const buffer = await download.arrayBuffer().then(res => Buffer.from(res)) + const contentType = download.headers.get('Content-Type') + + const bufferToJson = (buf: Buffer): T => { + return JSON.parse(buf.toString()) + } + + switch (contentType) { + case JSON_CONTENT_TYPE: + return bufferToJson(buffer) + case 'application/octet-stream': + return gunzip(buffer).then(bufferToJson) + default: + throw new InvalidProgramStateError(`Unknown Content-Type: ${contentType}`) + } + } +} diff --git a/test/__recordings__/HttpClient_3032634686/get_1410115415/should-return-a-result_2976281088/recording.har b/test/__recordings__/HttpClient_3032634686/get_1410115415/should-return-a-result_2976281088/recording.har new file mode 100644 index 000000000..84f7bfa0c --- /dev/null +++ b/test/__recordings__/HttpClient_3032634686/get_1410115415/should-return-a-result_2976281088/recording.har @@ -0,0 +1,132 @@ +{ + "log": { + "_recordingName": "HttpClient/get/should return a result", + "creator": { + "comment": "persister:fs", + "name": "Polly.JS", + "version": "2.6.3" + }, + "entries": [ + { + "_id": "b3b1b482066f3fa509c2b49732a9c1e4", + "_order": 0, + "cache": {}, + "request": { + "bodySize": 0, + "cookies": [], + "headers": [ + { + "_fromType": "array", + "name": "content-type", + "value": "application/json" + }, + { + "_fromType": "array", + "name": "accept", + "value": "application/json" + }, + { + "_fromType": "array", + "name": "user-agent", + "value": "amazon-advertising-api-nodejs-sdk-2/1.0.0" + }, + { + "_fromType": "array", + "name": "amazon-advertising-api-scope", + "value": "2984328618318898" + }, + { + "_fromType": "array", + "name": "bidding_controls_on", + "value": "true" + }, + { + "_fromType": "array", + "name": "accept-encoding", + "value": "application/json" + }, + { + "_fromType": "array", + "name": "connection", + "value": "close" + }, + { + "name": "host", + "value": "advertising-api-test.amazon.com" + } + ], + "headersSize": 349, + "httpVersion": "HTTP/1.1", + "method": "GET", + "queryString": [], + "url": "https://advertising-api-test.amazon.com/v2/profiles" + }, + "response": { + "bodySize": 864, + "content": { + "mimeType": "application/json", + "size": 864, + "text": "[{\"profileId\":2984328618318898,\"countryCode\":\"US\",\"currencyCode\":\"USD\",\"dailyBudget\":340.0,\"timezone\":\"America/Los_Angeles\",\"accountInfo\":{\"marketplaceStringId\":\"ATVPDKIKX0DER\",\"id\":\"AUZWHWR0590BC\",\"type\":\"seller\"}},{\"profileId\":1350266456614264,\"countryCode\":\"CA\",\"currencyCode\":\"CAD\",\"dailyBudget\":0.0,\"timezone\":\"America/Los_Angeles\",\"accountInfo\":{\"marketplaceStringId\":\"A2EUQ1WTGCTBG2\",\"id\":\"AUZWHWR0590BC\",\"type\":\"seller\"}},{\"profileId\":4383529933717909,\"countryCode\":\"UK\",\"currencyCode\":\"GBP\",\"dailyBudget\":0.0,\"timezone\":\"Europe/London\",\"accountInfo\":{\"marketplaceStringId\":\"A1F83G8C2ARO7P\",\"id\":\"AUZWHWR0590BC\",\"type\":\"seller\"}},{\"profileId\":2973802954634317,\"countryCode\":\"US\",\"currencyCode\":\"USD\",\"timezone\":\"America/Los_Angeles\",\"accountInfo\":{\"marketplaceStringId\":\"ATVPDKIKX0DER\",\"id\":\"ENTITY_692466442578862883903374\",\"type\":\"vendor\",\"name\":\"yay\"}}]" + }, + "cookies": [], + "headers": [ + { + "name": "server", + "value": "Server" + }, + { + "name": "date", + "value": "Thu, 17 Oct 2019 20:21:40 GMT" + }, + { + "name": "content-type", + "value": "application/json" + }, + { + "name": "transfer-encoding", + "value": "chunked" + }, + { + "name": "connection", + "value": "close" + }, + { + "name": "access-control-allow-headers", + "value": "Authorization, Amazon-Advertising-API-ClientId, Amazon-Advertising-API-Scope" + }, + { + "name": "x-amz-request-id", + "value": "DGTFVV64PJVT9Z62AM5T" + }, + { + "name": "vary", + "value": "Accept-Encoding,X-Amzn-CDN-Cache,X-Amzn-AX-Treatment,User-Agent" + }, + { + "name": "x-amz-rid", + "value": "DGTFVV64PJVT9Z62AM5T" + } + ], + "headersSize": 386, + "httpVersion": "HTTP/1.1", + "redirectURL": "", + "status": 200, + "statusText": "OK" + }, + "startedDateTime": "2019-10-17T20:21:30.287Z", + "time": 380, + "timings": { + "blocked": -1, + "connect": -1, + "dns": -1, + "receive": 0, + "send": 0, + "ssl": -1, + "wait": 380 + } + } + ], + "pages": [], + "version": "1.2" + } +} diff --git a/test/http-client-factory.ts b/test/http-client-factory.ts new file mode 100644 index 000000000..b2d66a1c7 --- /dev/null +++ b/test/http-client-factory.ts @@ -0,0 +1,15 @@ +import { HttpClient, HttpClientAuth } from '../src/http-client' +import { config } from './config' + +const SANDBOX = true +const SANDBOX_URI = 'https://advertising-api-test.amazon.com' + +export const auth: HttpClientAuth = { + authorizationToken: config.TEST_ACCESS_TOKEN || '', + clientId: config.TEST_CLIENT_ID || '', + scope: config.TEST_SCOPE || -1, +} + +export function httpClientFactory(): HttpClient { + return new HttpClient(SANDBOX_URI, auth, SANDBOX) +} diff --git a/test/http-client.test.ts b/test/http-client.test.ts new file mode 100644 index 000000000..6dbe3eba4 --- /dev/null +++ b/test/http-client.test.ts @@ -0,0 +1,59 @@ +import setupPolly from './polly' +import { HttpClient } from '../src/http-client' +import { + UnauthorizedError, + NullError, + ResourceNotFoundError, + InvalidProgramStateError, +} from '../src/errors' + +import { httpClientFactory } from './http-client-factory' + +describe('HttpClient', () => { + setupPolly() + + let client: HttpClient + beforeEach(() => { + client = httpClientFactory() + }) + + describe('get', () => { + it('should return a result', async () => { + const res = await client.get('v2/profiles') + expect(Array.isArray(res)).toBeTruthy() + }) + + it.skip('should throw a known error object when encountering an error', () => { + return expect(client.get('profiles')).rejects.toThrow(UnauthorizedError) + }) + + it.skip('should throw NullError when response body is null', () => { + return expect(client.get('profiles')).rejects.toThrow(NullError) + }) + + it.skip('should throw a ResourceNotFoundError when resource is not found', () => { + return expect(client.get('foobar')).rejects.toThrow(ResourceNotFoundError) + }) + }) + + describe.skip('download', () => { + it('should throw if location header not set', async () => { + const promise = client.download('profiles') + return expect(promise).rejects.toThrowError(InvalidProgramStateError) + }) + }) + + describe.skip('BIDDING_CONTROLS_ON header', () => { + // it('should set the header in sandbox environment', async done => { + // expect.assertions(1) + // const scope = nock('https://advertising-api-test.amazon.com') + // .get('/v2/profiles') + // .reply(200, {}) + // scope.once('request', req => { + // expect(req.headers.bidding_controls_on).toEqual(['true']) + // done() + // }) + // await client.get('profiles') + // }) + }) +}) diff --git a/test/polly.ts b/test/polly.ts new file mode 100644 index 000000000..d4424c3e6 --- /dev/null +++ b/test/polly.ts @@ -0,0 +1,37 @@ +import path from 'path' +import { setupPolly } from 'setup-polly-jest' +import { Polly, PollyConfig } from '@pollyjs/core' +import NodeHttpAdapter from '@pollyjs/adapter-node-http' +import FSPersister from '@pollyjs/persister-fs' +import { config } from './config' + +Polly.register(FSPersister) +Polly.register(NodeHttpAdapter) + +export default function(pollyConfig: PollyConfig = {}) { + const context = setupPolly({ + adapters: ['node-http'], + mode: config.POLLY_MODE, + persister: 'fs', + persisterOptions: { + fs: { + recordingsDir: path.resolve(__dirname, '__recordings__'), + }, + }, + recordIfMissing: true, + recordFailedRequests: true, + + // overwrite default config + ...pollyConfig, + }) + + beforeEach(() => { + // removes secrets from stored recordings + context.polly.server.any().on('beforeResponse', req => { + req.removeHeader('authorization') + req.removeHeader('amazon-advertising-api-clientid') + }) + }) + + return context +} diff --git a/test/refresh-token.ts b/test/refresh-token.ts new file mode 100644 index 000000000..7eb898e44 --- /dev/null +++ b/test/refresh-token.ts @@ -0,0 +1,38 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs' +import { join } from 'path' +import { OAuthClient } from '../src/o-auth-client' +import { config } from './config' + +const DOTENV_PATH = join(__dirname, '../.env') + +if (!existsSync(DOTENV_PATH)) { + throw new Error('The `.env` file does not exist.') +} + +const client = new OAuthClient({ + clientId: config.TEST_CLIENT_ID, + clientSecret: config.TEST_CLIENT_SECRET, +}) + +if (!config.TEST_ACCESS_TOKEN) { + throw new Error('Missing `TEST_ACCESS_TOKEN` environment variable.') +} + +if (!config.TEST_REFRESH_TOKEN) { + throw new Error('Missing `TEST_REFRESH_TOKEN` environment variable.') +} + +const token = client.createToken(config.TEST_ACCESS_TOKEN, config.TEST_REFRESH_TOKEN) + +const dotenv = readFileSync(DOTENV_PATH, { encoding: 'utf8' }) + +token + .refresh() + .then(tokens => { + const res = dotenv.replace( + /TEST_ACCESS_TOKEN=(.+?)\n/, + `TEST_ACCESS_TOKEN=${tokens.accessToken}\n`, + ) + writeFileSync(DOTENV_PATH, res, { encoding: 'utf8' }) + }) + .catch(err => console.error(err)) diff --git a/test/types/setup-polly-jest/index.d.ts b/test/types/setup-polly-jest/index.d.ts new file mode 100644 index 000000000..8b4f526e8 --- /dev/null +++ b/test/types/setup-polly-jest/index.d.ts @@ -0,0 +1,6 @@ +// custom declarations for setup-polly-jest + +declare module 'setup-polly-jest' { + import { Polly, PollyConfig } from '@pollyjs/core' + export function setupPolly(config: PollyConfig): { polly: Polly } +}