Skip to content

Commit

Permalink
feat: Draft base BravoClient class, including a method for general Fa…
Browse files Browse the repository at this point in the history
…vro API requests and a wrapper class for returned results.
  • Loading branch information
adam-coster committed Jun 25, 2021
1 parent 2f0ddae commit 2d90d6b
Show file tree
Hide file tree
Showing 5 changed files with 155 additions and 18 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ build
sandbox
sand\ box
debug.log
.env
12 changes: 1 addition & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
137 changes: 137 additions & 0 deletions src/lib/BravoClient.ts
Original file line number Diff line number Diff line change
@@ -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<FavroApiMethod>;
query?: Record<string, string>;
body?: any;
headers?: Record<string, string>;
/**
* 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 <https://github.com/bscotch/favro-sdk>`,
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');
}
}
16 changes: 16 additions & 0 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
7 changes: 0 additions & 7 deletions src/lib/sample-module.ts

This file was deleted.

0 comments on commit 2d90d6b

Please sign in to comment.