Skip to content

Commit

Permalink
feat: Implement methods for listing orgs, setting the current org, an…
Browse files Browse the repository at this point in the history
…d listing org users.
  • Loading branch information
adam-coster committed Jun 25, 2021
1 parent 2d90d6b commit 6f59b1e
Show file tree
Hide file tree
Showing 13 changed files with 362 additions and 123 deletions.
26 changes: 7 additions & 19 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,17 @@
"node": true,
"mocha": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint",
"prettier"
],
"plugins": ["@typescript-eslint", "prettier"],
"parserOptions": {
"ecmaVersion": 2019,
"sourceType": "module",
"project": "./tsconfig.json"
},
"ignorePatterns": [
".eslintrc.js",
"prettier.config.js"
],
"ignorePatterns": [".eslintrc.js", "prettier.config.js"],
"rules": {
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/ban-ts-ignore": "off",
"@typescript-eslint/type-annotation-spacing": "off",
"@typescript-eslint/explicit-function-return-type": "off",
Expand Down Expand Up @@ -55,22 +47,18 @@
"@typescript-eslint/no-var-requires": "error",
"@typescript-eslint/prefer-namespace-keyword": "error",
"@typescript-eslint/triple-slash-reference": "error",
"@typescript-eslint/no-floating-promises": [
"error"
],
"@typescript-eslint/no-floating-promises": ["error"],
"no-var": "error",
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error"
},
"overrides": [
{
"files": [
"*.js"
],
"files": ["*.js"],
"rules": {
"@typescript-eslint/no-var-requires": "off"
}
}
]
}
}
9 changes: 8 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@typescript-eslint/parser": "^4.28.0",
"chai": "^4.3.4",
"conventional-changelog-cli": "^2.1.1",
"dotenv": "^10.0.0",
"eslint": "^7.29.0",
"eslint-plugin-prettier": "^3.4.0",
"fs-extra": "^10.0.0",
Expand Down
2 changes: 1 addition & 1 deletion src/cli/cli-subcommand.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import cli from 'commander';
import { program as cli } from 'commander';

// Kick it off
cli
Expand Down
6 changes: 2 additions & 4 deletions src/cli/cli.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
#!/usr/bin/env node
import cli from "commander";
import { program as cli } from 'commander';

// Kick it off
cli.description('CLI Name')
.command("subcommand", "Do some things.")
.parse();
cli.description('CLI Name').command('subcommand', 'Do some things.').parse();
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './lib/BravoClient.js';
export * from './types/FavroApi.js';
202 changes: 164 additions & 38 deletions src/lib/BravoClient.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,51 @@
import { assertBravoClaim, BravoError } from './errors.js';
import fetch, { Response } from 'node-fetch';
import fetch from 'node-fetch';
import { URL } from 'url';
import {
AnyEntity,
FavroResponseData,
FavroApiMethod,
FavroDataOrganization,
FavroDataOrganizationUser,
} from '../types/FavroApi';
import { FavroResponse } from './FavroResponse';

type FavroApiMethod = 'get' | 'post' | 'put' | 'delete';
type FavroDataOrganizationUserPartial =
FavroDataOrganization['sharedToUsers'][number];

