mirror of
https://github.com/jdalrymple/gitbeaker.git
synced 2026-01-25 16:04:01 +00:00
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:
parent
18effa2aca
commit
ee4730fbd0
43
README.md
43
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.
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
import { shim } from 'universal-url';
|
||||
|
||||
// Add URL shim
|
||||
shim();
|
||||
|
||||
import { bundler } from './infrastructure';
|
||||
import * as APIServices from './services';
|
||||
|
||||
@ -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;
|
||||
|
||||
61
src/infrastructure/KyRequester.ts
Normal file
61
src/infrastructure/KyRequester.ts
Normal 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 };
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
@ -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 };
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user