Skip to content

Commit

Permalink
feat: Add methods for creating and deleting Collections, add more cla…
Browse files Browse the repository at this point in the history
…sses and types for the disparate kinds of API response data, and improve generic request handling.
  • Loading branch information
adam-coster committed Jun 26, 2021
1 parent 546cc98 commit 65c49ed
Show file tree
Hide file tree
Showing 8 changed files with 341 additions and 114 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"build/types/**/*"
],
"scripts": {
"test": "npm run build && mocha --inspect --require source-map-support/register --bail ./build/test/",
"test": "npm run build && mocha --inspect --require source-map-support/register --bail --timeout 6000 ./build/test/",
"build": "rimraf build && tsc && tsc-alias",
"preversion": "npm run lint && npm run build && npm test",
"version": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0 && npm run format && git add -A",
Expand Down
255 changes: 213 additions & 42 deletions src/lib/BravoClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,36 @@ import { assertBravoClaim, BravoError } from './errors.js';
import fetch from 'node-fetch';
import { URL } from 'url';
import {
AnyEntityData,
FavroApiMethod,
FavroDataOrganizationUser,
FavroDataCollection,
FavroEntityConstructor,
DataAnyEntity,
OptionFavroHttpMethod,
DataFavroUser,
DataFavroCollection,
ConstructorFavroEntity,
OptionFavroCollectionVisibility,
DataFavroResponse,
OptionFavroCollectionColorBackground,
OptionFavroCollectionRole,
} from '../types/FavroApi';
import { FavroResponse } from './FavroResponse';
import { FavroResponse, FavroResponseEntities } from './FavroResponse';
import { findByField, findRequiredByField } from './utility.js';
import { FavroCollection } from './FavroCollection';
import { FavroUser } from './FavroUser';
import { FavroOrganization } from './FavroOrganization';
import { FavroEntity } from './FavroEntity.js';

export interface OptionsBravoRequest {
method?: OptionFavroHttpMethod | Capitalize<OptionFavroHttpMethod>;
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;
excludeOrganizationId?: boolean;
requireOrganizationId?: boolean;
}

export class BravoClient {
static readonly baseUrl = 'https://favro.com/api/v1';
Expand All @@ -38,7 +57,7 @@ export class BravoClient {
private _backendId?: string;

private _organizations?: FavroOrganization[];
private _users?: FavroUser<FavroDataOrganizationUser>[];
private _users?: FavroUser[];
private _collections?: FavroCollection[];

constructor(options?: {
Expand Down Expand Up @@ -87,23 +106,20 @@ export class BravoClient {
*
* @param url Relative to the base URL {@link https://favro.com/api/v1}
*/
async request<EntityData extends AnyEntityData>(
async request(
url: string,
entityClass: FavroEntityConstructor<EntityData>,
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;
excludeOrganizationId?: boolean;
requireOrganizationId?: boolean;
},
) {
options?: OptionsBravoRequest,
): Promise<FavroResponse>;
async request<EntityData extends DataAnyEntity>(
url: string,
options: OptionsBravoRequest,
entityClass: ConstructorFavroEntity<EntityData>,
): Promise<FavroResponseEntities<EntityData, FavroEntity<EntityData>>>;
async request<EntityData extends DataAnyEntity>(
url: string,
options?: OptionsBravoRequest,
entityClass?: ConstructorFavroEntity<EntityData>,
): Promise<any> {
assertBravoClaim(
typeof this._requestsRemaining == 'undefined' ||
this._requestsRemaining > 0,
Expand Down Expand Up @@ -139,6 +155,7 @@ export class BravoClient {
}
}
const headers = {
Host: 'favro.com', // Required by API (otherwise fails without explanation)
'Content-Type': contentType!,
...options?.headers,
...this.authHeader,
Expand All @@ -148,16 +165,43 @@ export class BravoClient {
: this._organizationId,
'X-Favro-Backend-Identifier': options?.backendId || this._backendId!,
};
const cleanHeaders = Object.keys(headers).reduce((acc, header: string) => {
// @ts-expect-error
if (typeof headers[header] == 'undefined') {
return acc;
}
// @ts-expect-error
acc[header] = `${headers[header]}`;
return acc;
}, {} as Record<string, string>);
const res = await fetch(fullUrl.toString(), {
method,
headers: headers as Record<string, string>, // Force it to assume no undefineds
body: options?.body,
headers: cleanHeaders, // Force it to assume no undefineds
body,
});
const favroRes = new FavroResponse(
this,
this._backendId =
res.headers.get('X-Favro-Backend-Identifier') || this._backendId;
if (!entityClass) {
return new FavroResponse(res);
}
assertBravoClaim(res.status < 300, `Failed with status ${res.status}`);
let responseBody: string | DataFavroResponse<EntityData> = (
await res.buffer()
).toString('utf8');
assertBravoClaim(
res.headers.get('Content-Type')?.startsWith('application/json'),
'Response type is not JSON, cannot be wrapped in Entity class.',
);
try {
responseBody = JSON.parse(responseBody) as DataFavroResponse<EntityData>;
} catch {
throw new BravoError(`Could not JSON-parse: ${responseBody.toString()}`);
}
const favroRes = new FavroResponseEntities(
responseBody,
entityClass,
this,
res,
await res.json(),
);
this._limitResetsAt = favroRes.limitResetsAt;
this._requestsRemaining = favroRes.requestsRemaining;
Expand Down Expand Up @@ -186,9 +230,13 @@ export class BravoClient {
*/
async listOrganizations() {
if (!this._organizations) {
const res = await this.request('organizations', FavroOrganization, {
excludeOrganizationId: true,
});
const res = await this.request(
'organizations',
{
excludeOrganizationId: true,
},
FavroOrganization,
);
this._organizations = res.entities as FavroOrganization[];
}
return [...this._organizations];
Expand Down Expand Up @@ -224,27 +272,27 @@ export class BravoClient {
//#region Users

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

/**
* Basic user info (just userIds and roles) obtained directly
* from organization data (doesn't require an API request)
* Basic user info (just userIds and org roles) obtained directly
* from organization data.
*/
async listPartialUsers() {
async listOrganizationMembers() {
const org = await this.currentOrganization();
assertBravoClaim(org, 'Organization not set');
return org.sharedToUsers;
Expand All @@ -266,30 +314,152 @@ export class BravoClient {
return findRequiredByField(await this.listFullUsers(), 'userId', userId);
}

async findPartialUserById(userId: string) {
return findRequiredByField(await this.listPartialUsers(), 'userId', userId);
/** Find a user's basic info (userId & role) in this org, by ID. */
async findOrganizationMemberById(userId: string) {
return findRequiredByField(
await this.listOrganizationMembers(),
'userId',
userId,
);
}

//#endregion

//#region Collections

/**
* Create a new collection. Currently uses only the
* most essential parameters for simplicity. Sharing
* defaults to 'organization' if not provided.
*
* {@link https://favro.com/developer/#create-a-collection}
*/
async createCollection(
name: string,
options?: {
publicSharing?: OptionFavroCollectionVisibility;
background?: OptionFavroCollectionColorBackground;
sharedToUsers?: {
email?: string;
userId?: string;
role: OptionFavroCollectionRole;
}[];
},
) {
const res = await this.request(
'collections',
{
method: 'post',
body: {
name,
publicSharing: options?.publicSharing || 'organization',
// background: options?.background || 'blue',
// sharedToUsers: [
// ...(options?.sharedToUsers || []),
// { email: this._userEmail, role: 'admin' },
// ],
},
},
FavroCollection,
);
const collection = res.entities[0] as FavroCollection | undefined;
assertBravoClaim(
collection,
`Failed to create collection with status: ${res.status}`,
);
this._addCollectionToCache(collection);
return collection;
}

async deleteCollectionById(collectionId: string) {
const res = await this.request(`collections/${collectionId}`, {
method: 'delete',
});
assertBravoClaim(
res.succeeded,
`Failed to delete collection with status ${res.status}`,
);
this._removeCollectionFromCache(collectionId);
}

/**
* Add a collection to the cache *if the cache already exists*,
* e.g. for updating it after creating a new collection. Ensures
* uniqueness. If the collection is already in the cache, it
* will be replaced by the one provided in this call (e.g. for
* replacing the cached copy with an updated one).
*/
private _addCollectionToCache(collection: FavroCollection) {
if (!this._collections) {
return;
}
const cachedIdx = this._collections.findIndex(
(c) => c.collectionId == collection.collectionId,
);
if (cachedIdx > -1) {
this._collections.splice(cachedIdx, 1, collection);
} else {
this._collections.push(collection);
}
}

/**
* May need to remove a collection from the cache, e.g. after
* a deletion triggered locally.
*/
private _removeCollectionFromCache(collectionId: string) {
const cachedIdx = this._collections?.findIndex(
(c) => c.collectionId == collectionId,
);
if (typeof cachedIdx == 'number' && cachedIdx > -1) {
this._collections!.splice(cachedIdx, 1);
}
}

async deleteCollectionByName(name: string) {
const collection = await this.findCollectionByName(name);
assertBravoClaim(collection, `Could not find collection with name ${name}`);
await this.deleteCollectionById(collection.collectionId);
}

/**
* Fetch *all* collections from the current org.
* Returns the cached result of the first call until
* the cache is cleared.
* (Does not include archived)
* {@link https://favro.com/developer/#get-all-collections}
*/
async listCollections() {
const org = await this.currentOrganization();
assertBravoClaim(org, 'Organization not set');
if (!this._collections) {
const res = await this.request('users', FavroCollection);
const res = await this.request(
'collections',
{ method: 'get' },
FavroCollection,
);
this._collections = res.entities as FavroCollection[];
}
return [...this._collections];
}

/**
* Look for a specific collection by name, which requires
* fetching *all* collections. Checks the cache first.
* {@link https://favro.com/developer/#get-a-collection}
*/
async findCollectionByName(name: string) {
return findRequiredByField(await this.listCollections(), 'name', name, {
ignoreCase: true,
});
}

/**
* Look for a specific collection by ID, first in the
* cached collections and falling back to the single-collection
* endpoint.
* {@link https://favro.com/developer/#get-a-collection}
*/
async findCollectionById(collectionId: string) {
// See if already in the cache
let collection = findByField(
Expand All @@ -299,8 +469,9 @@ export class BravoClient {
);
if (!collection) {
// Then hit the API directly!
const res = await this.request<FavroDataCollection>(
const res = await this.request<DataFavroCollection>(
`collections/${collectionId}`,
{ method: 'get' },
FavroCollection,
);
assertBravoClaim(
Expand Down
4 changes: 2 additions & 2 deletions src/lib/FavroCollection.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { FavroDataCollection } from '../types/FavroApi';
import { DataFavroCollection } from '../types/FavroApi';
import { FavroEntity } from './FavroEntity.js';

export class FavroCollection extends FavroEntity<FavroDataCollection> {
export class FavroCollection extends FavroEntity<DataFavroCollection> {
get name() {
return this._data.name;
}
Expand Down
Loading

0 comments on commit 65c49ed

Please sign in to comment.