From 2d90d6b8e58a72c5f3bd48efe0201f5b58bfaa96 Mon Sep 17 00:00:00 2001 From: Adam Coster Date: Fri, 25 Jun 2021 15:49:32 -0500 Subject: [PATCH] feat: Draft base BravoClient class, including a method for general Favro API requests and a wrapper class for returned results. --- .gitignore | 1 + src/index.ts | 12 +--- src/lib/BravoClient.ts | 137 +++++++++++++++++++++++++++++++++++++++ src/lib/errors.ts | 16 +++++ src/lib/sample-module.ts | 7 -- 5 files changed, 155 insertions(+), 18 deletions(-) create mode 100644 src/lib/BravoClient.ts create mode 100644 src/lib/errors.ts delete mode 100644 src/lib/sample-module.ts diff --git a/.gitignore b/.gitignore index 8269f72..e788e33 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ build sandbox sand\ box debug.log +.env diff --git a/src/index.ts b/src/index.ts index 63dff66..6789214 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1 @@ -/** - * @file This is the entrypoint for your project. - * If used as a node module, when someone runs - * `import stuff from 'your-module'` (typescript) - * or `const stuff = require('your-module')` (javascript) - * whatever is exported here is what they'll get. - * For small projects you could put all your code right in this file. - */ - -export * from './lib/sample-module.js'; -export default undefined; +export * from './lib/BravoClient.js'; diff --git a/src/lib/BravoClient.ts b/src/lib/BravoClient.ts new file mode 100644 index 0000000..ff010c6 --- /dev/null +++ b/src/lib/BravoClient.ts @@ -0,0 +1,137 @@ +import { assertBravoClaim, BravoError } from './errors.js'; +import fetch, { Response } from 'node-fetch'; +import { URL } from 'url'; + +type FavroApiMethod = 'get' | 'post' | 'put' | 'delete'; + +export class FavroResponse { + private _response: Response; + + constructor(response: Response) { + this._response = response; + } + + get status() { + return this._response.status; + } + + get succeeded() { + return this.status <= 399 && this.status >= 200; + } + + get failed() { + return this.succeeded; + } + + get requestsRemaining() { + return Number(this._response.headers.get('X-RateLimit-Remaining')); + } + + get limitResetsAt() { + return new Date(this._response.headers.get('X-RateLimit-Reset')!); + } +} + +export class BravoClient { + static readonly baseUrl = 'https://favro.com/api/v1'; + + private _token!: string; + private _organizationId!: string; + /** + * Authentication requires the user's identifer (their email address) + */ + private _userEmail!: string; + /** + * The response header X-RateLimit-Remaining informs how many + * requests we can make before being blocked. Use this to ensure + * we don't frequently hit those! X-RateLimit-Reset is the time + * when the limit will be reset. + */ + private _requestsRemaining?: number; + private _limitResetsAt?: Date; + /** + * Favro responses include the header X-Favro-Backend-Identifier, + * which is used to route to the same server. Required for paging. + */ + private _backendId?: string; + + constructor(options: { + token?: string; + organizationId?: string; + userEmail?: string; + }) { + for (const [optionsName, envName] of [ + ['token', 'FAVRO_TOKEN'], + ['organizationId', 'FAVRO_ORGANIZATION_ID'], + ['userEmail', 'FAVRO_USER_EMAIL'], + ] as const) { + const value = options?.[optionsName] || process.env[envName]; + assertBravoClaim(value, `A Favro ${optionsName} is required.`); + this[`_${optionsName}`] = value; + } + } + + private get authHeader() { + const encodedCredentials = BravoClient.toBase64( + `${this._userEmail}:${this._token}`, + ); + return { + Authorization: `Basic ${encodedCredentials}`, + }; + } + + /** + * General API request function against Favro's HTTP API {@link https://favro.com/developer/}. + * Defaults to a GET request. Default headers are automatically handled. + * + * @param url Relative to the base URL {@link https://favro.com/api/v1} + */ + async request( + url: string, + options?: { + method?: FavroApiMethod | Capitalize; + query?: Record; + body?: any; + headers?: Record; + /** + * BravoClients use the last-received Backend ID by default, + * but you can override this if necessary. + */ + backendId?: string; + }, + ) { + const method = options?.method || 'get'; + if (['get', 'delete'].includes(method) && options?.body) { + throw new BravoError(`HTTP Bodies not allowed for ${method} method`); + } + // Ensure initial slash + url = url.startsWith('/') ? url : `/${url}`; + url = `${BravoClient.baseUrl}${url}`; + const fullUrl = new URL(url); + if (options?.query) { + for (const param of Object.keys(options.query)) { + fullUrl.searchParams.append(param, options.query[param]); + } + } + const res = new FavroResponse( + await fetch(fullUrl.toString(), { + method, + headers: { + ...options?.headers, + ...this.authHeader, + 'User-Agent': `BravoClient `, + organizationId: this._organizationId, + 'X-Favro-Backend-Identifier': options?.backendId || this._backendId!, + }, + body: options?.body, + }), + ); + this._limitResetsAt = res.limitResetsAt; + this._requestsRemaining = res.requestsRemaining; + return res; + } + + static toBase64(string: string) { + return Buffer.from(string).toString('base64'); + } +} diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..8e1b1a9 --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,16 @@ +export class BravoError extends Error { + constructor(message: string) { + super(message); + this.name = 'BravoError'; + Error.captureStackTrace(this, this.constructor); + } +} + +export function assertBravoClaim( + claim: any, + message = 'Assertion failed', +): asserts claim { + if (!claim) { + throw new BravoError(message); + } +} diff --git a/src/lib/sample-module.ts b/src/lib/sample-module.ts deleted file mode 100644 index 53781b0..0000000 --- a/src/lib/sample-module.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file ./lib is a great place to keep all your code. - * You can then choose what to make available by default by - * exporting your lib modules from the ./src/index.ts entrypoint. -*/ - -export default undefined;