import {Enforce} from '@classroomtechtools/enforce_arguments';
import {OAuth2} from './lib/Oauth2.js';
/**
* Interacting with APIs, made a cinch
* @author Adam Morris https://classroomtechtools.com classroomtechtools.ctt@gmail.com
* @lastmodified 4 May 2020
* @version 0.8 Adam Morris: Changed name to Endponts, restructured for jsdoc
* @version 0.6 Adam Morris: First draft with oauth fix
* @library MsIomH3IL48mShjNoiUoRiq8b30WIDiE_
*/
class Batch {
/**
* An object that represents a collection of requests that will be asynchronously retrieved
* @class
* @example
* const batch = Batch();
* batch.add(request); // Request
* const responses = bacth.fetchAll();
* @return {Batch}
*/
constructor () {
this.queue = [];
}
/**
* Add request to batch queue
* @param {Request} request - An Endpoints.Request object
*/
add ({request}={}) {
Enforce.named(arguments, {request: EndpointsLib.Request}, 'Batch#add');
const [_, obj] = request.url_params({embedUrl: true});
this.queue.push(obj);
}
/**
* Use UrlFetchApp to reach out to the internet. Returns Response objects in same order as requests
* Response objects also have #request
* @example Make list of response jsons
* batch.fetchAll().map(response => response.json);
* @return {Response[]}
* @example Make list of original request urls
* batch.fetchAll().map(response => response.request.url);
*/
fetchAll () {
return UrlFetchApp.fetchAll(this.queue).map( (response, idx) => {
// NOTE: requestObject is just a regular object
const requestObject = this.queue[idx];
return new Response({response, requestObject});
});
}
}
/**
* DiscoveryCache - Used internally
*/
class DiscoveryCache {
constructor () {
this.cache = CacheService.getScriptCache();
}
getUrl ({name, version, resource, method}={}) {
const {EndpointsLib} = Import;
const key = `${name}${version}${resource}${method}`;
let data = this.cache.get(key);
let ret = null;
if (data) {
return data;
}
data = this.getEndpoint(name, version).json;
if (data.error) {
throw new Error(`No "${name}" with version "${version}" found. Perhaps spelling is wrong?`);
}
if (resource.indexOf('.') === -1) {
// straight forward
if (!data.resources[resource]) {
throw new Error(`No resource "${resource}" found in ${name}${version}`);
}
if (!data.resources[resource].methods[method]) {
throw new Error(`No method "${method}" found in resource "${resource}" of "${name}${version}", only: ${Object.keys(data.resources[resource].methods)} available`);
}
ret = data.baseUrl + data.resources[resource].methods[method].path;
} else {
// resources can be dot-noted in order to resolve a path, e.g. sheets.spreadsheets.values, sheets.spreadsheets.developerMetadata
let resources = data;
resource.split('.').forEach(function (res) {
resources = resources.resources[res];
});
ret = data.baseUrl + resources.methods[method].path;
}
this.cache.put(key, ret, 21600); // max is 6 hours
return ret;
}
getEndpoint(name, version) {
return new EndpointsLib.EndpointsBase().httpget({url: `https://www.googleapis.com/discovery/v1/apis/${name}/${version}/rest`}).fetch();
}
}
/**
* Class that fills in EndpointsBase.utils namespace
* provides utility methods used throughout the library, can be exported
*/
class Utils {
validateDiscovery ({name=null, version=null, resource=null, method=null}={}) {
return name && version && resource && method;
}
/**
* Like js template literal, replace '${here}' with {here: 'there'} // "there"
* Usage: interpolate("${greet}, ${noun}", {greet: 'hello', noun: 'world'}) // "Hello, World"
* @param {String} baseString - A string with ${x} placeholders
* @param {Object} params - key/value for substitution
* @return {String}
*/
interpolate (baseString, params) {
const names = Object.keys(params);
const vals = Object.values(params);
try {
return new Function(...names, `return \`${baseString}\`;`)(...vals);
} catch (e) {
throw new Error(`insufficient parameters. Has ${Object.keys(params)} but ${e.message}`);
}
}
/**
* Convert strings that have {name.subname} pattern to ${name_subname} so can be interpolated
* Used internally; required since Google APIs use former pattern instead of latter
*/
translateToTemplate (string) {
// Use special patterns available in second parameter go from a {} to ${}
return string.replace(/{\+*([a-zA-Z_.]*?)}/g, function (one, two) {
return '${' + two.replace('.', '_') + '}';
});
}
/**
* Convert an obj to string of params used in query strings
* supports multiple query strings as arrays, e.g.:
* {fields: [], key: 'value'} converts to ?key=value - No fields included as it is empty array
* {arr: ['one', two'], key: 'value'} converts to ?array=one&array=two&key=value
* @return {String}
*/
makeQueryString ({...kwargs}={}) {
const arr = Object.entries(kwargs).reduce(
function (acc, [key, value]) {
if (Array.isArray(value)) {
for (const v of value) {
acc.push(key + '=' + encodeURIComponent(v));
}
} else {
acc.push(key + '=' + encodeURIComponent(value));
}
return acc;
}, []
);
return (arr.length === 0 ? '' : '?' + arr.join('&'))
}
}
const PRIVATE_OAUTH = Symbol('private_oauth');
/**
* Request instance
*/
class Request {
constructor ({url, oauth, method='get', headers={}, payload={}, query={}}={}, {mixin=null}) {
Enforce.named(arguments, {url: '!string', oauth: '!any', method: 'string', headers: 'object', payload: 'object', query: 'object', mixin: 'any'});
this._url = url;
this.headers = headers;
this.payload = payload;
this.method = method;
this.query = query;
// standard parameters is quite useful for performance, use is specially
this._fields = [];
this[PRIVATE_OAUTH] = oauth;
if (mixin) Object.assign(this, mixin);
}
/*
* Reach out to the internet with UrlFetchApp, returns Response object
* @return {Response}
*/
fetch () {
const [url, requestObject] = this.url_params({embedUrl: true});
let response;
try {
response = UrlFetchApp.fetch(url, requestObject);
} catch (e) {
response = null;
throw new Error(e.message, {url, requestObject});
}
let resp = new Response({response, requestObject});
// auto-detect ratelimits, try again
if (resp.hitRateLimit) {
response = UrlFetchApp.fetch(url, requestObject);
resp = new Response({response, requestObject});
}
return resp;
}
/**
* Returns this.url
* @return {String}
*/
getUrl () {
return this.url;
}
/**
* Calculates url, adding query parameters
* In case key fields is non empty, converts with .join(",") as needed by fields standard query param
*/
get url () {
if ( (this._fields || []).length > 0) {
// convert fields data type from array to string with , delimiter, but don't replace
return this._url + EndpointsBase.utils.makeQueryString({...this.query, ...{fields: this._fields.join(',')}});
}
return this._url + EndpointsBase.utils.makeQueryString(this.query);
}
/**
* Copies key in obj to request object so that query parameters are passed on fetch
* @param {Object} obj - the object that is copied to queries object
*/
addQuery (obj={}) {
Enforce.positional(arguments, {obj: 'object'}, 'Request#addQuery');
for (const [key, value] of Object.entries(obj)) {
this.query[key] = value;
}
}
addHeader (obj={}) {
/**
* Copies key in obj to headers object so
*/
Enforce.positional(arguments, {obj: 'object'}, 'Request#addHeader');
for (const [key, value] of Object.entries(obj)) {
this.headers[key] = value;
}
}
clearQuery () {
this.query = {};
}
set fields (value=null) {
Enforce.positional(arguments, {value: 'string'}, 'Request#set_fields');
this._fields.push(value);
}
/*
* Pushes value to this.query.fields
* @param {String} value
*/
setFields (value) {
this.fields = value;
}
/*
* Sets query.fields to empty array
*/
clearFields () {
this._fields = [];
}
/*
* Returns the param object required for UrlFetchApp.fetch or fetchAll
* @param {bool} embedUrl if true contains url in object (for fetchAll)
* @param {bool} muteExceptions if true errors will be returned as jsons
* @returns {[str, obj]}
*/
url_params ({embedUrl=false, muteExceptions=true}={}) {
Enforce.named(arguments, {embedUrl: 'boolean', muteExceptions: 'boolean'}, 'Request#url_params');
const obj = {};
// calculate url based on queries as needed
const url = this.url;
// we'll derive the oauth token upon request, if applicable, here
// keep backward compatible with Oauth2 lib
if (this[PRIVATE_OAUTH]) {
const token = (_ => {
if (this[PRIVATE_OAUTH].hasAccess) {
// if our oauth has a method "hasAccess" we know it's using the Oauth lib
if (this[PRIVATE_OAUTH].hasAccess()) {
// return the access token (usually the case will do so)
return this[PRIVATE_OAUTH].getAccessToken();
}
// return null if Oauth lib reports no access (in some cases may have problems)
return null;
}
// here oauth is an object (class instance) with token property
// return that, or null if not present or empty
return this[PRIVATE_OAUTH].token || null;
})();
if (token==null) throw new Error("No authorization");
this.headers['Authorization'] = `Bearer ${token}`;
}
if (Object.keys(this.headers).length > 0) {
obj.headers = this.headers;
}
obj.muteHttpExceptions = muteExceptions;
obj.method = this.method;
if (embedUrl) obj.url = url;
if (Object.keys(this.payload).length > 0) {
obj.payload = JSON.stringify(this.payload);
obj.contentType = 'application/json';
}
return [url, obj];
}
getUrlParams () {
return
}
}
class Response {
/*
* Response object
* @param {Object} param
* @param {Object} param.response
* @param {Object} param.requestObject
*/
constructor ({response=null, requestObject=null}={}) {
Enforce.named(arguments, {response: 'object', requestObject: 'object'}, 'Response#constructor');
this.response = response;
this.requestObject = requestObject;
// By default, if response cannot be parsed to json we'll send back a json with error information
// instead of throwing error
this.catchUnparseableJsonResponse = true;
}
getText () {
/*
* Return the plain text of the response (getContentText)
* @return {String}
*/
return this.text;
}
getJson () {
/*
* Return the json of the response
* @throws {Error} if not parsable
* @return {String}
*/
return this.json;
}
get text () {
/**
* Return the plain text of the response (getContentText)
*/
return this.response.getContentText();
}
get json () {
/*
* Return the json
* @throws {Error} if cannot be parsed as json
*/
const text = this.text;
let result;
try {
return JSON.parse(text);
} catch (err) {
if (this.catchUnparseableJsonResponse) {
// return a json with error message instead, as usually does Google APIs
const contentType = this.headers["Content-Type"];
const [mime, charset] = contentType.split(';').map(str => str.trim());
return {
error: {
status: this.statusCode,
message: err.message,
charset: charset || null,
mime: mime || null,
},
text
}
}
throw new Error(err);
}
return result;
}
/*
* Same as getAllHeaders
* @return {Object}
*/
get headers () {
return this.response.getAllHeaders();
}
getHeaders () {
return this.headers;
}
/*
* Same as getRepsonseCode, 200 is success
* @return {Number}
*/
get statusCode () {
return this.response.getResponseCode();
}
/**
* Returns this.statusCode
* @return {Number}
*/
getStatusCode () {
return this.statusCode;
}
/*
* Returns true if statusCode == 200
* @return {Boolean}
*/
get ok () {
return this.statusCode === 200;
}
/**
* Returns this.ok
* @return {Boolean}
*/
isOk () {
return this.ok;
}
get hitRateLimit () {
if (this.statusCode === 429) {
const headers = this.getAllHeaders();
let header_reset_at = headers['x-ratelimit-reset'];
header_reset_at = header_reset_at.replace(" UTC", "+0000").replace(" ", "T");
const reset_at = new Date(header_reset_at).getTime();
const utf_now = new Date().getTime();
const milliseconds = reset_at - utf_now + 10;
if (milliseconds > 0) {
Utilities.sleep(milliseconds);
}
return true;
}
return false;
}
/**
* Returns this.requestObject
* @return {Object}
*/
getRequest () {
return this.requestObject;
}
}
/*
* Extensibly interact with Google APIs through Discovery
*/
class EndpointsBase {
/*
* An abstract endpoint
* @param {Object} [base]
* @param {String} [base.baseUrl] default=null
* @param {String|Object} [base.oauth] default=null
* @param {Object} [base.discovery]
* @param {Object} [stickies] permanent values for options
* @param {Object} [stickies.stickyHeaders] permanent headers for any created requests
* @param {Object} [stickies.stickyQuery] permanent queries on any created requests
* @param {Object} [stickies.payload] payload for any created requests
*/
constructor ({baseUrl=null, oauth=null, discovery={}}={}, {stickyHeaders={}, stickyQuery={}, stickyPayload={}}={}) {
Enforce.named(arguments, {baseUrl: 'string', oauth: 'any', discovery: 'object', stickyHeaders: 'object', stickyQuery: 'object', stickyPayload: 'object'}, 'EndpointsBase.constructor');
this.disc = null;
this.baseUrl = baseUrl;
this.stickyHeaders = stickyHeaders;
this.stickyQuery = stickyQuery;
this.stickyPayload = stickyPayload;
this.oauth = oauth;
if (Object.keys(discovery).length > 0 && EndpointsBase.utils.validateDiscovery(discovery)) {
this.disc = new DiscoveryCache();
this.baseUrl = EndpointsBase.utils.translateToTemplate( this.disc.getUrl(discovery) );
}
// set oauth to a basic class
if (this.oauth === 'me') {
class OAUTH {
get token () {
return ScriptApp.getOAuthToken();
}
}
this.oauth = new OAUTH();
}
}
/**
* An endpoint's baseUrl property is a string with placeholders
*/
getBaseUrl () {
return this.baseUrl;
}
/*
* Creates http get request
* @param {String} method
* @param {Object} base
* @param {String} [base.url]
* @param {Object} [base.pathParams] - replace ${placeholders} by key/values found in baseUrl
* @param {Object} [options]
* @param {Object} [options.query]
* @param {Object} [options.payload]
* @param {Object} [options.headers]
* @param {Object} [advanced]
* @param {Any} [advanced.mixin] mixin pattern on this object
*/
createRequest (method, {url=null, ...pathParams}={}, {query={}, payload={}, headers={}}={}, {mixin=null}={}) {
const options = {};
// check for what it has been passed
if (Object.keys(pathParams).length > 0) {
if (!url && !this.baseUrl) throw new TypeError("createRequest requires a url named parameter in the second parameter");
if (this.baseUrl && url) throw new TypeError("createRequest has been passed url when baseUrl has already been defined.");
options.url = EndpointsBase.utils.interpolate(this.baseUrl || url, pathParams);
} else if (url !== null) {
options.url = url;
} else {
options.url = this.baseUrl;
}
options.method = method;
options.headers = {...this.stickyHeaders, ...headers}; // second overwrites
options.payload = {...this.stickyPayload, ...payload};
options.query = {...this.stickyQuery, ...query};
options.oauth = this.oauth;
return new Request(options, {mixin});
}
/*
* Creates http get request
* @param {Object} pathParams - replace ${placeholders} by key/values
* @param {Object} [options]
* @param {Object} [options.query]
* @param {Object} [options.payload]
* @param {Object} [options.headers]
*/
httpget ({...pathParams}={}, {...options}={}) {
return this.createRequest('get', pathParams, options);
}
/*
* Creates http post request
* @param {Object} pathParams - replace ${placeholders} by key/values
* @param {Object} [options]
* @param {Object} [options.query]
* @param {Object} [options.payload]
* @param {Object} [options.headers]
*/
httppost ({...pathParams}={}, {...options}={}) {
return this.createRequest('post', pathParams, options);
}
/*
* Creates http put request
* @param {Object} pathParams - replace ${placeholders} by key/values
* @param {Object} [options]
* @param {Object} [options.query]
* @param {Object} [options.payload]
* @param {Object} [options.headers]
*/
httpput ({...pathParams}={}, {...options}={}) {
return this.createRequest('put', pathParams, options);
}
/*
* Creates http patch request
* @param {Object} pathParams - replace ${placeholders} by key/values
* @param {Object} [options]
* @param {Object} [options.query]
* @param {Object} [options.payload]
* @param {Object} [options.headers]
*/
httppatch ({...pathParams}={}, {...options}={}) {
return this.createRequest('patch', path, options);
}
/*
* Creates http delete request
* @param {Object} pathParams - replace ${placeholders} by key/values
* @param {Object} [options]
* @param {Object} [options.query]
* @param {Object} [options.payload]
* @param {Object} [options.headers]
*/
httpdelete({...pathParams}={}, {...options}={}) {
return this.createRequest('delete', pathParams, options);
}
static get utils () {
return new Utils();
}
static discovery ({name, version, resource, method}={}, {oauth="me"}={}) {
const discovery = {
name: name,
version: version,
resource: resource,
method: method
};
return new EndpointsBase({oauth, discovery});
}
static googOauthService ({service = null, email = null, privateKey = null, scopes = null}) {
const oauthService = OAuth2.createService(service)
.setTokenUrl('https://accounts.google.com/o/oauth2/token')
.setIssuer(email)
.setPrivateKey(privateKey)
.setPropertyStore(PropertiesService.getUserProperties())
.setScope(scopes);
return oauthService;
}
static batchRequests ({...kwargs}={}) {
const b = new Batch();
const r = new EndpointsBase(kwargs);
return [b, r];
}
}
const EndpointsLib = {EndpointsBase, Response, Batch, Request};
export {EndpointsLib};
export const module = EndpointsBase;