/** * @file * @copyright 2016 Yahoo Inc. * @license Licensed under {@link https://spdx.org/licenses/BSD-3-Clause-Clear.html BSD-3-Clause-Clear}. * Github.js is freely distributable. */import axios from 'axios';import debug from 'debug';import {Base64} from 'js-base64';const log = debug('github:request');/** * The error structure returned when a network call fails */class ResponseError extends Error { /** * Construct a new ResponseError * @param {string} message - an message to return instead of the the default error message * @param {string} path - the requested path * @param {Object} response - the object returned by Axios */ constructor(message, path, response) { super(message); this.path = path; this.request = response.config; this.response = (response || {}).response || response; this.status = response.status; }}/** * Requestable wraps the logic for making http requests to the API */class Requestable { /** * Either a username and password or an oauth token for Github * @typedef {Object} Requestable.auth * @prop {string} [username] - the Github username * @prop {string} [password] - the user's password * @prop {token} [token] - an OAuth token */ /** * Initialize the http internals. * @param {Requestable.auth} [auth] - the credentials to authenticate to Github. If auth is * not provided request will be made unauthenticated * @param {string} [apiBase=https://api.github.com] - the base Github API URL * @param {string} [AcceptHeader=v3] - the accept header for the requests */ constructor(auth, apiBase, AcceptHeader) { this.__apiBase = apiBase || 'https://api.github.com'; this.__auth = { token: auth.token, username: auth.username, password: auth.password, }; this.__AcceptHeader = AcceptHeader || 'v3'; if (auth.token) { this.__authorizationHeader = 'token ' + auth.token; } else if (auth.username && auth.password) { this.__authorizationHeader = 'Basic ' + Base64.encode(auth.username + ':' + auth.password); } } /** * Compute the URL to use to make a request. * @private * @param {string} path - either a URL relative to the API base or an absolute URL * @return {string} - the URL to use */ __getURL(path) { let url = path; if (path.indexOf('//') === -1) { url = this.__apiBase + path; } let newCacheBuster = 'timestamp=' + new Date().getTime(); return url.replace(/(timestamp=\d+)/, newCacheBuster); } /** * Compute the headers required for an API request. * @private * @param {boolean} raw - if the request should be treated as JSON or as a raw request * @param {string} AcceptHeader - the accept header for the request * @return {Object} - the headers to use in the request */ __getRequestHeaders(raw, AcceptHeader) { let headers = { 'Content-Type': 'application/json;charset=UTF-8', 'Accept': 'application/vnd.github.' + (AcceptHeader || this.__AcceptHeader), }; if (raw) { headers.Accept += '.raw'; } headers.Accept += '+json'; if (this.__authorizationHeader) { headers.Authorization = this.__authorizationHeader; } return headers; } /** * Sets the default options for API requests * @protected * @param {Object} [requestOptions={}] - the current options for the request * @return {Object} - the options to pass to the request */ _getOptionsWithDefaults(requestOptions = {}) { if (!(requestOptions.visibility || requestOptions.affiliation)) { requestOptions.type = requestOptions.type || 'all'; } requestOptions.sort = requestOptions.sort || 'updated'; requestOptions.per_page = requestOptions.per_page || '100'; // eslint-disable-line return requestOptions; } /** * if a `Date` is passed to this function it will be converted to an ISO string * @param {*} date - the object to attempt to cooerce into an ISO date string * @return {string} - the ISO representation of `date` or whatever was passed in if it was not a date */ _dateToISO(date) { if (date && (date instanceof Date)) { date = date.toISOString(); } return date; } /** * A function that receives the result of the API request. * @callback Requestable.callback * @param {Requestable.Error} error - the error returned by the API or `null` * @param {(Object|true)} result - the data returned by the API or `true` if the API returns `204 No Content` * @param {Object} request - the raw {@linkcode https://github.com/mzabriskie/axios#response-schema Response} */ /** * Make a request. * @param {string} method - the method for the request (GET, PUT, POST, DELETE) * @param {string} path - the path for the request * @param {*} [data] - the data to send to the server. For HTTP methods that don't have a body the data * will be sent as query parameters * @param {Requestable.callback} [cb] - the callback for the request * @param {boolean} [raw=false] - if the request should be sent as raw. If this is a falsy value then the * request will be made as JSON * @return {Promise} - the Promise for the http request */ _request(method, path, data, cb, raw) { const url = this.__getURL(path); const AcceptHeader = (data || {}).AcceptHeader; if (AcceptHeader) { delete data.AcceptHeader; } const headers = this.__getRequestHeaders(raw, AcceptHeader); let queryParams = {}; const shouldUseDataAsParams = data && (typeof data === 'object') && methodHasNoBody(method); if (shouldUseDataAsParams) { queryParams = data; data = undefined; } const config = { url: url, method: method, headers: headers, params: queryParams, data: data, responseType: raw ? 'text' : 'json', }; log(`${config.method} to ${config.url}`); const requestPromise = axios(config).catch(callbackErrorOrThrow(cb, path)); if (cb) { requestPromise.then((response) => { if (response.data && Object.keys(response.data).length > 0) { // When data has results cb(null, response.data, response); } else if (config.method !== 'GET' && Object.keys(response.data).length < 1) { // True when successful submit a request and receive a empty object cb(null, (response.status < 300), response); } else { cb(null, response.data, response); } }); } return requestPromise; } /** * Make a request to an endpoint the returns 204 when true and 404 when false * @param {string} path - the path to request * @param {Object} data - any query parameters for the request * @param {Requestable.callback} cb - the callback that will receive `true` or `false` * @param {method} [method=GET] - HTTP Method to use * @return {Promise} - the promise for the http request */ _request204or404(path, data, cb, method = 'GET') { return this._request(method, path, data) .then(function success(response) { if (cb) { cb(null, true, response); } return true; }, function failure(response) { if (response.response.status === 404) { if (cb) { cb(null, false, response); } return false; } if (cb) { cb(response); } throw response; }); } /** * Make a request and fetch all the available data. Github will paginate responses so for queries * that might span multiple pages this method is preferred to {@link Requestable#request} * @param {string} path - the path to request * @param {Object} options - the query parameters to include * @param {Requestable.callback} [cb] - the function to receive the data. The returned data will always be an array. * @param {Object[]} results - the partial results. This argument is intended for interal use only. * @return {Promise} - a promise which will resolve when all pages have been fetched * @deprecated This will be folded into {@link Requestable#_request} in the 2.0 release. */ _requestAllPages(path, options, cb, results) { results = results || []; return this._request('GET', path, options) .then((response) => { let thisGroup; if (response.data instanceof Array) { thisGroup = response.data; } else if (response.data.items instanceof Array) { thisGroup = response.data.items; } else { let message = `cannot figure out how to append ${response.data} to the result set`; throw new ResponseError(message, path, response); } results.push(...thisGroup); const nextUrl = getNextPage(response.headers.link); if (nextUrl && typeof options.page !== 'number') { log(`getting next page: ${nextUrl}`); return this._requestAllPages(nextUrl, options, cb, results); } if (cb) { cb(null, results, response); } response.data = results; return response; }).catch(callbackErrorOrThrow(cb, path)); }}module.exports = Requestable;// ////////////////////////// //// Private helper functions //// ////////////////////////// //const METHODS_WITH_NO_BODY = ['GET', 'HEAD', 'DELETE'];function methodHasNoBody(method) { return METHODS_WITH_NO_BODY.indexOf(method) !== -1;}function getNextPage(linksHeader = '') { const links = linksHeader.split(/\s*,\s*/); // splits and strips the urls return links.reduce(function(nextUrl, link) { if (link.search(/rel="next"/) !== -1) { return (link.match(/<(.*)>/) || [])[1]; } return nextUrl; }, undefined);}function callbackErrorOrThrow(cb, path) { return function handler(object) { let error; if (object.hasOwnProperty('config')) { const {response: {status, statusText}, config: {method, url}} = object; let message = (`${status} error making request ${method} ${url}: "${statusText}"`); error = new ResponseError(message, path, object); log(`${message} ${JSON.stringify(object.data)}`); } else { error = object; } if (cb) { log('going to error callback'); cb(error); } else { log('throwing error'); throw error; } };}