bungie-net-api
Version:
A zero dependency library for the Bungie.net api
486 lines (431 loc) • 15.6 kB
JavaScript
/** @module MicroLibrary */
const Fs = require( 'fs' );
const EventEmitter = require( 'events' );
const QueryString = require( 'querystring' );
const Https = require( 'https' );
const Path = require( 'path' );
/** The root directory for the project that this library will be used in */
const projectRoot = Path.dirname( require.main.filename || process.mainModule.filename );
/** Contains the parsed contents of the package.json file */
const Info = {};//JSON.parse( Fs.readFileSync( projectRoot + '/../package.json' ) );
class Request {
/**
* Makes HTTP requests
* @constructor
* @params( ApiCreds ) = Your API credentials;
*/
constructor( ApiCreds ){
this.ApiCreds = ApiCreds;
}
/**
* Makes an HTTP GET request to the specified uri
* @param { string } uri - The URI to perform the GET request on
* @param { oAuth } [oAuth=false] - The oAuth credentials
* @returns { Promise }
*/
get( uri, oAuth = false ){
return new Promise( ( resolve, reject ) => {
let headers = {
"X-API-KEY": this.ApiCreds.key,
"User-Agent": this.ApiCreds.userAgent
}
// Send the appropriate authorization header
headers["Authorization"] = ( typeof oAuth == 'object' ) ?
"Bearer " + oAuth.access_token : // Use the oAuth token to
"Basic " + new Buffer.from( this.ApiCreds.clientId + ":" + this.ApiCreds.clientSecret ).toString( 'base64' );
Https.get( uri, { headers }, Response => {
let data = '';
// How long of a result should we expect?
let len = parseInt( Response.headers['content-length'], 10 ) ;
let size = this._convertBytes( len );
let progress = 0;
// A chunk of data has been received.
Response.on('data', (chunk) => {
data += chunk;
progress += chunk.length;
});
// The whole response has been received.
Response.on('end', () => {
// If the request was successful resolve the promise, otherwise reject it.
if( Response.headers[ 'content-type' ].substring( 0, 16 ) === 'application/json' ){
data = JSON.parse( data );
if( typeof data.ErrorCode !== 'undefined' && data.ErrorCode !== 1 )
reject( JSON.stringify( data ) );
else
resolve( data );
} else {
reject( data );
}
} );
} );
} );
}
/**
* Performs an HTTPS post request
* @param { string } uri - The full path you want to post to
* @param { object } PostData - An object containing the keys/value pairs you want to post to the server
* @param { oAuth } [oAuth=false] - The oAuth object retrieved from module:OAuth~OAuth.requestAccessToken
* @returns { Promise }
*/
async post( uri, PostData, oAuth = false ){
return new Promise( ( resolve, reject ) => {
// Parse the host and endpoint from the uri
let host = uri.substring( uri.indexOf('//') + 2 , uri.indexOf('.net/') + 4 );
let path = uri.substring( uri.indexOf('.net') + 4, uri.length );
var data = '';
let headers = {
"X-API-KEY" : this.ApiCreds.key,
"User-Agent" : this.ApiCreds.userAgent
}
if( typeof PostData === 'object' ){
PostData = JSON.stringify( PostData );
headers["Content-Type"] = "application/json"
} else {
headers["Content-Type"] = "application/x-www-form-urlencoded"
}
headers["Content-Length"] = PostData.length;
// Send the appropriate authorization header
headers["Authorization"] = ( typeof oAuth == 'object' ) ?
"Bearer " + oAuth.access_token : // Use the oAuth token to
"Basic " + new Buffer.from( this.ApiCreds.clientId + ":" + this.ApiCreds.clientSecret ).toString( 'base64' );
let request = Https.request( {
// Request options
host : host,
path : path,
port : 443,
method: "POST",
headers: headers
// Capture the response
}, Response => {
Response.on( 'data', chunk => {
data += chunk;
} );
Response.on( 'end', () => {
// If the request was successful resolve the promise, otherwise reject it.
if( Response.headers[ 'content-type' ].substring( 0, 16 ) === 'application/json' ){
data = JSON.parse( data );
if( typeof data.ErrorCode !== 'undefined' && data.ErrorCode !== 1 )
reject( JSON.stringify( data ) );
else
resolve( data );
} else {
reject( data );
}
} );
} );
// Post the data
request.write( PostData );
// End the request
request.end();
} );
}
// Converts bytes to larger units
_convertBytes( bytes, unit ){
let Conversions = {
B : bytes, // Bytes
KB : bytes / 1024, // KiloBytes
MB : bytes / 1048576, // MegaBytes
GB : bytes / 1073741824 // GigaBytes
};
let keys = Object.keys( Conversions );
if( typeof unit === 'string' )
return Conversions[ unit.toUpperCase() ];
else{
// If they didn't give a sane unit type, return the largest whole unit
let i = 0;
for( i; Conversions[ keys [ i ] ] >= 1.0 ; i++ ){}
return Conversions[ keys[ i - 1 ] ] + " " + keys[ i - 1 ];
}
}
};
/**
* Generates a generic User-Agent header
* @param { Object } Options - The data required to generate a Bungie.net Compatible API string
* @param { string } Options.name - The name of your project
* @param { string } Options.version - The version
* @returns { string } A user-agent string in the form of “AppName/Version AppId/appIdNum (+webUrl;contactEmail)”
*@see {@link https://github.com/Bungie-net/api#are-there-any-restrictions-on-the-api|Restrictions} for more information.
*/
function generateUserAgent( ApiCreds ){
if( typeof Info.homepage !== 'undefined ' ){
var website = Info.homepage;
} else if ( typeof Info.repository.url !== 'undefined' ){
var website = Info.repository.url;
} else {
var website = "N/A";
}
let email = ( typeof Info.author ==='undefined' || typeof Info.author.email === 'undefined' ) ? 'N/A' : Info.author.email;
return `${Info.name}/${Info.version} AppId/${ApiCreds.clientId} (+${website};${email})`;
}
class TypeError extends Error{
/**
* @constructor
* @extends Error
* @param { Object } TypeError - Contains the information about the TypeError
* @property { string } varName - The name of the variable that filed the type check
* @property { var } variable - The variable that failed the type check
* @property { string } expected - The expected data type
* @example
* throw new TypeError({
* varName: "Options.search",
* variable: Options.search,
* expected: "number-like"
* });
*
* @example
* { TypeError: TypeError: Options.search expected to be number-like; Got string
* at new TypeError ...
* name: 'TypeError',
* varName: 'Options.search',
* expected: 'number-like',
* failedValue: 'THIS_IS_NOT_A_NUM' }
*/
constructor( TypeError ){
super( "TypeError: " + TypeError.varName + " expected to be " + TypeError.expected + "; Got " + typeof TypeError.variable );
this.name = this.constructor.name;
this.varName = TypeError.varName;
this.expected = TypeError.expected;
this.failedValue = TypeError.variable;
Error.captureStackTrace( this, TypeError );
}
}
class MicroLibLoadError extends Error{
/**
* @constructor
* @extends Error
* @param { Object } MicroLibError - Contains the information about the module that failed
* @property { string } message - A description of the error that occurred
* @property { string } reason - The reason that the module failed to load
* @property { MicroLibDefinition } Module - An object containing all of the module information
* @example
* throw new MicroLibLoadError({
* message: "The micro-library testLib failed to load",
* reason: "The main file for this module could not be found",
* Module: {
* name: "testLib",
* wrapperKey: "Test",
* main: "doesNotExist.js",
* path: "/lib/testLib"
* }
* });
*/
constructor( MicroLibError ){
super( MicroLibError.message );
this.name = this.constructor.name;
Object.keys( MicroLibError ).forEach( key => {
this[key] = MicroLibError[key];
});
Error.captureStackTrace( this, MicroLibLoadError );
}
}
class EnumError extends Error{
/**
* @constructor
* @extends Error
* @param { Object } EnumError - Contains the information about the failed enumeration lookup
* @property { string } Table - The table in which the key does not exist
* @property { string } key - The key that failed the lookup
* @example
* throw new EnumError({
* key: "this_key_does_not_exist",
* Table: Forum.Enums.topicsQuickDate
* });
*
* @example
* { EnumError: this_key_does_not_exist is not a valid quick date
* at ...
* name: 'EnumError',
* key: 'this_key_does_not_exist',
* lookupTable:
* { '0': 'ALL',
* '1': 'LASTYEAR',
* '2': 'LASTMONTH',
* '3': 'LASTWEEK',
* '4': 'LASTDAY',
* description: 'quick date',
* ALL: 0,
* LASTYEAR: 1,
* LASTMONTH: 2,
* LASTWEEK: 3,
* LASTDAY: 4 } }
*
*/
constructor( EnumError ){
super( EnumError.key + " is not a valid " + EnumError.Table.description );
this.name = this.constructor.name;
this.key = EnumError.key;
this.lookupTable = EnumError.Table
Error.captureStackTrace( this, EnumError );
}
}
/**
* Checks if the first parameter `key` is a key in the Enum object `Table`
* @function
* @param { Object } key - The key to look up
* @param { Object } Table - The Enum Table in which to look for the key
* @returns { Promise } - Resolves with the enumerated value, rejects with an {@link module:EnumError~EnumError|EnumError}
*/
async function enumLookup( key, Table ){
return new Promise( ( resolve, reject ) => {
// convert string keys to uppercase
key = ( typeof key === "string" ) ? key.toUpperCase() : key;
let typeOf = typeof Table[ key ];
if( ( typeOf !== 'number' && typeOf !== 'string' ) ){
reject( new EnumError( {
key : key,
Table : Table
} ) );
} else {
if( typeOf == 'string' ){
resolve ( Table[key] );
} else {
resolve ( key );
}
}
} );
}
/**
* URI enpoints are saved directly from Bungie with the placeholders in tact. The placeholders in the path each represent
* an API parameter.
* @function
* @param { string } uri - The URI string to be rendered
* @param { Object } PathParams - The PathParams required to renderEndpoint this URI
* @param { Object } QueryStrings - An object containing all of the query strings to be added to the end of the URI
* @returns { Promise }
* @example
* // Function is thenable
*
* let endpoint = "https://www.Bungie.net/some/path/{placeholder_1}/{second_placeholder}/{foo}";
* renderEndpoint( endpoint,
* { // PathParams - These go directly in the URI
* placeholder_1 : 'HELLO',
* second_placeholder: 'WORLD',
* foo: 'bar'
* },
* { // QueryStrings - These are appeneded on to the end of URI and are used for HTTP GET requests
* "key": "value",
* "second" : 2
* }).then( uri => {
* console.log( uri );
* });
*
* @example
* // Async/await
* async function test(){
* let endpoint = "https://www.Bungie.net/some/path/{placeholder_1}/{second_placeholder}/{foo}";
* let uri = await renderEndpoint( endpoint,
* { // These replace the placeholders present in the URI path
* placeholder_1 : 'HELLO',
* second_placeholder: 'WORLD',
* foo: 'bar'
* },
* { // These are queryStrings
* "key": "value",
* "second" : 2
* });
* console.log( uri );
* }
*
* @example { Output }
* // Both examples above produce this output
* "https://www.Bungie.net/som/path/HELLO/WORLD/bar?key=value&second=2"
*/
async function renderEndpoint( uri, PathParams = {}, QueryStrings = null ){
return new Promise( ( resolve, reject) => {
if( typeof PathParams !== 'object' )
reject( "You did not provide an Object containing your key/value pairs");
if( typeof uri !== 'string')
reject("The endpoint uri \"" + uri + "\" is not valid");
var rendered = uri;
let search = '';
// Render the path params
Object.keys( PathParams ).forEach( key => {
// Don't render any undefined values
if( typeof PathParams[key] == 'undefined' || PathParams[key] === "" )
return;
search = RegExp('{' + key + '}', 'g');
rendered = rendered.replace( search, PathParams[key] );
});
// If there are still placeholders in the URI, reject the promise.
let missingParams = rendered.match( /{\s*[\w\.]+\s*}/g );
if( missingParams !== null ){
// Pull values out of the braces
missingParams = missingParams.map( x => { return x.match( /[\w\.]+/ )[ 0 ] } );
reject( {
message: "Parameters were missing from the request",
missingParams: missingParams
} );
}
// Add any query strings we were sent
if( typeof QueryStrings == 'object' )
rendered += "?" + QueryString.stringify( QueryStrings );
resolve( encodeURI( rendered ) );
});
};
class ApiError extends Error{
/**
* @constructor
* @extends Error
* @param { Object } bNetError - The parsed bungee.net API error message.
* @property { number } ErrorCode - The error code of the Bungie.net error
* @property { number } ThrottleSeconds - The number of seconds that your API key was throttled
* @property { string } ErrorStatus - A string containing the status of the API call.
* @property { string } Message - A message from the gods themselves.
*/
constructor( bNetError ){
super( bNetError.Message );
this.name = this.constructor.name;
Object.keys( bNetError ).forEach( key => {
this[key] = bNetError[key];
});
Error.captureStackTrace( this, ApiError );
}
}
/**
* Generates a universally unique identifier. I copied it from the Internet. Don't fuck with it.
* @see {@link http://slavik.meltser.info/the-efficient-way-to-create-guid-uuid-in-javascript-with-explanation/|source} for more information
* @returns { string } - A universally unique identification string
*/
function uuid(){
console.log( "illuminati ◬ confirmed" );
function _p8(s) {
var p = (Math.random().toString(16)+"000000000").substr(2,8);
return s ? "-" + p.substr(0,4) + "-" + p.substr(4,4) : p ;
}
return _p8() + _p8(true) + _p8(true) + _p8();
}
/**
* SYNCHRONOUSLY Reverse maps an object to make enumeration lookup easier.
* @param ( Object ) Obj - Any simple object
* @returns { enum }
*/
function mapEnumSync( Obj ){
let rtrn = Obj;
Object.keys( Obj ).forEach( key => {
rtrn[ Obj[ key ] ] = key;
});
return rtrn;
}
/**
* Return the value of the first parameter unless the first parameter is undefiend, then returns null
* @param { arbitrary } val - The value whos default value is null
* @returns { arbitrary } - Returns null if val is undefined, returns the value of val otherwise
*/
function nullable( val ){
return ( typeof val === 'undefined' ) ? null : val;
}
module.exports = {
TypeError,
ApiError,
MicroLibLoadError,
renderEndpoint,
enumLookup,
EnumError,
generateUserAgent,
libInfo : Info,
Request,
mapEnumSync,
nullable,
projectRoot
};