From 97a3597ed86df2b4d324ad093b4af4012866c798 Mon Sep 17 00:00:00 2001 From: Gabor Soos Date: Fri, 26 Aug 2022 14:45:17 +0200 Subject: [PATCH] feat(package): move from javascript to typescript EME-5329 BREAKING CHANGE: Typescript implementation Co-authored-by: Gabor Nemeth --- .eslintrc | 1 + .gitignore | 1 + .npmignore | 3 +- lib/request.js | 120 ------------------ lib/requestError.js | 21 ---- package.json | 20 ++- src/escher-auth.d.ts | 34 +++++ {lib => src}/request.spec.js | 62 +++++----- src/request.ts | 123 +++++++++++++++++++ {lib => src}/requestError.spec.js | 4 +- src/requestError.ts | 23 ++++ {lib => src}/requestOption.spec.js | 4 +- lib/requestOption.js => src/requestOption.ts | 71 ++++++----- {lib => src}/testSetup.spec.js | 2 - {lib => src}/wrapper.spec.js | 74 ++++++----- lib/wrapper.js => src/wrapper.ts | 84 ++++++++----- tsconfig.json | 108 ++++++++++++++++ 17 files changed, 467 insertions(+), 288 deletions(-) delete mode 100644 lib/request.js delete mode 100644 lib/requestError.js create mode 100644 src/escher-auth.d.ts rename {lib => src}/request.spec.js (75%) create mode 100644 src/request.ts rename {lib => src}/requestError.spec.js (95%) create mode 100644 src/requestError.ts rename {lib => src}/requestOption.spec.js (98%) rename lib/requestOption.js => src/requestOption.ts (57%) rename {lib => src}/testSetup.spec.js (96%) rename {lib => src}/wrapper.spec.js (81%) rename lib/wrapper.js => src/wrapper.ts (60%) create mode 100644 tsconfig.json diff --git a/.eslintrc b/.eslintrc index 6ad4041..6723239 100644 --- a/.eslintrc +++ b/.eslintrc @@ -4,6 +4,7 @@ "mocha": true, "es6": true }, + "parser": "babel-eslint", "globals": { "expect": true }, diff --git a/.gitignore b/.gitignore index 677d5f9..d3cb10a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ logs *.log .nyc_output +dist # Runtime data pids diff --git a/.npmignore b/.npmignore index fb7bb02..5ad9b41 100644 --- a/.npmignore +++ b/.npmignore @@ -3,6 +3,7 @@ node_modules *.spec.js mocha.opts .nyc_output +src .idea @@ -28,4 +29,4 @@ coverage build/Release # Users Environment Variables -.lock-wscript \ No newline at end of file +.lock-wscript diff --git a/lib/request.js b/lib/request.js deleted file mode 100644 index 2808678..0000000 --- a/lib/request.js +++ /dev/null @@ -1,120 +0,0 @@ -'use strict'; - -const Escher = require('escher-auth'); -const http = require('http'); -const https = require('https'); -const Options = require('./requestOption'); -const Wrapper = require('./wrapper'); -const SuiteRequestError = require('./requestError'); -const logger = require('@emartech/json-logger')('suiterequest'); - -class SuiteRequest { - - static create(accessKeyId, apiSecret, requestOptions) { - return new SuiteRequest(accessKeyId, apiSecret, requestOptions); - } - - constructor(accessKeyId, apiSecret, requestOptions) { - const escherConfig = Object.assign({}, SuiteRequest.EscherConstants, { - accessKeyId: accessKeyId, - apiSecret: apiSecret, - credentialScope: requestOptions.credentialScope || SuiteRequest.EscherConstants.credentialScope - }); - - this._escher = new Escher(escherConfig); - this._options = requestOptions; - - if (requestOptions.keepAlive) { - this.httpAgent = new http.Agent({ keepAlive: true }); - this.httpsAgent = new https.Agent({ keepAlive: true }); - } - } - - get(path, data) { - return this._request('GET', path, data); - } - - patch(path, data) { - return this._request('PATCH', path, data); - } - - post(path, data) { - return this._request('POST', path, data); - } - - put(path, data) { - return this._request('PUT', path, data); - } - - delete(path) { - return this._request('DELETE', path); - } - - _request(method, path, data) { - const options = this._getOptionsFor(method, path); - const payload = data ? this._getPayload(data) : ''; - const signedOptions = this._signRequest(options, payload); - - logger.info('send', this._getLogParameters(options)); - return this._getRequestFor(signedOptions, payload).send(); - } - - setOptions(requestOptions) { - this._options = requestOptions; - } - - getOptions() { - return this._options; - } - - _getRequestFor(requestOptions, payload) { - const protocol = (this._options.secure) ? 'https:' : 'http:'; - return new Wrapper(requestOptions, protocol, payload); - } - - _getOptionsFor(method, path) { - const defaultOptions = this._options.toHash(); - const realPath = defaultOptions.prefix + path; - - return Object.assign({}, defaultOptions, { - method: method, - url: realPath, - path: realPath, - httpAgent: this.httpAgent, - httpsAgent: this.httpsAgent - }); - } - - _signRequest(options, payload) { - const headerNames = options.headers.map(function(header) { - return header[0]; - }); - - return this._escher.signRequest(options, payload, headerNames); - } - - _getLogParameters(options) { - const { method, host, url } = options; - return { method, host, url }; - } - - _getPayload(data) { - if (this._options.getHeader('content-type').indexOf('application/json') === -1) { - return data; - } - - return JSON.stringify(data); - } -} - -SuiteRequest.EscherConstants = { - algoPrefix: 'EMS', - vendorKey: 'EMS', - credentialScope: 'eu/suite/ems_request', - authHeaderName: 'X-Ems-Auth', - dateHeaderName: 'X-Ems-Date' -}; - -module.exports = SuiteRequest; -module.exports.Options = Options; -module.exports.Error = SuiteRequestError; diff --git a/lib/requestError.js b/lib/requestError.js deleted file mode 100644 index 9338b4f..0000000 --- a/lib/requestError.js +++ /dev/null @@ -1,21 +0,0 @@ -'use strict'; - -class SuiteRequestError extends Error { - constructor(message, code, response, originalCode) { - super(message); - - this.code = code; - this.originalCode = originalCode; - this.name = 'SuiteRequestError'; - - if (response) { - this.data = response.data || response; - } else { - this.data = { - replyText: message - }; - } - } -} - -module.exports = SuiteRequestError; diff --git a/package.json b/package.json index 4952c95..fc72816 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,16 @@ { "name": "escher-suiteapi-js", - "description": "Escher Suite Api js", + "description": "Escher Request", "scripts": { - "test": "NODE_ENV=test mocha --recursive ./lib", - "code-style": "eslint $(find . -name \"*.js\" -not -path \"./node_modules/*\")", + "test": "mocha --require ts-node/register ./src/ --recursive", + "lint": "eslint $(find ./src -name \"*.js\" -not -path \"./node_modules/*\")", + "build": "rm -rf dist && tsc --project ./tsconfig.json", "semantic-release": "CI=true semantic-release" }, - "main": "lib/request.js", + "publishConfig": { + "access": "public" + }, + "main": "dist/request.js", "repository": { "type": "git", "url": "git+https://github.com/emartech/escher-suiteapi-js.git" @@ -24,11 +28,13 @@ "node": ">=14.0.0" }, "dependencies": { - "@emartech/json-logger": "4.0.1", + "@emartech/json-logger": "5.0.2", "axios": "0.27.2", "escher-auth": "3.2.4" }, "devDependencies": { + "@types/node": "18.7.8", + "babel-eslint": "10.1.0", "chai": "4.3.6", "chai-subset": "1.6.0", "eslint": "7.21.0", @@ -38,6 +44,8 @@ "mocha": "10.0.0", "semantic-release": "17.4.7", "sinon": "14.0.0", - "sinon-chai": "3.7.0" + "sinon-chai": "3.7.0", + "ts-node": "10.9.1", + "typescript": "4.7.4" } } diff --git a/src/escher-auth.d.ts b/src/escher-auth.d.ts new file mode 100644 index 0000000..609e3fa --- /dev/null +++ b/src/escher-auth.d.ts @@ -0,0 +1,34 @@ +declare module 'escher-auth' { + export type KeyDB = (key: string) => string; + + export type EscherRequest = { + method: string; + host: string; + port: number; + url: string; + body?: string | Buffer; + headers?: string[][]; // [["Date","Fri, 09 Sep 2011 23:36:00 GMT"],["Host","host.foo.com"] + }; + + export type Config = { + algoPrefix?: string; + vendorKey?: string; + hashAlgo?: 'SHA256' | 'SHA512'; + credentialScope?: string; + accessKeyId?: string; + apiSecret?: string; + authHeaderName?: string; + dateHeaderName?: string; + clockSkew?: number; + } & Record; + + export default class Escher { + constructor(configToMerge?: Config); + static create(configToMerge?: Config): Escher; + public preSignUrl(url: string, expirationInSec: number): string; + public signRequest(requestOptions: ExtendedRequestOption, body?: string | Buffer, headersToSign?: string[]): EscherRequest; + public authenticate(request: EscherRequest, keyDb: KeyDB, mandatorySignedHeaders?: string[]): string; + public validateRequest(request: EscherRequest, body?: string | Buffer): void; + public validateMandatorySignedHeaders(mandatorySignedHeaders?: string[]): void; + } +} diff --git a/lib/request.spec.js b/src/request.spec.js similarity index 75% rename from lib/request.spec.js rename to src/request.spec.js index 9b98dbd..791c078 100644 --- a/lib/request.spec.js +++ b/src/request.spec.js @@ -1,5 +1,3 @@ -'use strict'; - const SuiteRequest = require('./request'); const axios = require('axios'); const Escher = require('escher-auth'); @@ -33,87 +31,87 @@ describe('SuiteRequest', function() { suiteRequest = SuiteRequest.create('key-id', 'secret', requestOptions); }); - it('should sign headers of GET request', function*() { - yield suiteRequest.get('/path'); + it('should sign headers of GET request', async () => { + await suiteRequest.get('/path'); const requestArgument = requestStub.args[0][0]; expect(requestArgument.headers['x-ems-auth']).to.have.string('SignedHeaders=content-type;host;x-ems-date,'); }); - it('should sign headers of PATCH request', function*() { - yield suiteRequest.patch('/path', { name: 'Almanach' }); + it('should sign headers of PATCH request', async () => { + await suiteRequest.patch('/path', { name: 'Almanach' }); const requestArgument = requestStub.args[0][0]; expect(requestArgument.headers['x-ems-auth']).to.have.string('SignedHeaders=content-type;host;x-ems-date,'); }); - it('should sign headers of POST request', function*() { - yield suiteRequest.post('/path', { name: 'Almanach' }); + it('should sign headers of POST request', async () => { + await suiteRequest.post('/path', { name: 'Almanach' }); const requestArgument = requestStub.args[0][0]; expect(requestArgument.headers['x-ems-auth']).to.have.string('SignedHeaders=content-type;host;x-ems-date,'); }); - it('should sign headers of DELETE request', function*() { - yield suiteRequest.delete('/path'); + it('should sign headers of DELETE request', async () => { + await suiteRequest.delete('/path'); const requestArgument = requestStub.args[0][0]; expect(requestArgument.headers['x-ems-auth']).to.have.string('SignedHeaders=content-type;host;x-ems-date,'); }); - it('should sign headers with non string values', function*() { + it('should sign headers with non string values', async () => { requestOptions.setHeader(['x-customer-id', 15]); - yield suiteRequest.post('/path', { name: 'Almanach' }); + await suiteRequest.post('/path', { name: 'Almanach' }); const requestArgument = requestStub.args[0][0]; expect(requestArgument.headers['x-ems-auth']).to.have.string('content-type;host;x-customer-id;x-ems-date,'); }); - it('should encode payload when content type is json', function*() { - yield suiteRequest.post('/path', { name: 'Almanach' }); + it('should encode payload when content type is json', async () => { + await suiteRequest.post('/path', { name: 'Almanach' }); const requestArgument = requestStub.args[0][0]; expect(requestArgument.data).to.eql('{"name":"Almanach"}'); }); - it('should encode payload when content type is json and method is GET', function*() { - yield suiteRequest.get('/path', { name: 'Almanach' }); + it('should encode payload when content type is json and method is GET', async () => { + await suiteRequest.get('/path', { name: 'Almanach' }); const requestArgument = requestStub.args[0][0]; expect(requestArgument.data).to.eql('{"name":"Almanach"}'); }); - it('should encode payload when content type is utf8 json', function*() { + it('should encode payload when content type is utf8 json', async () => { requestOptions.setHeader(['content-type', 'application/json;charset=utf-8']); - yield suiteRequest.post('/path', { name: 'Almanach' }); + await suiteRequest.post('/path', { name: 'Almanach' }); const requestArgument = requestStub.args[0][0]; expect(requestArgument.data).to.eql('{"name":"Almanach"}'); }); - it('should skip encoding of payload when content type is not json', function*() { + it('should skip encoding of payload when content type is not json', async () => { requestOptions.setHeader(['content-type', 'text/csv']); - yield suiteRequest.post('/path', 'header1;header2'); + await suiteRequest.post('/path', 'header1;header2'); const requestArgument = requestStub.args[0][0]; expect(requestArgument.data).to.eql('header1;header2'); }); - it('signs extra headers too', function*() { + it('signs extra headers too', async () => { requestOptions.setHeader(['extra-header', 'header-value']); - yield suiteRequest.get('/path'); + await suiteRequest.get('/path'); const requestArgument = requestStub.args[0][0]; expect(requestArgument.headers['x-ems-auth']) .to.have.string('SignedHeaders=content-type;extra-header;host;x-ems-date,'); }); - it('should pass down parameters to request call from request options', function*() { - yield suiteRequest.post('/path', { name: 'Almanach' }); + it('should pass down parameters to request call from request options', async () => { + await suiteRequest.post('/path', { name: 'Almanach' }); const requestArgument = requestStub.args[0][0]; @@ -126,33 +124,33 @@ describe('SuiteRequest', function() { }); }); - it('should sign the payload of PATCH request', function*() { + it('should sign the payload of PATCH request', async function() { const payload = { name: 'Test' }; this.sandbox.spy(Escher.prototype, 'signRequest'); - yield suiteRequest.patch('/path', payload); + await suiteRequest.patch('/path', payload); expect(Escher.prototype.signRequest.callCount).to.eql(1); const firstCall = Escher.prototype.signRequest.getCall(0); expect(firstCall.args[1]).to.eql(JSON.stringify(payload)); }); - it('should sign the payload of POST request', function*() { + it('should sign the payload of POST request', async function() { const payload = { name: 'Test' }; this.sandbox.spy(Escher.prototype, 'signRequest'); - yield suiteRequest.post('/path', payload); + await suiteRequest.post('/path', payload); expect(Escher.prototype.signRequest.callCount).to.eql(1); const firstCall = Escher.prototype.signRequest.getCall(0); expect(firstCall.args[1]).to.eql(JSON.stringify(payload)); }); - it('should sign the payload of GET request', function*() { + it('should sign the payload of GET request', async function() { const payload = { name: 'Test' }; this.sandbox.spy(Escher.prototype, 'signRequest'); - yield suiteRequest.get('/path', payload); + await suiteRequest.get('/path', payload); expect(Escher.prototype.signRequest.callCount).to.eql(1); const firstCall = Escher.prototype.signRequest.getCall(0); @@ -175,11 +173,11 @@ describe('SuiteRequest', function() { expect(suiteRequest.httpsAgent).to.be.an.instanceOf(https.Agent); }); - it('should pass http agents to wrapper', function*() { + it('should pass http agents to wrapper', async () => { requestOptions = new SuiteRequest.Options(serviceConfig.host, Object.assign({ keepAlive: true }, serviceConfig)); suiteRequest = SuiteRequest.create('key-id', 'secret', requestOptions); - yield suiteRequest.post('/path', { name: 'Almanach' }); + await suiteRequest.post('/path', { name: 'Almanach' }); const requestArgument = requestStub.args[0][0]; expect(requestArgument.httpAgent).to.eql(suiteRequest.httpAgent); diff --git a/src/request.ts b/src/request.ts new file mode 100644 index 0000000..a561488 --- /dev/null +++ b/src/request.ts @@ -0,0 +1,123 @@ +import Escher from 'escher-auth'; +import { Agent as HttpAgent } from 'http'; +import { Agent as HttpsAgent } from 'https'; +import { SuiteRequestOption } from './requestOption'; +import { RequestWrapper, ExtendedRequestOption } from './wrapper'; +import { SuiteRequestError } from './requestError'; +import createLogger from '@emartech/json-logger'; +const logger = createLogger('suiterequest'); + +export class SuiteRequest { + static Options = SuiteRequestOption; + static Error = SuiteRequestError; + static EscherConstants = { + algoPrefix: 'EMS', + vendorKey: 'EMS', + credentialScope: 'eu/suite/ems_request', + authHeaderName: 'X-Ems-Auth', + dateHeaderName: 'X-Ems-Date' + }; + _escher: Escher; + _options: SuiteRequestOption; + httpAgent?: HttpAgent; + httpsAgent?: HttpsAgent; + + static create(accessKeyId: string, apiSecret: string, requestOptions: SuiteRequestOption) { + return new SuiteRequest(accessKeyId, apiSecret, requestOptions); + } + + constructor(accessKeyId: string, apiSecret: string, requestOptions: SuiteRequestOption) { + const escherConfig = Object.assign({}, SuiteRequest.EscherConstants, { + accessKeyId: accessKeyId, + apiSecret: apiSecret, + credentialScope: requestOptions.credentialScope || SuiteRequest.EscherConstants.credentialScope + }); + + this._escher = new Escher(escherConfig); + this._options = requestOptions; + + if (requestOptions.keepAlive) { + this.httpAgent = new HttpAgent({ keepAlive: true }); + this.httpsAgent = new HttpsAgent({ keepAlive: true }); + } + } + + get(path: string, data: any) { + return this._request('GET', path, data); + } + + patch(path: string, data: any) { + return this._request('PATCH', path, data); + } + + post(path: string, data: any) { + return this._request('POST', path, data); + } + + put(path: string, data: any) { + return this._request('PUT', path, data); + } + + delete(path: string) { + return this._request('DELETE', path); + } + + _request(method: string, path: string, data?: any) { + const options = this._getOptionsFor(method, path); + const payload = data ? this._getPayload(data) : ''; + const signedOptions = this._signRequest(options, payload); + + logger.info('send', this._getLogParameters(options)); + return this._getRequestFor(signedOptions, payload).send(); + } + + setOptions(requestOptions: SuiteRequestOption) { + this._options = requestOptions; + } + + getOptions() { + return this._options; + } + + _getRequestFor(requestOptions: ExtendedRequestOption, payload: any) { + const protocol = (this._options.secure) ? 'https:' : 'http:'; + return new RequestWrapper(requestOptions, protocol, payload); + } + + _getOptionsFor(method: string, path: string): ExtendedRequestOption { + const defaultOptions = this._options.toHash(); + const realPath = defaultOptions.prefix + path; + + return Object.assign({}, defaultOptions, { + method: method, + url: realPath, + path: realPath, + httpAgent: this.httpAgent, + httpsAgent: this.httpsAgent + }); + } + + _signRequest(options: ExtendedRequestOption, payload: any) { + const headerNames = options.headers ? options.headers.map(function(header) { + return header[0]; + }) : []; + + return (this._escher.signRequest(options, payload, headerNames) as ExtendedRequestOption); + } + + _getLogParameters(options: ExtendedRequestOption) { + const { method, host, url } = options; + return { method, host, url }; + } + + _getPayload(data: any) { + if (this._options?.getHeader('content-type')?.indexOf('application/json') === -1) { + return data; + } + + return JSON.stringify(data); + } +} + +module.exports = SuiteRequest; +export default SuiteRequest; diff --git a/lib/requestError.spec.js b/src/requestError.spec.js similarity index 95% rename from lib/requestError.spec.js rename to src/requestError.spec.js index 110a212..571540d 100644 --- a/lib/requestError.spec.js +++ b/src/requestError.spec.js @@ -1,6 +1,4 @@ -'use strict'; - -const SuiteRequestError = require('./requestError'); +const { SuiteRequestError } = require('./requestError'); describe('SuiteRequestError', function() { it('should extend base Error class', function() { diff --git a/src/requestError.ts b/src/requestError.ts new file mode 100644 index 0000000..c5dfbc4 --- /dev/null +++ b/src/requestError.ts @@ -0,0 +1,23 @@ +import { AxiosResponse } from 'axios'; + +export class SuiteRequestError extends Error { + code: number; + originalCode: string | undefined; + data: any; + + constructor(message: string, code: number, response?: string | AxiosResponse, originalCode?: string) { + super(message); + + this.code = code; + this.originalCode = originalCode; + this.name = 'SuiteRequestError'; + + if (response) { + this.data = (response as AxiosResponse).data || response; + } else { + this.data = { + replyText: message + }; + } + } +} diff --git a/lib/requestOption.spec.js b/src/requestOption.spec.js similarity index 98% rename from lib/requestOption.spec.js rename to src/requestOption.spec.js index f27daa6..87d0cfa 100644 --- a/lib/requestOption.spec.js +++ b/src/requestOption.spec.js @@ -1,6 +1,4 @@ -'use strict'; - -const SuiteRequestOption = require('./requestOption'); +const { SuiteRequestOption } = require('./requestOption'); describe('SuiteRequestOption', function() { diff --git a/lib/requestOption.js b/src/requestOption.ts similarity index 57% rename from lib/requestOption.js rename to src/requestOption.ts index a5ffcb4..86b9443 100644 --- a/lib/requestOption.js +++ b/src/requestOption.ts @@ -1,23 +1,46 @@ -'use strict'; - const MEGA_BYTE = 1024 * 1024; -class SuiteRequestOption { +export interface RequestOptions { + secure?: boolean; + port?: number; + host?: string; + rejectUnauthorized?: boolean; + headers?: string[][]; + prefix?: string; + timeout?: number; + allowEmptyResponse?: boolean; + maxContentLength?: number; + keepAlive?: boolean; + environment?: string; +} - static createForInternalApi(environment, rejectUnauthorized) { +export class SuiteRequestOption { + secure = true; + port = 443; + host = ''; + rejectUnauthorized = true; + headers: string[][] = []; + prefix = ''; + timeout = 15000; + allowEmptyResponse = false; + maxContentLength = 10 * MEGA_BYTE; + keepAlive = false; + credentialScope = ''; + + static createForInternalApi(environment: string, rejectUnauthorized: boolean) { return this.create(environment, '/api/v2/internal', rejectUnauthorized); } - static createForServiceApi(environment, rejectUnauthorized) { + static createForServiceApi(environment: string, rejectUnauthorized: boolean) { return this.create(environment, '/api/services', rejectUnauthorized); } - static create(host, prefix, rejectUnauthorized) { - let options = {}; + static create(host: string, prefix: string, rejectUnauthorized: boolean) { + let options: RequestOptions = {}; if (typeof host === 'object') { options = host; - host = options.environment; + host = options.environment || ''; } else { options.rejectUnauthorized = rejectUnauthorized; } @@ -26,14 +49,14 @@ class SuiteRequestOption { return new SuiteRequestOption(host, options); } - constructor(host, options) { + constructor(host: string, options: RequestOptions) { this.secure = options.secure !== false; this.port = options.port || 443; this.host = host; this.rejectUnauthorized = options.rejectUnauthorized !== false; this.headers = [['content-type', 'application/json'], ['host', host]]; this.prefix = ''; - this.timeout = 'timeout' in options ? options.timeout : 15000; + this.timeout = options.timeout || 15000; this.allowEmptyResponse = false; this.maxContentLength = options.maxContentLength || 10 * MEGA_BYTE; this.keepAlive = !!options.keepAlive; @@ -45,32 +68,32 @@ class SuiteRequestOption { Object.assign(this, options); } - setToSecure(port, rejectUnauthorized) { + setToSecure(port: number, rejectUnauthorized: boolean) { this.port = port || 443; this.secure = true; this.rejectUnauthorized = rejectUnauthorized; } - setToUnsecure(port) { + setToUnsecure(port: number) { this.port = port || 80; this.secure = false; } - setEnvironment(environment) { + setEnvironment(environment: string) { this.host = environment; } - setPort(port) { + setPort(port: number) { this.port = port; } - setHeader(headerToSet) { + setHeader(headerToSet: string[]) { this.headers = this.headers - .filter(this._headersExcept(headerToSet[0])) + .filter(existingHeader => existingHeader[0] !== headerToSet[0]) .concat([headerToSet]); } - getHeader(name) { + getHeader(name: string) { const result = this.headers.find((header) => { return header[0].toLowerCase() === name.toLowerCase(); }); @@ -78,7 +101,7 @@ class SuiteRequestOption { return result ? result[1] : null; } - setTimeout(timeout) { + setTimeout(timeout: number) { this.timeout = timeout; } @@ -86,8 +109,8 @@ class SuiteRequestOption { return this.timeout; } - toHash() { - const hash = { + toHash(): RequestOptions { + const hash: RequestOptions = { port: this.port, host: this.host, headers: this.headers.slice(0), @@ -106,12 +129,4 @@ class SuiteRequestOption { return hash; } - - _headersExcept(headerKeyToSkip) { - return function(existingHeader) { - return existingHeader[0] !== headerKeyToSkip; - }; - } } - -module.exports = SuiteRequestOption; diff --git a/lib/testSetup.spec.js b/src/testSetup.spec.js similarity index 96% rename from lib/testSetup.spec.js rename to src/testSetup.spec.js index dcdb6b7..65104ac 100644 --- a/lib/testSetup.spec.js +++ b/src/testSetup.spec.js @@ -1,5 +1,3 @@ -'use strict'; - const sinon = require('sinon'); const chai = require('chai'); const chaiSubset = require('chai-subset'); diff --git a/lib/wrapper.spec.js b/src/wrapper.spec.js similarity index 81% rename from lib/wrapper.spec.js rename to src/wrapper.spec.js index c2db194..f3bd5d8 100644 --- a/lib/wrapper.spec.js +++ b/src/wrapper.spec.js @@ -1,9 +1,7 @@ -'use strict'; - const axios = require('axios'); -const Wrapper = require('./wrapper'); -const SuiteRequestError = require('./requestError'); -const RequestOption = require('./requestOption'); +const { RequestWrapper } = require('./wrapper'); +const { SuiteRequestError } = require('./requestError'); +const { SuiteRequestOption } = require('./requestOption'); const http = require('http'); const https = require('https'); @@ -28,7 +26,7 @@ describe('Wrapper', function() { statusMessage: 'OK' }; - escherRequestOptions = new RequestOption('very.host.io', { + escherRequestOptions = new SuiteRequestOption('very.host.io', { port: 443, headers: [ ['content-type', 'very-format'], @@ -66,11 +64,11 @@ describe('Wrapper', function() { }; this.sandbox.stub(axios.CancelToken, 'source').returns(source); - wrapper = new Wrapper(escherRequestOptions, 'http:'); + wrapper = new RequestWrapper(escherRequestOptions, 'http:'); }); - it('should send GET request and return its response', function *() { - const response = yield wrapper.send(); + it('should send GET request and return its response', async () => { + const response = await wrapper.send(); expect(response).to.be.eql(expectedApiResponse); const requestArgument = requestGetStub.args[0][0]; @@ -79,29 +77,29 @@ describe('Wrapper', function() { expect(requestArgument).not.to.have.own.property('httpsAgent'); }); - it('should pass http agents to axios', function *() { + it('should pass http agents to axios', async () => { const agents = { httpAgent: new http.Agent({ keepAlive: true }), httpsAgent: new https.Agent({ keepAlive: true }) }; - wrapper = new Wrapper( + wrapper = new RequestWrapper( Object.assign(agents, escherRequestOptions), 'http:' ); - yield wrapper.send(); + await wrapper.send(); const requestArgument = requestGetStub.args[0][0]; expect(requestArgument.httpAgent).to.eql(agents.httpAgent); expect(requestArgument.httpsAgent).to.eql(agents.httpsAgent); }); - it('should throw error when response code is 400 or above', function *() { + it('should throw error when response code is 400 or above', async () => { apiResponse.status = 400; apiResponse.data = JSON.stringify({ replyText: 'Unknown route' }); try { - yield wrapper.send(); + await wrapper.send(); throw new Error('Error should have been thrown'); } catch (err) { expect(err).to.be.an.instanceof(SuiteRequestError); @@ -114,26 +112,26 @@ describe('Wrapper', function() { describe('when empty response is allowed', function() { beforeEach(function() { escherRequestOptions.allowEmptyResponse = true; - wrapper = new Wrapper(escherRequestOptions, 'http:'); + wrapper = new RequestWrapper(escherRequestOptions, 'http:'); }); - it('should allow body to be empty', function *() { + it('should allow body to be empty', async () => { apiResponse.headers['content-type'] = 'text/html'; apiResponse.data = ''; apiResponse.status = 204; - const response = yield wrapper.send(); + const response = await wrapper.send(); expect(response.statusCode).to.eql(204); }); - it('should throw error if json is not parsable (empty)', function *() { + it('should throw error if json is not parsable (empty)', async () => { apiResponse.data = ''; try { - yield wrapper.send(); + await wrapper.send(); } catch (err) { expect(err).to.be.an.instanceof(SuiteRequestError); expect(err.message).to.match(/Unexpected end/); @@ -145,11 +143,11 @@ describe('Wrapper', function() { }); describe('when empty response is not allowed', function() { - it('should throw error if response body is empty', function *() { + it('should throw error if response body is empty', async () => { apiResponse.data = ''; try { - yield wrapper.send(); + await wrapper.send(); } catch (err) { expect(err).to.be.an.instanceof(SuiteRequestError); expect(err.message).to.eql('Empty http response'); @@ -160,12 +158,12 @@ describe('Wrapper', function() { throw new Error('Error should have been thrown'); }); - it('should throw error with status message if response body is empty and status message exists', function *() { + it('should throw error with status message if response body is empty and status message exists', async () => { apiResponse.data = ''; apiResponse.statusText = 'dummy status message'; try { - yield wrapper.send(); + await wrapper.send(); } catch (err) { expect(err.data).to.eql(apiResponse.statusText); return; @@ -173,13 +171,13 @@ describe('Wrapper', function() { throw new Error('Error should have been thrown'); }); - it('should throw a http response error even if the response body is empty', function *() { + it('should throw a http response error even if the response body is empty', async () => { apiResponse.data = '404 Not Found'; apiResponse.status = 404; apiResponse.headers = { 'content-type': 'text/plain' }; try { - yield wrapper.send(); + await wrapper.send(); throw new Error('should throw'); } catch (err) { expect(err).to.be.an.instanceOf(SuiteRequestError); @@ -208,9 +206,9 @@ describe('Wrapper', function() { isCancel.returns(false); }); - it('cancels the request', function *() { + it('cancels the request', async () => { try { - yield wrapper.send(); + await wrapper.send(); throw new Error('should throw SuiteRequestError'); } catch (err) { expect(source.cancel).to.have.been.calledWith(); @@ -223,9 +221,9 @@ describe('Wrapper', function() { isCancel.returns(true); }); - it('does not cancel the request', function *() { + it('does not cancel the request', async () => { try { - yield wrapper.send(); + await wrapper.send(); throw new Error('should throw SuiteRequestError'); } catch (err) { expect(source.cancel).not.to.have.been.calledWith(); @@ -233,26 +231,26 @@ describe('Wrapper', function() { }); }); - it('should pass original error code to SuiteRequestError', function*() { + it('should pass original error code to SuiteRequestError', async () => { try { axiosError.code = 'ECONNABORTED'; - yield wrapper.send(); + await wrapper.send(); throw new Error('should throw SuiteRequestError'); } catch (err) { expect(err.originalCode).to.eql('ECONNABORTED'); expect(err.code).to.eql(503); - expect(err.data).to.eql({}); + expect(err.data).to.eql({ replyText: 'axios error message' }); } }); }); - it('should throw error if json is not parsable (malformed)', function *() { + it('should throw error if json is not parsable (malformed)', async () => { apiResponse.data = 'this is an invalid json'; try { - yield wrapper.send(); + await wrapper.send(); } catch (err) { expect(err).to.be.an.instanceof(SuiteRequestError); expect(err.message).to.match(/Unexpected token/); @@ -262,19 +260,19 @@ describe('Wrapper', function() { throw new Error('Error should have been thrown'); }); - it('should parse JSON if content-type header contains charset too', function *() { + it('should parse JSON if content-type header contains charset too', async () => { const testJson = { text: 'Test JSON text' }; apiResponse.headers['content-type'] = 'application/json; charset=utf-8'; apiResponse.data = JSON.stringify(testJson); - const response = yield wrapper.send(); + const response = await wrapper.send(); expect(response.body).to.eql(testJson); }); - it('should send GET request with given timeout in options', function*() { + it('should send GET request with given timeout in options', async () => { escherRequestOptions.timeout = 60000; - yield (new Wrapper(escherRequestOptions, 'http:')).send(); + await (new RequestWrapper(escherRequestOptions, 'http:')).send(); const requestArgument = requestGetStub.args[0][0]; expect(requestArgument.timeout).to.eql(escherRequestOptions.timeout); diff --git a/lib/wrapper.js b/src/wrapper.ts similarity index 60% rename from lib/wrapper.js rename to src/wrapper.ts index cfb2e19..7a30b36 100644 --- a/lib/wrapper.js +++ b/src/wrapper.ts @@ -1,13 +1,34 @@ -'use strict'; +import { SuiteRequestError } from './requestError'; +import { RequestOptions } from './requestOption'; +import { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosResponseHeaders, CancelTokenSource } from 'axios'; +import { Agent as HttpAgent } from 'http'; +import { Agent as HttpsAgent } from 'https'; +import axios from 'axios'; +import createLogger from '@emartech/json-logger'; +const logger = createLogger('suiterequest'); +const debugLogger = createLogger('suiterequest-debug'); + +export interface ExtendedRequestOption extends RequestOptions { + method: string; + url: string; + path: string; + httpAgent?: HttpAgent; + httpsAgent?: HttpsAgent; +} -const SuiteRequestError = require('./requestError'); -const logger = require('@emartech/json-logger')('suiterequest'); -const debugLogger = require('@emartech/json-logger')('suiterequest-debug'); -const axios = require('axios'); +interface TransformedResponse { + body: T, + statusCode: number; + statusMessage: string; + headers: AxiosResponseHeaders +} -class RequestWrapper { +export class RequestWrapper { + protocol: string; + payload: any; + requestOptions: ExtendedRequestOption; - constructor(requestOptions, protocol, payload) { + constructor(requestOptions : ExtendedRequestOption, protocol: string, payload: any = undefined) { this.requestOptions = requestOptions; this.protocol = protocol; this.payload = payload; @@ -25,11 +46,11 @@ class RequestWrapper { const reqOptions = this._getRequestOptions(); const source = axios.CancelToken.source(); - const axiosOptions = { + const axiosOptions: AxiosRequestConfig = { method, - url: `${reqOptions.uri.protocol}//${reqOptions.uri.hostname}:${reqOptions.uri.port}${reqOptions.uri.pathname}`, + url: reqOptions.url, headers: reqOptions.headers, - data: reqOptions.body, + data: reqOptions.data, timeout: reqOptions.timeout, transformResponse: [body => body], maxContentLength: this.requestOptions.maxContentLength, @@ -48,14 +69,14 @@ class RequestWrapper { response => this._transformResponse(response), error => this._handleResponseError(error, source) ) - .then(response => { + .then((response: any) => { timer.info('send', this._getLogParameters()); return this._handleResponse(response); }); } - _transformResponse(response) { + _transformResponse(response: AxiosResponse): TransformedResponse { return { body: response.data, statusCode: response.status, @@ -64,7 +85,7 @@ class RequestWrapper { }; } - _handleResponseError(error, source) { + _handleResponseError(error: AxiosError, source: CancelTokenSource) { if (!axios.isCancel(error)) { source.cancel(); logger.info('Canceled request'); @@ -72,12 +93,12 @@ class RequestWrapper { logger.fromError('fatal_error', error, this._getLogParameters()); const recoverableErrorCodes = ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'ECONNABORTED']; - const code = recoverableErrorCodes.includes(error.code) ? 503 : 500; + const code = recoverableErrorCodes.includes(error.code || '') ? 503 : 500; - throw new SuiteRequestError(error.message, code, {}, error.code); + throw new SuiteRequestError(error.message, code, undefined, error.code); } - _handleResponse(response) { + _handleResponse(response: TransformedResponse) { if (response.statusCode >= 400) { logger.error('server_error', this._getLogParameters({ code: response.statusCode, @@ -103,44 +124,41 @@ class RequestWrapper { }; } - _isJsonResponse(response) { + _isJsonResponse(response: TransformedResponse) { return response.headers['content-type'] && response.headers['content-type'].indexOf('application/json') !== -1; } - _getLogParameters(extraParametersToLog) { + _getLogParameters(extraParametersToLog = {}) { const { method, host, url } = this.requestOptions; const requestParametersToLog = { method, host, url }; return Object.assign({}, requestParametersToLog, extraParametersToLog); } _getRequestOptions() { - const headers = {}; + const headers: Record = {}; - this.requestOptions.headers.forEach(function(header) { - headers[header[0]] = header[1]; - }); + if (this.requestOptions.headers) { + this.requestOptions.headers.forEach(function(header) { + headers[header[0]] = header[1]; + }); + } - const reqOptions = { - uri: { - hostname: this.requestOptions.host, - port: this.requestOptions.port, - protocol: this.protocol, - pathname: this.requestOptions.path - }, + const reqOptions: AxiosRequestConfig = { + url: `${this.protocol}//${this.requestOptions.host}:${this.requestOptions.port}${this.requestOptions.path}`, headers: headers, timeout: this.requestOptions.timeout }; debugLogger.info('wrapper_options', reqOptions); if (this.payload) { - reqOptions.body = this.payload; + reqOptions.data = this.payload; } return reqOptions; } - _parseBody(response) { + _parseBody(response: TransformedResponse) { if (!this._isJsonResponse(response)) { return response.body; } @@ -149,9 +167,7 @@ class RequestWrapper { return JSON.parse(response.body); } catch (ex) { logger.fromError('fatal_error', ex, this._getLogParameters()); - throw new SuiteRequestError(ex.message, 500); + throw new SuiteRequestError((ex as Error).message, 500); } } } - -module.exports = RequestWrapper; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..55cb716 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,108 @@ +{ + "exclude": ["dist", "node_modules", "src/**/*.spec.js"], + "include": ["src/**/*.ts", "src/**/*.js"], + "files": [ + "src/escher-auth.d.ts" + ], + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "src": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default src.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "commonjs", /* Specify what module code is generated. */ + "rootDir": "./src", /* Specify the root folder within your source files. */ + // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "./dist", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + "noEmit": false, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +}