fix: Removed xhr library in favour of ky, and switched request for got for a smaller package size and retry functionality

This commit is contained in:
Justin 2019-05-25 18:02:58 -04:00 committed by Justin Dalrymple
parent 18effa2aca
commit ee4730fbd0
7 changed files with 167 additions and 368 deletions

View File

@ -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.
});
```

View File

@ -1,3 +1,7 @@
import { shim } from 'universal-url';
// Add URL shim
shim();
import { bundler } from './infrastructure';
import * as APIServices from './services';

View File

@ -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<Pick<BaseModelOptions, 'token'>>
| BaseModelOptions & Required<Pick<BaseModelOptions, 'oauthToken'>>;
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;

View File

@ -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 };

View File

@ -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<Pick<RequestParametersInput, 'url'>>;
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<GetResponse> {
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<PostResponse> {
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<PutResponse> {
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<temporaryAny> {
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<DelResponse> {
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;

View File

@ -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<temporaryAny>;
}
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<F extends Function>(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;

View File

@ -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 };