From ee4730fbd0cbbf28e39255bf20b038d72d582d99 Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 25 May 2019 18:02:58 -0400 Subject: [PATCH] fix: Removed xhr library in favour of ky, and switched request for got for a smaller package size and retry functionality --- README.md | 43 +--- src/index.ts | 4 + src/infrastructure/BaseService.ts | 33 +-- src/infrastructure/KyRequester.ts | 61 +++++ src/infrastructure/RequestHelper.ts | 327 ++++++------------------- src/infrastructure/XMLHttpRequester.ts | 58 ----- src/infrastructure/index.ts | 9 +- 7 files changed, 167 insertions(+), 368 deletions(-) create mode 100644 src/infrastructure/KyRequester.ts delete mode 100644 src/infrastructure/XMLHttpRequester.ts diff --git a/README.md b/README.md index f0d03cae..edc50219 100644 --- a/README.md +++ b/README.md @@ -147,43 +147,26 @@ Instantiate the library using a basic token created in your [Gitlab Profile](htt ```javascript // ES6 (>=node 8.9.0) -import { Gitlab } from 'gitlab'; +import { Gitlab } from 'gitlab'; // All Resources +import { Projects } from 'gitlab'; // Just the Project Resource +//...etc // ES5, assuming native or polyfilled Promise is available const { Gitlab } = require('gitlab'); - -// Instantiating -const api = new Gitlab({ - host: 'http://example.com', // Defaults to https://gitlab.com - token: 'abcdefghij123456', // Can be created in your profile. -}); - -// Or, use a OAuth token instead! - -const api = new Gitlab({ - host: 'http://example.com', // Defaults to https://gitlab.com - oauthToken: 'abcdefghij123456', -}); - -// You can also use a CI job token: - -const api = new Gitlab({ - url: 'http://example.com', // Defaults to https://gitlab.com - jobToken: process.env.CI_JOB_TOKEN, -}); ``` -#### Specific Imports - -Sometimes you don't want to import and instantiate the whole Gitlab API, perhaps you only want access to the Projects API. To do this, one only needs to import and instantiate this specific API: - +Instatiating options: ```javascript -import { Projects } from 'gitlab'; - -const service = new Projects({ - host: 'http://example.com', // Defaults to https://gitlab.com - token: 'abcdefghij123456', // Can be created in your profile. +const api = new Gitlab({ + host: 'http://gl.com', //Optional, Default: https://gitlab.com + token: 'personaltoken', //Personal Token. Required (one of the three tokens are required) + oauthToken: 'oauthtoken', //OAuth Token. Required (one of the three tokens are required) + jobToken: 'myJobToken', //CI job token. Required (one of the three tokens are required) + rejectUnauthorized: false //Http Certificate setting. Optional, Default: false + sudo: false //Sudo query parameter. Optional, Default: false + version = 'v4', //API Version ID. Optional, Default: v4 camelize = false, //Response Key Camelize. Camelizes all response body keys. Optional, Default: false + requester = KyRequester, //Request Library Wrapper. Optional, Default: Currently wraps Ky. }); ``` diff --git a/src/index.ts b/src/index.ts index 7c715a1b..be140d2b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,7 @@ +import { shim } from 'universal-url'; + +// Add URL shim +shim(); import { bundler } from './infrastructure'; import * as APIServices from './services'; diff --git a/src/infrastructure/BaseService.ts b/src/infrastructure/BaseService.ts index 627e03f1..068bd8d6 100644 --- a/src/infrastructure/BaseService.ts +++ b/src/infrastructure/BaseService.ts @@ -1,29 +1,15 @@ -import URLJoin from 'url-join'; -import Request from 'request-promise'; -import XMLHttpRequester, { XhrStaticPromisified } from './XMLHttpRequester'; +import { KyRequester } from './KyRequester'; -interface BaseModelOptions { +export class BaseService { public readonly url: string; - token?: string; - oauthToken?: string; + public readonly requester: Requester; + public readonly headers: { [header: string]: string }; public readonly camelize: boolean; - version?: string; - sudo?: string | number; - rejectUnauthorized?: boolean; -} - -export type BaseModelContructorOptions = - | BaseModelOptions & Required> - | BaseModelOptions & Required>; -class BaseModel { - public url: string; - public readonly headers: { [header: string]: string | number}; public readonly rejectUnauthorized: boolean; - public readonly requester: XhrStaticPromisified; - public readonly useXMLHttpRequest: boolean; constructor({ token, + jobToken, oauthToken, sudo, host = 'https://gitlab.com', @@ -31,23 +17,20 @@ class BaseModel { version = 'v4', camelize = false, rejectUnauthorized = true, - }: BaseModelContructorOptions) { + requester = KyRequester, }: BaseServiceOptions) { this.url = [host, 'api', version, url].join('/'); this.headers = {}; - this.requester = useXMLHttpRequest - ? XMLHttpRequester : (Request as temporaryAny as XhrStaticPromisified); - this.useXMLHttpRequest = useXMLHttpRequest; this.rejectUnauthorized = rejectUnauthorized; this.camelize = camelize; + this.requester = requester; // Handle auth tokens if (oauthToken) this.headers.authorization = `Bearer ${oauthToken}`; + else if (jobToken) this.headers['job-token'] = jobToken; else if (token) this.headers['private-token'] = token; // Set sudo if (sudo) this.headers['Sudo'] = `${sudo}`; } } - -export default BaseModel; diff --git a/src/infrastructure/KyRequester.ts b/src/infrastructure/KyRequester.ts new file mode 100644 index 00000000..d9743160 --- /dev/null +++ b/src/infrastructure/KyRequester.ts @@ -0,0 +1,61 @@ +import Ky from 'ky-universal'; +import { decamelizeKeys } from 'humps'; +import { stringify } from 'query-string'; +import { skipAllCaps } from './Utils'; + +const methods = ['get', 'post', 'put', 'delete']; +const KyRequester = {} as Requester; + +function responseHeadersAsObject(response) { + const headers = {} + const keyVals = [...response.headers.entries()] + + keyVals.forEach(([key, val]) => { + headers[key] = val + }) + + return headers +} + +function defaultRequest( + service: any, + endpoint: string, + { body, query, sudo }: DefaultRequestOptions, +) { + const headers = new Headers(service.headers); + + if (sudo) headers.append('sudo', `${sudo}`); + + return [ + endpoint, + { + timeout: 30000, + headers, + searchParams: stringify(decamelizeKeys(query || {}), { arrayFormat: 'bracket' }), + prefixUrl: service.url, + json: typeof body === 'object' ? decamelizeKeys(body, skipAllCaps) : body, + rejectUnauthorized: service.rejectUnauthorized, + }, + ]; +} + +methods.forEach(m => { + KyRequester[m] = async function(service, endpoint, options) { + try { + const response = await Ky[m](...defaultRequest(service, endpoint, options)); + const { status } = response; + const headers = responseHeadersAsObject(response); + let body = await response.json(); + + if (typeof body === 'object') body = body || {}; + + return { body, headers, status }; + } catch(e) { + console.error(e) + console.error(...defaultRequest(service, endpoint, options)) + throw(e) + } + }; +}); + +export { KyRequester }; diff --git a/src/infrastructure/RequestHelper.ts b/src/infrastructure/RequestHelper.ts index 690c6593..a1beaf75 100644 --- a/src/infrastructure/RequestHelper.ts +++ b/src/infrastructure/RequestHelper.ts @@ -1,277 +1,100 @@ -import Humps from 'humps'; +import Li from 'li'; import { camelizeKeys } from 'humps'; -import QS from 'qs'; -import URLJoin from 'url-join'; -import StreamableRequest from 'request'; -import { BaseService } from '.'; -import { CommitAction } from '../services/Commits'; +import { BaseService } from './BaseService'; -export interface RequestParametersInput { - url?: string; - headers: import('./BaseService').default['headers']; - json?: boolean; - body?: Object; - qs?: Object; - qsStringifyOptions? : Object; - formData?: temporaryAny; - resolveWithFullResponse?: boolean; - rejectUnauthorized?: boolean; -} - -interface GetPaginatedOptions { - showPagination?: boolean; - maxPages?: number; - perPage?: number; - page?: number; - position?: temporaryAny; -} - -type RequestParametersOutput = RequestParametersInput & - Required>; - -export async function wait(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -function defaultRequest( - { url, useXMLHttpRequest, rejectUnauthorized }: BaseService, +export async function get( + service: BaseService, endpoint: string, - { headers, body, qs, formData, resolveWithFullResponse = false }: RequestParametersInput, -): RequestParametersOutput { - const params: RequestParametersOutput = { - url: URLJoin(url, endpoint), - headers, + options: PaginatedRequestOptions = {}, +): Promise { + const { showPagination, maxPages, sudo, ...query } = options; + const response = await service.requester.get(service, endpoint, { + query, sudo, + }); + const { headers } = response; + let { body } = response; + let pagination = { + total: headers['x-total'], + next: parseInt(headers['x-next-page'], 10) || null, + current: parseInt(headers['x-page'], 10) || 1, + previous: headers['x-prev-page'] || null, + perPage: headers['x-per-page'], + totalPages: headers['x-total-pages'], }; - if (body) params.body = Humps.decamelizeKeys(body); + const underLimit = maxPages ? pagination.current < maxPages : true; // Camelize response body if specified if (service.camelize) body = camelizeKeys(body); - // The xhr package doesn't have a way of passing in a qs object until v3 - params.url = URLJoin(params.url, `?${QS.stringify(Humps.decamelizeKeys(qs), { arrayFormat: 'brackets' })}`); - } else { - params.qs = Humps.decamelizeKeys(qs); - params.qsStringifyOptions = { arrayFormat: 'brackets' }; - } + + // Rescurse through pagination results + if (!query.page && underLimit && pagination.next) { + const { next } = Li.parse(headers.link); + const more = await get(service, next.replace(/.+\/api\/v\d\//, ''), { + maxPages, + sudo, + showPagination: true + }) as PaginationResponse; + + pagination = more.pagination; + body = [...body, ...more.data]; } - if (formData) params.formData = formData; - - params.resolveWithFullResponse = resolveWithFullResponse; - - params.rejectUnauthorized = rejectUnauthorized; - - return params; + return (query.page || body.length > 0) && showPagination ? { data: body, pagination } : body; } -function getStream(service: BaseService, endpoint: string, options: RequestOptions = {}) { - if (service.useXMLHttpRequest) { - throw new Error( - `Cannot use streaming functionality with XMLHttpRequest. Please instantiate without this - option to use streaming`, - ); - } - - const requestOptions = defaultRequest(service, endpoint, { - headers: service.headers, - qs: options, - }); - - return StreamableRequest.get(requestOptions); -} - -async function getPaginated( +export function stream( service: BaseService, endpoint: string, - options: GetPaginatedOptions = {}, + options: BaseRequestOptions = {}, ) { - const { showPagination, maxPages, ...queryOptions } = options; - const requestOptions = defaultRequest(service, endpoint, { - headers: service.headers, - qs: queryOptions, - resolveWithFullResponse: true, + if (typeof service.requester.stream !== 'function') { + throw new Error('Stream method is not implementated in requester!'); + } + + return service.requester.stream(service, endpoint, { + query: options, + }); +} + +export async function post( + service: BaseService, + endpoint: string, + options: BaseRequestOptions = {}, +): Promise { + const { sudo, ...body } = options; + const response = await service.requester.post(service, endpoint, { + body, + sudo, }); - const response = await service.requester.get(requestOptions); - const links = LinkParser(response.headers.link) || {}; - const page = response.headers['x-page']; - const underMaxPageLimit = maxPages ? page < maxPages : true; - let more = []; - let data: temporaryAny; - - // If not looking for a singular page and still under the max pages limit - // AND their is a next page, paginate - if (!queryOptions.page && underMaxPageLimit && links.next) { - more = await getPaginated(service, links.next.url.replace(service.url, ''), options); - data = [...response.body, ...more]; - } else { - data = response.body; - } - - if ((queryOptions.page || maxPages) && showPagination) { - return { - data, - pagination: { - total: response.headers['x-total'], - next: response.headers['x-next-page'] || null, - current: response.headers['x-page'] || null, - previous: response.headers['x-prev-page'] || null, - perPage: response.headers['x-per-page'], - totalPages: response.headers['x-total-pages'], - }, - }; - } - - return data; + return response.body; } -type RequestType = 'post' | 'get' | 'put' | 'delete'; +export async function put( + service: BaseService, + endpoint: string, + options: BaseRequestOptions = {}, +): Promise { + const { sudo, ...body } = options; + const response = await service.requester.put(service, endpoint, { + body, + }); -export interface RequestOptions { - targetIssueId?: string; - targetProjectId?: string; - content?: string; - id?: string; - sourceBranch?: string; - targetBranch?: string; - /** The duration in human format. e.g: 3h30m */ - duration?: string; - domain?: string; - cron?: temporaryAny; - description?: string; - file?: { - value: Buffer; - options: { - filename: string; - contentType: 'application/octet-stream'; - }; - }; - path?: string; - namespace?: string; - visibility?: string; - code?: string; - fileName?: string; - from?: string; - to?: string; - sha?: string; - runnerId?: string; - ref?: string; - scope?: string; - url?: string; - scopes?: temporaryAny; - expiresAt?: string; - note?: string; - actions?: CommitAction[]; - commitMessage?: string; - branch?: string; - body?: string | temporaryAny; - title?: string; - name?: string; - labelId?: temporaryAny; - accessLevel?: number; - userId?: UserId; - position?: temporaryAny; - value?: string; - linkUrl?: string; - imageUrl?: string; - key?: string; - action?: string; - targetType?: string; - email?: string; - password?: string; - search?: string; - public?: boolean; - text?: string; + return response.body; } -class RequestHelper { - static async request( - type: RequestType, - service: BaseService, - endpoint: string, - options: RequestOptions = {}, - form = false, - stream = false, - ): Promise { - try { - switch (type) { - case 'get': - if (stream) return await getStream(service, endpoint, options); - return await getPaginated(service, endpoint, options); +export async function del( + service: BaseService, + endpoint: string, + options: BaseRequestOptions = {}, +): Promise { + const { sudo, ...query } = options; + const response = await service.requester.delete(service, endpoint, { + query, + sudo, + }); - case 'post': { - const requestOptions = defaultRequest(service, endpoint, { - headers: service.headers, - [form ? 'formData' : 'body']: options, - }); - - return await service.requester.post(requestOptions); - } - - case 'put': { - const requestOptions = defaultRequest(service, endpoint, { - headers: service.headers, - body: options, - }); - - return await service.requester.put(requestOptions); - } - - case 'delete': { - const requestOptions = defaultRequest(service, endpoint, { - headers: service.headers, - qs: options, - }); - - return await service.requester.delete(requestOptions); - } - - default: - throw new Error(`Unknown request type ${type}`); - } - } catch (err) { - await RequestHelper.handleRequestError(err); - return RequestHelper.request(type, service, endpoint, options, form, stream); - } - } - - static async handleRequestError(err: temporaryAny) { - if ( - !err.response || - !err.response.headers || - !err.response.headers['retry-after'] || - parseInt(err.statusCode, 10) !== 429 - ) { - throw err; - } - - const sleepTime = parseInt(err.response.headers['retry-after'], 10); - - if (!sleepTime) throw err; - return wait(sleepTime * 1000); - } - - static get( - service: BaseService, - endpoint: string, - options: RequestOptions = {}, - { stream = false } = {}, - ) { - return RequestHelper.request('get', service, endpoint, options, false, stream); - } - - static post(service: BaseService, endpoint: string, options: RequestOptions = {}, form = false) { - return RequestHelper.request('post', service, endpoint, options, form); - } - - static put(service: BaseService, endpoint: string, options: RequestOptions = {}) { - return RequestHelper.request('put', service, endpoint, options); - } - - static delete(service: BaseService, endpoint: string, options: RequestOptions = {}) { - return RequestHelper.request('delete', service, endpoint, options); - } + return response.body; } - -export default RequestHelper; diff --git a/src/infrastructure/XMLHttpRequester.ts b/src/infrastructure/XMLHttpRequester.ts deleted file mode 100644 index 034979f4..00000000 --- a/src/infrastructure/XMLHttpRequester.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { StatusCodeError } from 'request-promise-core/errors'; -import { promisify } from 'util'; -import XHR, { XhrUriConfig, XhrUrlConfig } from 'xhr'; -import { wait } from './RequestHelper'; - -export interface XhrInstancePromisified { - (options: XhrUriConfig | XhrUrlConfig): Promise; -} - -export interface XhrStaticPromisified extends XhrInstancePromisified { - del: XhrInstancePromisified; - delete: XhrInstancePromisified; - get: XhrInstancePromisified; - head: XhrInstancePromisified; - patch: XhrInstancePromisified; - post: XhrInstancePromisified; - put: XhrInstancePromisified; -} - -interface XhrConfgExtraParams { - resolveWithFullResponse?: boolean; -} - -function promisifyWithRetry(fn: F): XhrInstancePromisified { - const promisifiedFn = promisify(fn); - - return async function getResponse( - opts: XhrUriConfig & XhrConfgExtraParams | XhrUrlConfig & XhrConfgExtraParams, - ) { - const response = await promisifiedFn(opts); - const sleepTime = parseInt(response.headers['retry-after'], 10); - if (response.statusCode === 429 && sleepTime) { - await wait(sleepTime * 1000); - } else if (response.statusCode >= 400 && response.statusCode <= 599) { - throw new StatusCodeError(response.statusCode, response.body, {}, null); - } - - return opts.resolveWithFullResponse ? response : response.body; - } as XhrInstancePromisified; -} - -const promisifyWithRetryDelete = promisifyWithRetry(XHR.del); -const XMLHttpRequesterPromisifiedExtras = { - del: promisifyWithRetryDelete, - delete: promisifyWithRetryDelete, - get: promisifyWithRetry(XHR.get), - head: promisifyWithRetry(XHR.head), - patch: promisifyWithRetry(XHR.patch), - post: promisifyWithRetry(XHR.post), - put: promisifyWithRetry(XHR.put), -}; - -const XMLHttpRequester: XhrStaticPromisified = Object.assign( - promisifyWithRetry(XHR), - XMLHttpRequesterPromisifiedExtras, -); - -export default XMLHttpRequester; diff --git a/src/infrastructure/index.ts b/src/infrastructure/index.ts index 54c191e3..f38b5fde 100644 --- a/src/infrastructure/index.ts +++ b/src/infrastructure/index.ts @@ -1,3 +1,6 @@ -export { default as BaseService } from './BaseService'; -export { default as RequestHelper } from './RequestHelper'; -export { default as Bundler } from './Bundler'; +import * as RequestHelper from './RequestHelper'; + +export { BaseService } from './BaseService'; +export { bundler } from './Utils'; +export { KyRequester } from './KyRequester'; +export { RequestHelper };