diff --git a/.eslintrc b/.eslintrc index 30b7014..7f6be1d 100644 --- a/.eslintrc +++ b/.eslintrc @@ -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", @@ -55,9 +47,7 @@ "@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", @@ -65,12 +55,10 @@ }, "overrides": [ { - "files": [ - "*.js" - ], + "files": ["*.js"], "rules": { "@typescript-eslint/no-var-requires": "off" } } ] -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index c09ca32..2ea29b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -910,6 +910,12 @@ "is-obj": "^2.0.0" } }, + "dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "dev": true + }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2217,7 +2223,8 @@ "dependencies": { "hosted-git-info": { "version": "2.8.8", - "resolved": "", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", + "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==", "dev": true }, "read-pkg": { diff --git a/package.json b/package.json index 039d54e..292332e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli/cli-subcommand.ts b/src/cli/cli-subcommand.ts index ccf6195..054d0e0 100644 --- a/src/cli/cli-subcommand.ts +++ b/src/cli/cli-subcommand.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import cli from 'commander'; +import { program as cli } from 'commander'; // Kick it off cli diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 9f48f3b..f3f420a 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -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(); diff --git a/src/index.ts b/src/index.ts index 6789214..e172f0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ export * from './lib/BravoClient.js'; +export * from './types/FavroApi.js'; diff --git a/src/lib/BravoClient.ts b/src/lib/BravoClient.ts index ff010c6..730b94b 100644 --- a/src/lib/BravoClient.ts +++ b/src/lib/BravoClient.ts @@ -1,34 +1,43 @@ 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; } } @@ -36,7 +45,7 @@ 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) */ @@ -55,20 +64,35 @@ export class BravoClient { */ private _backendId?: string; - constructor(options: { + private _organizations?: FavroDataOrganization[]; + + private _users?: FavroUser[]; + + 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() { @@ -86,7 +110,7 @@ export class BravoClient { * * @param url Relative to the base URL {@link https://favro.com/api/v1} */ - async request( + async request( url: string, options?: { method?: FavroApiMethod | Capitalize; @@ -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`); @@ -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 `, - 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 `, + 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, // Force it to assume no undefineds + body: options?.body, + }); + const favroRes = new FavroResponse( + res, + (await res.json()) as FavroResponseData, + ); + 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('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('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) { diff --git a/src/lib/FavroResponse.ts b/src/lib/FavroResponse.ts new file mode 100644 index 0000000..0fd841a --- /dev/null +++ b/src/lib/FavroResponse.ts @@ -0,0 +1,38 @@ +import { Response } from 'node-fetch'; +import { AnyEntity, FavroResponseData } from '../types/FavroApi'; + +export class FavroResponse { + private _response: Response; + private _entities: Entity[]; + + constructor(response: Response, data: FavroResponseData) { + this._response = response; + this._entities = 'entities' in data ? data.entities : [data]; + // Could be a PAGED response (with an entities field) or not! + // Normalize to always have the data be an array + } + + 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')!); + } + + get entities() { + return [...this._entities]; + } +} diff --git a/src/lib/utility.ts b/src/lib/utility.ts new file mode 100644 index 0000000..62fcc6c --- /dev/null +++ b/src/lib/utility.ts @@ -0,0 +1,10 @@ +import { assertBravoClaim } from './errors.js'; + +export function stringsMatchIgnoringCase(string1: string, string2: string) { + for (const val of [string1, string2]) { + assertBravoClaim(typeof val == 'string', 'All inputs must be strings'); + } + return ( + string1.toLocaleLowerCase().trim() == string2.toLocaleLowerCase().trim() + ); +} diff --git a/src/test/client.ts b/src/test/client.ts new file mode 100644 index 0000000..f35aa56 --- /dev/null +++ b/src/test/client.ts @@ -0,0 +1,95 @@ +import { BravoClient } from '@/BravoClient.js'; +import { expect } from 'chai'; +import fs from 'fs-extra'; +import dotenv from 'dotenv'; + +export class BravoTestError extends Error { + constructor(message: string) { + super(message); + this.name = 'BravoTestError'; + Error.captureStackTrace(this, this.constructor); + } +} + +export function assertBravoTestClaim( + claim: any, + message = 'Assertion failed', +): asserts claim { + if (!claim) { + throw new BravoTestError(message); + } +} + +/** + * @note A root .env file must be populated with the required + * env vars in order to run tests! + */ +dotenv.config(); + +const organizationName = process.env.FAVRO_ORGANIZATION_NAME!; +const myUserEmail = process.env.FAVRO_USER_EMAIL!; + +const sandboxRoot = './sandbox'; +const samplesRoot = './samples'; + +/** + * Clone any files in a "./samples" folder into + * a "./sandbox" folder, overwriting any files + * currently in there. This is useful for allowing + * your test suite to make changes to files without + * changing the originals, so that you can easily + * reset back to an original state prior to running a test. + */ +function resetSandbox() { + if (!fs.existsSync(samplesRoot)) { + // Then no samples exist, and no sandbox needed + return; + } + fs.ensureDirSync(sandboxRoot); + fs.emptyDirSync(sandboxRoot); + fs.copySync(samplesRoot, sandboxRoot); +} + +describe('BravoClient', function () { + // Use a single client for tests, so that we can + // do some caching and avoid hitting the strict + // rate limits. + + const client = new BravoClient(); + + before(function () { + resetSandbox(); + }); + + it('can list organizations', async function () { + const organizations = await client.listOrganizations(); + expect(organizations.length).to.be.greaterThan(0); + }); + + it('can find a specific organization and set it as the current one', async function () { + const org = await client.findOrganizationByName( + process.env.FAVRO_ORGANIZATION_NAME!, + ); + assertBravoTestClaim(org, 'Org not found'); + assertBravoTestClaim( + org.organizationId, + 'Organization ID not found on org data', + ); + client.organizationId = org.organizationId; + assertBravoTestClaim(org.organizationId == client.organizationId); + }); + + it('can find all users for an organization, including self', async function () { + await client.setOrganizationIdByName(organizationName); + const partialUsers = await client.listPartialUsers(); + expect(partialUsers.length, 'has partial users').to.be.greaterThan(0); + const fullUsers = await client.listFullUsers(); + expect(fullUsers.length, 'has full users').to.be.greaterThan(0); + const me = fullUsers.find((u) => u.email == myUserEmail); + assertBravoTestClaim(me, 'Current user somehow not found in org'); + }); + + after(function () { + resetSandbox(); + }); +}); diff --git a/src/test/index.ts b/src/test/index.ts deleted file mode 100644 index 27c9201..0000000 --- a/src/test/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @file Test suite, using Mocha and Chai. - * Compiled files inside the 'test' folder are excluded from - * published npm projects. - * (Note that fs-extra is added as a dev dependency to make - * sandbox setup much easier. If you aren't using a sandbox - * you can remove this dependency. If you need fs-extra for - * your main code, move it into the regular 'dependencies' - * section of your package.json file) - */ - -import {expect} from "chai"; -import fs from "fs-extra"; - -const sandboxRoot = "./sandbox"; -const samplesRoot = "./samples"; - -/** - * Clone any files in a "./samples" folder into - * a "./sandbox" folder, overwriting any files - * currently in there. This is useful for allowing - * your test suite to make changes to files without - * changing the originals, so that you can easily - * reset back to an original state prior to running a test. - */ -function resetSandbox() { - if(!fs.existsSync(samplesRoot)){ - // Then no samples exist, and no sandbox needed - return; - } - fs.ensureDirSync(sandboxRoot); - fs.emptyDirSync(sandboxRoot); - fs.copySync(samplesRoot, sandboxRoot); -} - -describe("Test Suite", function () { - - before(function(){ - resetSandbox(); - }); - - describe("Test Group", function () { - it("can do something", function () { - resetSandbox(); - expect(false).to.be.true; - }); - }); - - after(function(){ - resetSandbox(); - }); - -}); diff --git a/src/types/FavroApi.ts b/src/types/FavroApi.ts new file mode 100644 index 0000000..e4ab397 --- /dev/null +++ b/src/types/FavroApi.ts @@ -0,0 +1,35 @@ +export type FavroApiMethod = 'get' | 'post' | 'put' | 'delete'; +type FavroRole = + | 'administrator' + | 'fullMember' + | 'externalMember' + | 'guest' + | 'disabled'; + +export interface FavroDataOrganization { + organizationId: string; + name: string; + sharedToUsers: { + userId: string; + role: FavroRole; + joinDate: string; + }[]; +} + +export interface FavroDataOrganizationUser { + userId: string; + name: string; + email: string; + organizationRole: FavroRole; +} +export type AnyEntity = Record; +interface FavroResponsePaged { + limit: number; + page: number; + pages: number; + requestId: string; + entities: Entity[]; +} +export type FavroResponseData = + | FavroResponsePaged + | Entity; diff --git a/src/types/index.ts b/src/types/index.ts deleted file mode 100644 index ac2899b..0000000 --- a/src/types/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** @file Typescript typing information - * If you want to break your types out from your code, you can - * place them into the 'types' folder. Note that if you using - * the type declaration extention ('.d.ts') your files will not - * be compiled -- if you need to deliver your types to consumers - * of a published npm module use the '.ts' extension instead. - */ \ No newline at end of file