export class FavroResponse {
private _response: Response;

constructor(response: Response) {
this._response = response;
}

get status() {
return this._response.status;
export class FavroUser<
Data extends FavroDataOrganizationUser | FavroDataOrganizationUserPartial,
> {
private _data: Data;
constructor(data: Data) {
this._data = data;
}

get succeeded() {
return this.status <= 399 && this.status >= 200;
get userId() {
return this._data.userId;
}

get failed() {
return this.succeeded;
get role() {
return 'organizationRole' in this._data
? this._data.organizationRole
: this._data.role;
}

get requestsRemaining() {
return Number(this._response.headers.get('X-RateLimit-Remaining'));
get name(): Data extends FavroDataOrganizationUser ? string : undefined {
// @ts-expect-error
return 'name' in this._data ? this._data.name : undefined;
}

get limitResetsAt() {
return new Date(this._response.headers.get('X-RateLimit-Reset')!);
get email() {
return 'email' in this._data ? this._data.email : undefined;
}
}

export class BravoClient {
static readonly baseUrl = 'https://favro.com/api/v1';

private _token!: string;
private _organizationId!: string;
private _organizationId?: string;
/**
* Authentication requires the user's identifer (their email address)
*/
Expand All @@ -55,20 +64,35 @@ export class BravoClient {
*/
private _backendId?: string;

constructor(options: {
private _organizations?: FavroDataOrganization[];

private _users?: FavroUser<FavroDataOrganizationUser>[];

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;
}
this._organizationId =
options?.organizationId || process.env.FAVRO_ORGANIZATION_ID;
}

get organizationId() {
return this._organizationId;
}
set organizationId(organizationId: string | undefined) {
if (this._organizationId && this._organizationId != organizationId) {
this.clearCache();
}
this._organizationId = organizationId;
}

private get authHeader() {
Expand All @@ -86,7 +110,7 @@ export class BravoClient {
*
* @param url Relative to the base URL {@link https://favro.com/api/v1}
*/
async request(
async request<Entity extends AnyEntity = AnyEntity>(
url: string,
options?: {
method?: FavroApiMethod | Capitalize<FavroApiMethod>;
Expand All @@ -98,8 +122,19 @@ export class BravoClient {
* but you can override this if necessary.
*/
backendId?: string;
excludeOrganizationId?: boolean;
requireOrganizationId?: boolean;
},
) {
assertBravoClaim(
typeof this._requestsRemaining == 'undefined' ||
this._requestsRemaining > 0,
'No requests remaining!',
);
assertBravoClaim(
this._organizationId || !options?.requireOrganizationId,
'An organizationId must be set for this request',
);
const method = options?.method || 'get';
if (['get', 'delete'].includes(method) && options?.body) {
throw new BravoError(`HTTP Bodies not allowed for ${method} method`);
Expand All @@ -113,22 +148,113 @@ export class BravoClient {
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,
}),
let body = options?.body;
let contentType: string | undefined;
if (typeof body != 'undefined') {
if (Buffer.isBuffer(body)) {
contentType = 'application/octet-stream';
} else if (typeof body == 'string') {
contentType = 'text/markdown';
} else {
body = JSON.stringify(body);
contentType = 'application/json';
}
}
const headers = {
'Content-Type': contentType!,
...options?.headers,
...this.authHeader,
'User-Agent': `BravoClient <https://github.com/bscotch/favro-sdk>`,
organizationId: options?.excludeOrganizationId
? undefined
: this._organizationId,
'X-Favro-Backend-Identifier': options?.backendId || this._backendId!,
};
const res = await fetch(fullUrl.toString(), {
method,
headers: headers as Record<string, string>, // Force it to assume no undefineds
body: options?.body,
});
const favroRes = new FavroResponse(
res,
(await res.json()) as FavroResponseData<Entity>,
);
this._limitResetsAt = favroRes.limitResetsAt;
this._requestsRemaining = favroRes.requestsRemaining;
if (this._requestsRemaining < 1 || res.status == 429) {
// TODO: Set an interval before allowing requests to go through again, OR SOMETHING
this._requestsRemaining = 0;
}
return favroRes;
}

async currentOrganization() {
if (!this._organizationId) {
return;
}
return (await this.listOrganizations()).find(
(org) => org.organizationId == this._organizationId,
);
this._limitResetsAt = res.limitResetsAt;
this._requestsRemaining = res.requestsRemaining;
return res;
}

/**
* List the calling user's organizations
*/
async listOrganizations() {
if (!this._organizations) {
const res = await this.request<FavroDataOrganization>('organizations', {
excludeOrganizationId: true,
});
this._organizations = res.entities;
}
return [...this._organizations];
}

async findOrganizationByName(name: string) {
const orgs = await this.listOrganizations();
return orgs.find((org) => org.name);
}

async setOrganizationIdByName(organizationName: string) {
const org = await this.findOrganizationByName(organizationName);
assertBravoClaim(org, `Org by name of ${organizationName} not found`);
assertBravoClaim(org.organizationId, `Org does not have an ID`);
this.organizationId = org.organizationId;
}

/**
* Full user info for the org (includes emails and names),
* requires an API request.
*/
async listFullUsers() {
const org = await this.currentOrganization();
assertBravoClaim(org, 'Organization not set');
if (!this._users) {
const res = await this.request<FavroDataOrganizationUser>('users');
this._users = res.entities.map((u) => new FavroUser(u));
}
return [...this._users];
}

/**
* Basic user info (just userIds and roles) obtained directly
* from organization data (doesn't require an API request)
*/
async listPartialUsers() {
const org = await this.currentOrganization();
assertBravoClaim(org, 'Organization not set');
const users = org.sharedToUsers.map((u) => new FavroUser(u));
return users;
}

/**
* To reduce API calls (the rate limits are tight), things
* are generally cached. To ensure requests are up to date
* with recent changes, you can force a cache clear.
*/
clearCache() {
this._users = undefined;
this._organizations = undefined;
}

static toBase64(string: string) {
Expand Down
Loading

0 comments on commit 6f59b1e

Please sign in to comment.