Skip to content

Instantly share code, notes, and snippets.

@chee
Created February 17, 2020 10:46
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save chee/7a68d18d01c389e20249b579f1bded3e to your computer and use it in GitHub Desktop.
Save chee/7a68d18d01c389e20249b579f1bded3e to your computer and use it in GitHub Desktop.
github@9
"use strict";
var error = require("./error");
var fs = require("fs");
var HttpsProxyAgent = require('https-proxy-agent');
var mime = require("mime");
var netrc = require("netrc");
var Util = require("./util");
var Url = require("url");
var Promise = require("./promise");
/** section: github
* class Client
*
* Copyright 2012 Cloud9 IDE, Inc.
*
* This product includes software developed by
* Cloud9 IDE, Inc (http://c9.io).
*
* Author: Mike de Boer <mike@c9.io>
*
* Upon instantiation of the [[Client]] class, the routes.json file is loaded
* and parsed for the API HTTP endpoints. For each HTTP endpoint to the
* HTTP server, a method is generated which accepts a Javascript Object
* with parameters and an optional callback to be invoked when the API request
* returns from the server or when the parameters could not be validated.
*
* When an HTTP endpoint is processed and a method is generated as described
* above, [[Client]] also sets up parameter validation with the rules as
* defined in the routes.json.
*
* These definitions are parsed and methods are created that the client can call
* to make an HTTP request to the server.
*
* For example, the endpoint `gists/get-from-user` will be exposed as a member
* on the [[Client]] object and may be invoked with
*
* client.getFromUser({
* "user": "bob"
* }, function(err, ret) {
* // do something with the result here.
* });
*
* // or to fetch a specfic page:
* client.getFromUser({
* "user": "bob",
* "page": 2,
* "per_page": 100
* }, function(err, ret) {
* // do something with the result here.
* });
*
* All the parameters as specified in the Object that is passed to the function
* as first argument, will be validated according to the rules in the `params`
* block of the route definition.
* Thus, in the case of the `user` parameter, according to the definition in
* the `params` block, it's a variable that first needs to be looked up in the
* `params` block of the `defines` section (at the top of the JSON file). Params
* that start with a `$` sign will be substituted with the param with the same
* name from the `defines/params` section.
* There we see that it is a required parameter (needs to hold a value). In other
* words, if the validation requirements are not met, an HTTP error is passed as
* first argument of the callback.
*
* Implementation Notes: the `method` is NOT case sensitive, whereas `url` is.
* The `url` parameter also supports denoting parameters inside it as follows:
*
* "get-from-user": {
* "url": "/users/:owner/gists",
* "method": "GET"
* ...
* }
**/
var Client = module.exports = function(config) {
if (!(this instanceof Client)) {
return new Client(config);
}
config = config || {}
config.headers = config.headers || {};
this.config = config;
this.debug = Util.isTrue(config.debug);
this.Promise = config.Promise || config.promise || Promise;
this.routes = JSON.parse(fs.readFileSync(__dirname + "/routes.json", "utf8"));
var pathPrefix = "";
// Check if a prefix is passed in the config and strip any leading or trailing slashes from it.
if (typeof config.pathPrefix == "string") {
pathPrefix = "/" + config.pathPrefix.replace(/(^[\/]+|[\/]+$)/g, "");
this.config.pathPrefix = pathPrefix;
}
// store mapping of accept header to preview api endpoints
var mediaHash = this.routes.defines.acceptTree;
var mediaTypes = {};
for (var accept in mediaHash) {
for (var route in mediaHash[accept]) {
mediaTypes[mediaHash[accept][route]] = accept;
}
}
this.acceptUrls = mediaTypes;
this.setupRoutes();
};
(function() {
/**
* Client#setupRoutes() -> null
*
* Configures the routes as defined in routes.json.
*
* [[Client#setupRoutes]] is invoked by the constructor, takes the
* contents of the JSON document that contains the definitions of all the
* available API routes and iterates over them.
*
* It first recurses through each definition block until it reaches an API
* endpoint. It knows that an endpoint is found when the `url` and `param`
* definitions are found as a direct member of a definition block.
* Then the availability of an implementation by the API is checked; if it's
* not present, this means that a portion of the API as defined in the routes.json
* file is not implemented properly, thus an exception is thrown.
* After this check, a method is attached to the [[Client]] instance
* and becomes available for use. Inside this method, the parameter validation
* and typecasting is done, according to the definition of the parameters in
* the `params` block, upon invocation.
*
* This mechanism ensures that the handlers ALWAYS receive normalized data
* that is of the correct format and type. JSON parameters are parsed, Strings
* are trimmed, Numbers and Floats are casted and checked for NaN after that.
*
* Note: Query escaping for usage with SQL products is something that can be
* implemented additionally by adding an additional parameter type.
**/
this.setupRoutes = function() {
var self = this;
var routes = this.routes;
var defines = routes.defines;
this.constants = defines.constants;
this.requestHeaders = defines["request-headers"].map(function(header) {
return header.toLowerCase();
});
this.responseHeaders = defines["response-headers"].map(function(header) {
return header.toLowerCase();
});
delete routes.defines;
function trim(s) {
if (typeof s != "string") {
return s;
}
return s.replace(/^[\s\t\r\n]+/, "").replace(/[\s\t\r\n]+$/, "");
}
function parseParams(msg, paramsStruct) {
var params = Object.keys(paramsStruct);
var paramName, def, value, type;
for (var i = 0, l = params.length; i < l; ++i) {
paramName = params[i];
if (paramName.charAt(0) == "$") {
paramName = paramName.substr(1);
if (!defines.params[paramName]) {
throw new error.BadRequest("Invalid variable parameter name substitution; param '" +
paramName + "' not found in defines block", "fatal");
} else {
def = paramsStruct[paramName] = defines.params[paramName];
delete paramsStruct["$" + paramName];
}
} else {
def = paramsStruct[paramName];
}
value = msg[paramName];
if (typeof value != "boolean" && !value) {
// we don't need validation for undefined parameter values
// that are not required.
if (!def.required ||
(def["allow-empty"] && value === "") ||
(def["allow-null"] && value === null)) {
continue;
}
throw new error.BadRequest("Empty value for parameter '" +
paramName + "': " + value);
}
// validate the value and type of parameter:
if (def.validation) {
if (!new RegExp(def.validation).test(value)) {
throw new error.BadRequest("Invalid value for parameter '" +
paramName + "': " + value);
}
}
if (def.type) {
type = def.type.toLowerCase();
if (type == "number") {
value = parseInt(value, 10);
if (isNaN(value)) {
throw new error.BadRequest("Invalid value for parameter '" +
paramName + "': " + msg[paramName] + " is NaN");
}
} else if (type == "float") {
value = parseFloat(value);
if (isNaN(value)) {
throw new error.BadRequest("Invalid value for parameter '" +
paramName + "': " + msg[paramName] + " is NaN");
}
} else if (type == "json") {
if (typeof value == "string") {
try {
value = JSON.parse(value);
} catch(ex) {
throw new error.BadRequest("JSON parse error of value for parameter '" +
paramName + "': " + value);
}
}
} else if (type == "date") {
value = new Date(value);
}
}
msg[paramName] = value;
}
}
function prepareApi(struct, baseType) {
if (!baseType) {
baseType = "";
}
Object.keys(struct).forEach(function(routePart) {
var block = struct[routePart];
if (!block) {
return;
}
var messageType = baseType + "/" + routePart;
if (block.url && block.params) {
// we ended up at an API definition part!
var endPoint = messageType.replace(/^[\/]+/g, "");
var parts = messageType.split("/");
var section = Util.toCamelCase(parts[1].toLowerCase());
parts.splice(0, 2);
var funcName = Util.toCamelCase(parts.join("-"));
if (!self[section]) {
self[section] = {};
// add a utility function 'getFooApi()', which returns the
// section to which functions are attached.
self[Util.toCamelCase("get-" + section + "-api")] = function() {
return self[section];
};
}
self[section][funcName] = function(msg, callback) {
try {
parseParams(msg, block.params);
} catch (ex) {
// when the message was sent to the client, we can
// reply with the error directly.
self.sendError(ex, block, msg, callback);
if (self.debug) {
Util.log(ex.message, "fatal");
}
if (self.Promise && typeof callback !== 'function') {
return self.Promise.reject(ex)
}
// on error, there's no need to continue.
return;
}
if (!callback) {
if (self.Promise) {
return new self.Promise(function(resolve,reject) {
var cb = function(err, obj) {
if (err) {
reject(err);
} else {
resolve(obj);
}
};
self.handler(msg, JSON.parse(JSON.stringify(block)), cb);
});
} else {
throw new Error('neither a callback or global promise implementation was provided');
}
} else {
self.handler(msg, JSON.parse(JSON.stringify(block)), callback);
}
};
} else {
// recurse into this block next:
prepareApi(block, messageType);
}
});
}
prepareApi(routes);
};
/**
* Client#authenticate(options) -> null
* - options (Object): Object containing the authentication type and credentials
* - type (String): One of the following: `basic`, `oauth`, `token`, or `integration`
* - username (String): Github username
* - password (String): Password to your account
* - token (String): oauth/jwt token
*
* Set an authentication method to have access to protected resources.
*
* ##### Example
*
* // basic
* github.authenticate({
* type: "basic",
* username: "mikedeboertest",
* password: "test1324"
* });
*
* // oauth
* github.authenticate({
* type: "oauth",
* token: "e5a4a27487c26e571892846366de023349321a73"
* });
*
* // oauth key/secret
* github.authenticate({
* type: "oauth",
* key: "clientID",
* secret: "clientSecret"
* });
*
* // user token
* github.authenticate({
* type: "token",
* token: "userToken",
* });
*
* // integration (jwt)
* github.authenticate({
* type: "integration",
* token: "jwt",
* });
**/
this.authenticate = function(options) {
if (!options) {
this.auth = false;
return;
}
if (!options.type || "basic|oauth|client|token|integration|netrc".indexOf(options.type) === -1) {
throw new Error("Invalid authentication type, must be 'basic', 'integration', 'oauth', 'client' or 'netrc'");
}
if (options.type == "basic" && (!options.username || !options.password)) {
throw new Error("Basic authentication requires both a username and password to be set");
}
if (options.type == "oauth") {
if (!options.token && !(options.key && options.secret)) {
throw new Error("OAuth2 authentication requires a token or key & secret to be set");
}
}
if ((options.type == "token" || options.type == "integration") && !options.token) {
throw new Error("Token authentication requires a token to be set");
}
this.auth = options;
};
function getPageLinks(link) {
if (typeof link == "object" && (link.link || link.meta.link)) {
link = link.link || link.meta.link;
}
var links = {};
if (typeof link != "string") {
return links;
}
// link format:
// '<https://api.github.com/users/aseemk/followers?page=2>; rel="next", <https://api.github.com/users/aseemk/followers?page=2>; rel="last"'
link.replace(/<([^>]*)>;\s*rel="([\w]*)\"/g, function(m, uri, type) {
links[type] = uri;
});
return links;
}
/**
* Client#hasNextPage(link) -> null
* - link (mixed): response of a request or the contents of the Link header
*
* Check if a request result contains a link to the next page
**/
this.hasNextPage = function(link) {
return getPageLinks(link).next;
};
/**
* Client#hasPreviousPage(link) -> null
* - link (mixed): response of a request or the contents of the Link header
*
* Check if a request result contains a link to the previous page
**/
this.hasPreviousPage = function(link) {
return getPageLinks(link).prev;
};
/**
* Client#hasLastPage(link) -> null
* - link (mixed): response of a request or the contents of the Link header
*
* Check if a request result contains a link to the last page
**/
this.hasLastPage = function(link) {
return getPageLinks(link).last;
};
/**
* Client#hasFirstPage(link) -> null
* - link (mixed): response of a request or the contents of the Link header
*
* Check if a request result contains a link to the first page
**/
this.hasFirstPage = function(link) {
return getPageLinks(link).first;
};
function getPage(link, which, headers, callback) {
var self = this;
var url = getPageLinks(link)[which];
if (!url) {
var urlErr = new error.NotFound("No " + which + " page found");
return self.Promise && !callback ? self.Promise.reject(urlErr) : callback(urlErr);
}
var parsedUrl = Url.parse(url, true);
var msg = Object.create(parsedUrl.query);
if (headers != null) {
msg.headers = headers;
}
var block = {
url: parsedUrl.pathname,
method: "GET",
params: parsedUrl.query
};
if (!callback) {
if (self.Promise) {
return new self.Promise(function(resolve,reject) {
var cb = function(err, obj) {
if (err) {
reject(err);
} else {
resolve(obj);
}
};
self.handler(msg, JSON.parse(JSON.stringify(block)), cb);
});
} else {
throw new Error('neither a callback or global promise implementation was provided');
}
} else {
self.handler(msg, JSON.parse(JSON.stringify(block)), callback);
}
}
/**
* Client#getNextPage(link, callback) -> null
* - link (mixed): response of a request or the contents of the Link header
* - headers (Object): Optional. Key/ value pair of request headers to pass along with the HTTP request.
* - callback (Function): function to call when the request is finished with an error as first argument and result data as second argument.
*
* Get the next page, based on the contents of the `Link` header
**/
this.getNextPage = function(link, headers, callback) {
if (typeof headers == 'function') {
callback = headers;
headers = null;
}
headers = applyAcceptHeader(link, headers);
return getPage.call(this, link, "next", headers, callback);
};
/**
* Client#getPreviousPage(link, callback) -> null
* - link (mixed): response of a request or the contents of the Link header
* - headers (Object): Optional. Key/ value pair of request headers to pass along with the HTTP request.
* - callback (Function): function to call when the request is finished with an error as first argument and result data as second argument.
*
* Get the previous page, based on the contents of the `Link` header
**/
this.getPreviousPage = function(link, headers, callback) {
if (typeof headers == 'function') {
callback = headers;
headers = null;
}
headers = applyAcceptHeader(link, headers);
return getPage.call(this, link, "prev", headers, callback);
};
/**
* Client#getLastPage(link, callback) -> null
* - link (mixed): response of a request or the contents of the Link header
* - headers (Object): Optional. Key/ value pair of request headers to pass along with the HTTP request.
* - callback (Function): function to call when the request is finished with an error as first argument and result data as second argument.
*
* Get the last page, based on the contents of the `Link` header
**/
this.getLastPage = function(link, headers, callback) {
if (typeof headers == 'function') {
callback = headers;
headers = null;
}
headers = applyAcceptHeader(link, headers);
return getPage.call(this, link, "last", headers, callback);
};
/**
* Client#getFirstPage(link, callback) -> null
* - link (mixed): response of a request or the contents of the Link header
* - headers (Object): Optional. Key/ value pair of request headers to pass along with the HTTP request.
* - callback (Function): function to call when the request is finished with an error as first argument and result data as second argument.
*
* Get the first page, based on the contents of the `Link` header
**/
this.getFirstPage = function(link, headers, callback) {
if (typeof headers == 'function') {
callback = headers;
headers = null;
}
headers = applyAcceptHeader(link, headers);
return getPage.call(this, link, "first", headers, callback);
};
function applyAcceptHeader(res, headers) {
var previous = res.meta && res.meta['x-github-media-type'];
if (!previous || (headers && headers.accept)) {
return headers;
}
headers = headers || {};
headers.accept = 'application/vnd.' + previous.replace('; format=', '+');
return headers;
}
function getRequestFormat(hasBody, block) {
if (hasBody) {
return block.requestFormat || this.constants.requestFormat;
}
return "query";
}
function getQueryAndUrl(msg, def, format, config) {
var url = def.url;
if (config.pathPrefix && url.indexOf(config.pathPrefix) !== 0) {
url = config.pathPrefix + def.url;
}
var ret = {
query: format == "json" ? {} : format == "raw" ? msg.data : []
};
if (!def || !def.params) {
ret.url = url;
return ret;
}
Object.keys(def.params).forEach(function(paramName) {
paramName = paramName.replace(/^[$]+/, "");
if (!(paramName in msg)) {
return;
}
var isUrlParam = url.indexOf(":" + paramName) !== -1;
var valFormat = isUrlParam || format != "json" ? "query" : format;
var val;
if (valFormat != "json") {
if (typeof msg[paramName] == "object") {
try {
msg[paramName] = JSON.stringify(msg[paramName]);
val = encodeURIComponent(msg[paramName]);
} catch (ex) {
return Util.log("httpSend: Error while converting object to JSON: "
+ (ex.message || ex), "error");
}
} else if (def.params[paramName] && def.params[paramName].combined) {
// Check if this is a combined (search) string.
val = msg[paramName].split(/[\s\t\r\n]*\+[\s\t\r\n]*/)
.map(function(part) {
return encodeURIComponent(part);
})
.join("+");
} else {
// the ref param is a path so we don't want to [fully] encode it but we do want to encode the # if there is one
// (see https://github.com/mikedeboer/node-github/issues/499#issuecomment-280093040)
if (paramName === 'ref') {
val = msg[paramName].replace(/#/g, '%23');
} else {
val = encodeURIComponent(msg[paramName]);
}
}
} else {
val = msg[paramName];
}
if (isUrlParam) {
url = url.replace(":" + paramName, val);
} else {
if (format == "json" && def.params[paramName].sendValueAsBody) {
ret.query = val;
} else if (format == "json") {
ret.query[paramName] = val;
} else if (format != "raw") {
ret.query.push(paramName + "=" + val);
}
}
});
ret.url = url;
return ret;
}
/**
* Client#httpSend(msg, block, callback) -> null
* - msg (Object): parameters to send as the request body
* - block (Object): parameter definition from the `routes.json` file that
* contains validation rules
* - callback (Function): function to be called when the request returns.
* If the the request returns with an error, the error is passed to
* the callback as its first argument (NodeJS-style).
*
* Send an HTTP request to the server and pass the result to a callback.
**/
this.httpSend = function(msg, block, callback) {
var self = this;
var method = block.method.toLowerCase();
var hasFileBody = block.hasFileBody;
var hasBody = !hasFileBody && (typeof(msg.body) !== "undefined" || "head|get|delete".indexOf(method) === -1);
var format = getRequestFormat.call(this, hasBody, block);
var protocol = this.config.protocol || this.constants.protocol || "http";
var port = this.config.port || (protocol == "https" ? 443 : 80);
var host = block.host || this.config.host || this.constants.host;
// Edge case for github enterprise uploadAsset:
// 1) In public api, host changes to uploads.github.com. In enterprise, the host remains the same.
// 2) In enterprise, the pathPrefix changes from: /api/v3 to /api/uploads.
if ((this.config.host && this.config.host !== this.constants.host)
&& (block.host && block.host === "uploads.github.com")) { // enterprise uploadAsset
host = this.config.host;
this.config.pathPrefix = "/api/uploads";
}
var obj = getQueryAndUrl(msg, block, format, self.config);
var query = obj.query;
var url = this.config.url ? this.config.url + obj.url : obj.url;
var path = url;
if (!hasBody && query.length) {
path += "?" + query.join("&");
}
var proxyUrl;
var agent = undefined;
if (this.config.proxy !== undefined) {
proxyUrl = this.config.proxy;
} else {
proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
}
if (proxyUrl) {
agent = new HttpsProxyAgent(proxyUrl);
}
var ca = this.config.ca;
var headers = {
"host": host,
"content-length": "0"
};
if (hasBody) {
if (format == "json") {
query = JSON.stringify(query);
} else if (format != "raw") {
query = query.join("&");
}
headers["content-length"] = Buffer.byteLength(query, "utf8");
headers["content-type"] = format == "json"
? "application/json; charset=utf-8"
: format == "raw"
? "text/plain; charset=utf-8"
: "application/x-www-form-urlencoded; charset=utf-8";
}
if (this.auth) {
var basic;
switch (this.auth.type) {
case "oauth":
if (this.auth.token) {
path += (path.indexOf("?") === -1 ? "?" : "&") +
"access_token=" + encodeURIComponent(this.auth.token);
} else {
path += (path.indexOf("?") === -1 ? "?" : "&") +
"client_id=" + encodeURIComponent(this.auth.key) +
"&client_secret=" + encodeURIComponent(this.auth.secret);
}
break;
case "token":
headers["Authorization"] = "token " + this.auth.token;
break;
case "integration":
headers["Authorization"] = "Bearer " + this.auth.token;
headers["accept"] = "application/vnd.github.machine-man-preview+json"
break;
case "basic":
basic = new Buffer(this.auth.username + ":" + this.auth.password, "ascii").toString("base64");
headers["Authorization"] = "Basic " + basic;
break;
case "netrc":
var auth = netrc()[host];
if (!auth) {
throw new Error("~/.netrc authentication type chosen but no credentials found for '" + host + "'");
}
basic = new Buffer(auth.login + ":" + auth.password, "ascii").toString("base64");
headers["Authorization"] = "Basic " + basic;
default:
break;
}
}
function callCallback(err, result) {
if (callback) {
var cb = callback;
callback = undefined;
cb(err, result);
}
}
function addCustomHeaders(customHeaders) {
Object.keys(customHeaders).forEach(function(header) {
var headerLC = header.toLowerCase();
if (self.requestHeaders.indexOf(headerLC) == -1) {
return;
}
headers[headerLC] = customHeaders[header];
});
}
addCustomHeaders(Util.extend(msg.headers || {}, this.config.headers));
if (!headers["user-agent"]) {
headers["user-agent"] = "NodeJS HTTP Client";
}
if (!("accept" in headers)) {
headers["accept"] = this.acceptUrls[block.url] || this.config.requestMedia || this.constants.requestMedia;
}
var options = {
host: host,
port: port,
path: path,
method: method,
headers: headers,
ca: ca,
family: this.config.family
};
if (agent) {
options.agent = agent;
}
if (this.config.rejectUnauthorized !== undefined) {
options.rejectUnauthorized = this.config.rejectUnauthorized;
}
if (this.debug) {
console.log("REQUEST: ", options);
}
function httpSendRequest() {
var reqModule = self.config.followRedirects === false ? protocol : 'follow-redirects/' + protocol;
var req = require(reqModule).request(options, function(res) {
if (self.debug) {
console.log("STATUS: " + res.statusCode);
console.log("HEADERS: " + JSON.stringify(res.headers));
}
res.setEncoding("utf8");
var data = "";
res.on("data", function(chunk) {
data += chunk;
});
res.on("error", function(err) {
callCallback(err);
});
res.on("end", function() {
if (res.statusCode >= 400 && res.statusCode < 600 || res.statusCode < 10) {
callCallback(new error.HttpError(data, res.statusCode, res.headers));
} else {
res.data = data;
callCallback(null, res);
}
});
});
var timeout = (block.timeout !== undefined) ? block.timeout : self.config.timeout;
if (timeout) {
req.setTimeout(timeout);
}
req.on("error", function(e) {
if (self.debug) {
console.log("problem with request: " + e.message);
}
callCallback(e.message);
});
req.on("timeout", function() {
if (self.debug) {
console.log("problem with request: timed out");
}
req.abort();
callCallback(new error.GatewayTimeout());
});
// write data to request body
if (hasBody && query.length) {
if (self.debug) {
console.log("REQUEST BODY: " + query + "\n");
}
req.write(query + "\n");
}
if (block.hasFileBody) {
var stream = fs.createReadStream(msg.filePath);
stream.pipe(req);
} else {
req.end();
}
};
if (hasFileBody) {
fs.stat(msg.filePath, function(err, stat) {
if (err) {
callCallback(err);
} else {
headers["content-length"] = stat.size;
headers["content-type"] = mime.lookup(msg.name);
httpSendRequest();
}
});
} else {
httpSendRequest();
}
};
this.sendError = function(err, block, msg, callback) {
if (this.debug) {
Util.log(err, block, msg, "error");
}
if (typeof err == "string") {
err = new error.InternalServerError(err);
}
if (callback && typeof(callback) === "function") {
callback(err);
}
};
this.handler = function(msg, block, callback) {
var self = this;
this.httpSend(msg, block, function(err, res) {
if (err) {
return self.sendError(err, msg, null, callback);
}
var ret;
try {
var data = res.data;
var contentType = res.headers["content-type"];
if (contentType && contentType.indexOf("application/json") !== -1) {
data = res.data && JSON.parse(res.data);
}
ret = {data: data};
} catch (ex) {
if (callback) {
callback(new error.InternalServerError(ex.message), res);
}
return;
}
if (!ret) {
ret = {};
}
ret.meta = {};
self.responseHeaders.forEach(function(header) {
if (res.headers[header]) {
ret.meta[header] = res.headers[header];
}
});
if (callback) {
callback(null, ret);
}
});
}
}).call(Client.prototype);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment