From 89efac63640c8380c3e8de9f454b097e1c346523 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Fri, 10 Nov 2023 12:34:10 -0800 Subject: [PATCH] Add SeamHttpMultiWorkspace client --- src/lib/seam/connect/auth.ts | 38 ++++++--- src/lib/seam/connect/index.ts | 1 + src/lib/seam/connect/options.ts | 77 +++++++++++++++---- src/lib/seam/connect/parse-options.ts | 7 +- .../seam/connect/seam-http-multi-workspace.ts | 77 +++++++++++++++++++ test/seam/connect/client.test.ts | 54 +++++++++++++ .../connect/console-session-token.test.ts | 61 ++++++++++++++- .../connect/personal-access-token.test.ts | 68 +++++++++++++++- 8 files changed, 351 insertions(+), 32 deletions(-) create mode 100644 src/lib/seam/connect/seam-http-multi-workspace.ts diff --git a/src/lib/seam/connect/auth.ts b/src/lib/seam/connect/auth.ts index 56894a53..0d71eeb0 100644 --- a/src/lib/seam/connect/auth.ts +++ b/src/lib/seam/connect/auth.ts @@ -1,9 +1,13 @@ import { + isSeamHttpMultiWorkspaceOptionsWithConsoleSessionToken, + isSeamHttpMultiWorkspaceOptionsWithPersonalAccessToken, isSeamHttpOptionsWithApiKey, isSeamHttpOptionsWithClientSessionToken, isSeamHttpOptionsWithConsoleSessionToken, isSeamHttpOptionsWithPersonalAccessToken, SeamHttpInvalidOptionsError, + type SeamHttpMultiWorkspaceOptionsWithConsoleSessionToken, + type SeamHttpMultiWorkspaceOptionsWithPersonalAccessToken, type SeamHttpOptionsWithApiKey, type SeamHttpOptionsWithClientSessionToken, type SeamHttpOptionsWithConsoleSessionToken, @@ -26,11 +30,17 @@ export const getAuthHeaders = (options: Options): Headers => { return getAuthHeadersForClientSessionToken(options) } - if (isSeamHttpOptionsWithConsoleSessionToken(options)) { + if ( + isSeamHttpMultiWorkspaceOptionsWithConsoleSessionToken(options) || + isSeamHttpOptionsWithConsoleSessionToken(options) + ) { return getAuthHeadersForConsoleSessionToken(options) } - if (isSeamHttpOptionsWithPersonalAccessToken(options)) { + if ( + isSeamHttpMultiWorkspaceOptionsWithPersonalAccessToken(options) || + isSeamHttpOptionsWithPersonalAccessToken(options) + ) { return getAuthHeadersForPersonalAccessToken(options) } @@ -40,8 +50,8 @@ export const getAuthHeaders = (options: Options): Headers => { 'an apiKey,', 'clientSessionToken,', 'publishableKey,', - 'consoleSessionToken with a workspaceId', - 'or personalAccessToken with a workspaceId', + 'consoleSessionToken', + 'or personalAccessToken', ].join(' '), ) } @@ -117,8 +127,12 @@ const getAuthHeadersForClientSessionToken = ({ const getAuthHeadersForConsoleSessionToken = ({ consoleSessionToken, - workspaceId, -}: SeamHttpOptionsWithConsoleSessionToken): Headers => { + ...options +}: + | SeamHttpMultiWorkspaceOptionsWithConsoleSessionToken + | SeamHttpOptionsWithConsoleSessionToken): Headers => { + const workspaceId = 'workspaceId' in options ? options.workspaceId : undefined + if (isAccessToken(consoleSessionToken)) { throw new SeamHttpInvalidTokenError( 'An Access Token cannot be used as a consoleSessionToken', @@ -145,14 +159,18 @@ const getAuthHeadersForConsoleSessionToken = ({ return { authorization: `Bearer ${consoleSessionToken}`, - 'seam-workspace-id': workspaceId, + ...(workspaceId != null ? { 'seam-workspace-id': workspaceId } : {}), } } const getAuthHeadersForPersonalAccessToken = ({ personalAccessToken, - workspaceId, -}: SeamHttpOptionsWithPersonalAccessToken): Headers => { + ...options +}: + | SeamHttpMultiWorkspaceOptionsWithPersonalAccessToken + | SeamHttpOptionsWithPersonalAccessToken): Headers => { + const workspaceId = 'workspaceId' in options ? options.workspaceId : undefined + if (isJwt(personalAccessToken)) { throw new SeamHttpInvalidTokenError( 'A JWT cannot be used as a personalAccessToken', @@ -179,7 +197,7 @@ const getAuthHeadersForPersonalAccessToken = ({ return { authorization: `Bearer ${personalAccessToken}`, - 'seam-workspace-id': workspaceId, + ...(workspaceId != null ? { 'seam-workspace-id': workspaceId } : {}), } } diff --git a/src/lib/seam/connect/index.ts b/src/lib/seam/connect/index.ts index 8dc2fc60..0deaa60c 100644 --- a/src/lib/seam/connect/index.ts +++ b/src/lib/seam/connect/index.ts @@ -4,4 +4,5 @@ export * from './options.js' export * from './routes/index.js' export * from './seam-http.js' export * from './seam-http-error.js' +export * from './seam-http-multi-workspace.js' export * from 'lib/params-serializer.js' diff --git a/src/lib/seam/connect/options.ts b/src/lib/seam/connect/options.ts index 3ad38dfa..432a7db8 100644 --- a/src/lib/seam/connect/options.ts +++ b/src/lib/seam/connect/options.ts @@ -1,5 +1,10 @@ import type { Client, ClientOptions } from './client.js' +export type SeamHttpMultiWorkspaceOptions = + | SeamHttpMultiWorkspaceOptionsWithClient + | SeamHttpMultiWorkspaceOptionsWithConsoleSessionToken + | SeamHttpMultiWorkspaceOptionsWithPersonalAccessToken + export type SeamHttpOptions = | SeamHttpOptionsFromEnv | SeamHttpOptionsWithClient @@ -17,6 +22,14 @@ export interface SeamHttpFromPublishableKeyOptions export interface SeamHttpOptionsFromEnv extends SeamHttpCommonOptions {} +export interface SeamHttpMultiWorkspaceOptionsWithClient { + client: Client +} + +export const isSeamHttpMultiWorkspaceOptionsWithClient = ( + options: SeamHttpOptions, +): options is SeamHttpOptionsWithClient => isSeamHttpOptionsWithClient(options) + export interface SeamHttpOptionsWithClient { client: Client } @@ -102,24 +115,17 @@ export const isSeamHttpOptionsWithClientSessionToken = ( return true } -export interface SeamHttpOptionsWithConsoleSessionToken +export interface SeamHttpMultiWorkspaceOptionsWithConsoleSessionToken extends SeamHttpCommonOptions { consoleSessionToken: string - workspaceId: string } -export const isSeamHttpOptionsWithConsoleSessionToken = ( +export const isSeamHttpMultiWorkspaceOptionsWithConsoleSessionToken = ( options: SeamHttpOptions, -): options is SeamHttpOptionsWithConsoleSessionToken => { +): options is SeamHttpMultiWorkspaceOptionsWithConsoleSessionToken => { if (!('consoleSessionToken' in options)) return false if (options.consoleSessionToken == null) return false - if (!('workspaceId' in options) || options.workspaceId == null) { - throw new SeamHttpInvalidOptionsError( - 'Must pass a workspaceId when using a consoleSessionToken', - ) - } - if ('apiKey' in options && options.apiKey != null) { throw new SeamHttpInvalidOptionsError( 'The apiKey option cannot be used with the consoleSessionToken option', @@ -141,24 +147,38 @@ export const isSeamHttpOptionsWithConsoleSessionToken = ( return true } -export interface SeamHttpOptionsWithPersonalAccessToken +export interface SeamHttpOptionsWithConsoleSessionToken extends SeamHttpCommonOptions { - personalAccessToken: string + consoleSessionToken: string workspaceId: string } -export const isSeamHttpOptionsWithPersonalAccessToken = ( +export const isSeamHttpOptionsWithConsoleSessionToken = ( options: SeamHttpOptions, -): options is SeamHttpOptionsWithPersonalAccessToken => { - if (!('personalAccessToken' in options)) return false - if (options.personalAccessToken == null) return false +): options is SeamHttpOptionsWithConsoleSessionToken => { + if (!isSeamHttpMultiWorkspaceOptionsWithConsoleSessionToken(options)) + return false if (!('workspaceId' in options) || options.workspaceId == null) { throw new SeamHttpInvalidOptionsError( - 'Must pass a workspaceId when using a personalAccessToken', + 'Must pass a workspaceId when using a consoleSessionToken', ) } + return true +} + +export interface SeamHttpMultiWorkspaceOptionsWithPersonalAccessToken + extends SeamHttpCommonOptions { + personalAccessToken: string +} + +export const isSeamHttpMultiWorkspaceOptionsWithPersonalAccessToken = ( + options: SeamHttpOptions, +): options is SeamHttpMultiWorkspaceOptionsWithPersonalAccessToken => { + if (!('personalAccessToken' in options)) return false + if (options.personalAccessToken == null) return false + if ('apiKey' in options && options.apiKey != null) { throw new SeamHttpInvalidOptionsError( 'The apiKey option cannot be used with the personalAccessToken option', @@ -180,6 +200,27 @@ export const isSeamHttpOptionsWithPersonalAccessToken = ( return true } +export interface SeamHttpOptionsWithPersonalAccessToken + extends SeamHttpCommonOptions { + personalAccessToken: string + workspaceId: string +} + +export const isSeamHttpOptionsWithPersonalAccessToken = ( + options: SeamHttpOptions, +): options is SeamHttpOptionsWithPersonalAccessToken => { + if (!isSeamHttpMultiWorkspaceOptionsWithPersonalAccessToken(options)) + return false + + if (!('workspaceId' in options) || options.workspaceId == null) { + throw new SeamHttpInvalidOptionsError( + 'Must pass a workspaceId when using a personalAccessToken', + ) + } + + return true +} + export class SeamHttpInvalidOptionsError extends Error { constructor(message: string) { super(`SeamHttp received invalid options: ${message}`) @@ -187,3 +228,5 @@ export class SeamHttpInvalidOptionsError extends Error { Error.captureStackTrace(this, this.constructor) } } + +export class SeamHttpMultiWorkspaceInvalidOptionsError extends SeamHttpInvalidOptionsError {} diff --git a/src/lib/seam/connect/parse-options.ts b/src/lib/seam/connect/parse-options.ts index 16125f64..9322e15d 100644 --- a/src/lib/seam/connect/parse-options.ts +++ b/src/lib/seam/connect/parse-options.ts @@ -3,8 +3,10 @@ import version from 'lib/version.js' import { getAuthHeaders } from './auth.js' import type { ClientOptions } from './client.js' import { + isSeamHttpMultiWorkspaceOptionsWithClient, isSeamHttpOptionsWithClient, isSeamHttpOptionsWithClientSessionToken, + type SeamHttpMultiWorkspaceOptions, type SeamHttpOptions, } from './options.js' @@ -15,7 +17,9 @@ const sdkHeaders = { 'seam-sdk-version': version, } -export type Options = SeamHttpOptions & { publishableKey?: string } +export type Options = + | SeamHttpMultiWorkspaceOptions + | (SeamHttpOptions & { publishableKey?: string }) export const parseOptions = ( apiKeyOrOptions: string | Options, @@ -23,6 +27,7 @@ export const parseOptions = ( const options = getNormalizedOptions(apiKeyOrOptions) if (isSeamHttpOptionsWithClient(options)) return options + if (isSeamHttpMultiWorkspaceOptionsWithClient(options)) return options return { axiosOptions: { diff --git a/src/lib/seam/connect/seam-http-multi-workspace.ts b/src/lib/seam/connect/seam-http-multi-workspace.ts new file mode 100644 index 00000000..ffb7e061 --- /dev/null +++ b/src/lib/seam/connect/seam-http-multi-workspace.ts @@ -0,0 +1,77 @@ +import { type Client, createClient } from './client.js' +import { SeamHttpWorkspaces } from './routes/index.js' +import { + isSeamHttpMultiWorkspaceOptionsWithClient, + isSeamHttpMultiWorkspaceOptionsWithConsoleSessionToken, + isSeamHttpMultiWorkspaceOptionsWithPersonalAccessToken, + SeamHttpMultiWorkspaceInvalidOptionsError, + type SeamHttpMultiWorkspaceOptions, + type SeamHttpMultiWorkspaceOptionsWithClient, + type SeamHttpMultiWorkspaceOptionsWithConsoleSessionToken, + type SeamHttpMultiWorkspaceOptionsWithPersonalAccessToken, +} from './options.js' +import { parseOptions } from './parse-options.js' + +export class SeamHttpMultiWorkspace { + client: Client + + constructor(options: SeamHttpMultiWorkspaceOptions) { + const clientOptions = parseOptions(options) + this.client = createClient(clientOptions) + } + + static fromClient( + client: SeamHttpMultiWorkspaceOptionsWithClient['client'], + options: Omit = {}, + ): SeamHttpMultiWorkspace { + const constructorOptions = { ...options, client } + if (!isSeamHttpMultiWorkspaceOptionsWithClient(constructorOptions)) { + throw new SeamHttpMultiWorkspaceInvalidOptionsError('Missing client') + } + return new SeamHttpMultiWorkspace(constructorOptions) + } + + static fromConsoleSessionToken( + consoleSessionToken: SeamHttpMultiWorkspaceOptionsWithConsoleSessionToken['consoleSessionToken'], + options: Omit< + SeamHttpMultiWorkspaceOptionsWithConsoleSessionToken, + 'consoleSessionToken' + > = {}, + ): SeamHttpMultiWorkspace { + const constructorOptions = { ...options, consoleSessionToken } + if ( + !isSeamHttpMultiWorkspaceOptionsWithConsoleSessionToken( + constructorOptions, + ) + ) { + throw new SeamHttpMultiWorkspaceInvalidOptionsError( + 'Missing consoleSessionToken', + ) + } + return new SeamHttpMultiWorkspace(constructorOptions) + } + + static fromPersonalAccessToken( + personalAccessToken: SeamHttpMultiWorkspaceOptionsWithPersonalAccessToken['personalAccessToken'], + options: Omit< + SeamHttpMultiWorkspaceOptionsWithPersonalAccessToken, + 'personalAccessToken' + > = {}, + ): SeamHttpMultiWorkspace { + const constructorOptions = { ...options, personalAccessToken } + if ( + !isSeamHttpMultiWorkspaceOptionsWithPersonalAccessToken( + constructorOptions, + ) + ) { + throw new SeamHttpMultiWorkspaceInvalidOptionsError( + 'Missing personalAccessToken', + ) + } + return new SeamHttpMultiWorkspace(constructorOptions) + } + + get workspaces(): SeamHttpWorkspaces { + return SeamHttpWorkspaces.fromClient(this.client) + } +} diff --git a/test/seam/connect/client.test.ts b/test/seam/connect/client.test.ts index d8dede05..74ffec73 100644 --- a/test/seam/connect/client.test.ts +++ b/test/seam/connect/client.test.ts @@ -6,6 +6,8 @@ import { type DevicesListParams, type DevicesListResponse, SeamHttp, + SeamHttpMultiWorkspace, + type WorkspacesListResponse, } from '@seamapi/http/connect' test('SeamHttp: fromClient returns instance that uses client', async (t) => { @@ -105,3 +107,55 @@ test('SeamHttp: merges axios headers when creating client', async (t) => { t.is(device.workspace_id, seed.seed_workspace_1) t.is(device.device_id, seed.august_device_1) }) + +// UPSTREAM: Fake does not support personal access token. +// https://github.com/seamapi/fake-seam-connect/issues/126 +test.failing( + 'SeamHttpMultiWorkspace: fromClient returns instance that uses client', + async (t) => { + const { endpoint } = await getTestServer(t) + const seam = SeamHttpMultiWorkspace.fromClient( + SeamHttpMultiWorkspace.fromPersonalAccessToken('seam_at_TODO', { + endpoint, + }).client, + ) + const workspaces = await seam.workspaces.list() + t.true(workspaces.length > 0) + }, +) + +// UPSTREAM: Fake does not support personal access token. +// https://github.com/seamapi/fake-seam-connect/issues/126 +test.failing( + 'SeamHttpMultiWorkspace: constructor returns instance that uses client', + async (t) => { + const { endpoint } = await getTestServer(t) + const seam = new SeamHttpMultiWorkspace({ + client: SeamHttpMultiWorkspace.fromPersonalAccessToken('seam_at_TODO', { + endpoint, + }).client, + }) + const workspaces = await seam.workspaces.list() + t.true(workspaces.length > 0) + }, +) + +// UPSTREAM: Fake does not support personal access token. +// https://github.com/seamapi/fake-seam-connect/issues/126 +test.failing( + 'SeamHttpMultiWorkspace: can use client to make requests', + async (t) => { + const { endpoint } = await getTestServer(t) + const seam = new SeamHttpMultiWorkspace({ + client: SeamHttpMultiWorkspace.fromPersonalAccessToken('seam_at_TODO', { + endpoint, + }).client, + }) + const { + data: { workspaces }, + status, + } = await seam.client.get('/workspaces/list') + t.is(status, 200) + t.true(workspaces.length > 0) + }, +) diff --git a/test/seam/connect/console-session-token.test.ts b/test/seam/connect/console-session-token.test.ts index a642f453..4728abea 100644 --- a/test/seam/connect/console-session-token.test.ts +++ b/test/seam/connect/console-session-token.test.ts @@ -1,7 +1,11 @@ import test from 'ava' import { getTestServer } from 'fixtures/seam/connect/api.js' -import { SeamHttp, SeamHttpInvalidTokenError } from '@seamapi/http/connect' +import { + SeamHttp, + SeamHttpInvalidTokenError, + SeamHttpMultiWorkspace, +} from '@seamapi/http/connect' // UPSTREAM: Fake does not support JWT. // https://github.com/seamapi/fake-seam-connect/issues/124 @@ -69,3 +73,58 @@ test('SeamHttp: checks consoleSessionToken format', (t) => { message: /Access Token/, }) }) + +// UPSTREAM: Fake does not support JWT. +// https://github.com/seamapi/fake-seam-connect/issues/124 +test.failing( + 'SeamHttpMultiWorkspace: fromConsoleSessionToken returns instance authorized with consoleSessionToken', + async (t) => { + const { endpoint } = await getTestServer(t) + const seam = SeamHttpMultiWorkspace.fromConsoleSessionToken('ey_TODO', { + endpoint, + }) + const workspaces = await seam.workspaces.list() + t.true(workspaces.length > 0) + }, +) + +// UPSTREAM: Fake does not support JWT. +// https://github.com/seamapi/fake-seam-connect/issues/124 +test.failing( + 'SeamHttpMultiWorkspace: constructor returns instance authorized with consoleSessionToken', + async (t) => { + const { endpoint } = await getTestServer(t) + const seam = new SeamHttpMultiWorkspace({ + consoleSessionToken: 'ey_TODO', + endpoint, + }) + const workspaces = await seam.workspaces.list() + t.true(workspaces.length > 0) + }, +) + +test('SeamHttpMultiWorkspace: checks consoleSessionToken format', (t) => { + t.throws( + () => + SeamHttpMultiWorkspace.fromConsoleSessionToken('some-invalid-key-format'), + { + instanceOf: SeamHttpInvalidTokenError, + message: /Unknown/, + }, + ) + t.throws( + () => SeamHttpMultiWorkspace.fromConsoleSessionToken('seam_apikey_token'), + { + instanceOf: SeamHttpInvalidTokenError, + message: /Unknown/, + }, + ) + t.throws(() => SeamHttpMultiWorkspace.fromConsoleSessionToken('seam_cst'), { + instanceOf: SeamHttpInvalidTokenError, + message: /Client Session Token/, + }) + t.throws(() => SeamHttpMultiWorkspace.fromConsoleSessionToken('seam_at'), { + instanceOf: SeamHttpInvalidTokenError, + message: /Access Token/, + }) +}) diff --git a/test/seam/connect/personal-access-token.test.ts b/test/seam/connect/personal-access-token.test.ts index 94b56eab..66f5a390 100644 --- a/test/seam/connect/personal-access-token.test.ts +++ b/test/seam/connect/personal-access-token.test.ts @@ -1,7 +1,11 @@ import test from 'ava' import { getTestServer } from 'fixtures/seam/connect/api.js' -import { SeamHttp, SeamHttpInvalidTokenError } from '@seamapi/http/connect' +import { + SeamHttp, + SeamHttpInvalidTokenError, + SeamHttpMultiWorkspace, +} from '@seamapi/http/connect' // UPSTREAM: Fake does not support personal access token. // https://github.com/seamapi/fake-seam-connect/issues/126 @@ -10,7 +14,7 @@ test.failing( async (t) => { const { seed, endpoint } = await getTestServer(t) const seam = SeamHttp.fromPersonalAccessToken( - 'at_TODO', + 'seam_at_TODO', seed.seed_workspace_1, { endpoint, @@ -31,7 +35,7 @@ test.failing( async (t) => { const { seed, endpoint } = await getTestServer(t) const seam = new SeamHttp({ - personalAccessToken: 'at_TODO', + personalAccessToken: 'seam_at_TODO', workspaceId: seed.seed_workspace_1, endpoint, }) @@ -69,3 +73,61 @@ test('SeamHttp: checks personalAccessToken format', (t) => { message: /JWT/, }) }) + +// UPSTREAM: Fake does not support personal access token. +// https://github.com/seamapi/fake-seam-connect/issues/126 +test.failing( + 'SeamHttpMultiWorkspace: fromPersonalAccessToken returns instance authorized with personalAccessToken', + async (t) => { + const { endpoint } = await getTestServer(t) + const seam = SeamHttpMultiWorkspace.fromPersonalAccessToken( + 'seam_at_TODO', + { + endpoint, + }, + ) + const workspaces = await seam.workspaces.list() + t.true(workspaces.length > 0) + }, +) + +// UPSTREAM: Fake does not support personal access token. +// https://github.com/seamapi/fake-seam-connect/issues/126 +test.failing( + 'SeamHttpMultiWorkspace: constructor returns instance authorized with personalAccessToken', + async (t) => { + const { endpoint } = await getTestServer(t) + const seam = new SeamHttpMultiWorkspace({ + personalAccessToken: 'seam_at_TODO', + endpoint, + }) + const workspaces = await seam.workspaces.list() + t.true(workspaces.length > 0) + }, +) + +test('SeamHttpMultiWorkspace: checks personalAccessToken format', (t) => { + t.throws( + () => + SeamHttpMultiWorkspace.fromPersonalAccessToken('some-invalid-key-format'), + { + instanceOf: SeamHttpInvalidTokenError, + message: /Unknown/, + }, + ) + t.throws( + () => SeamHttpMultiWorkspace.fromPersonalAccessToken('seam_apikey_token'), + { + instanceOf: SeamHttpInvalidTokenError, + message: /Unknown/, + }, + ) + t.throws(() => SeamHttpMultiWorkspace.fromPersonalAccessToken('seam_cst'), { + instanceOf: SeamHttpInvalidTokenError, + message: /Client Session Token/, + }) + t.throws(() => SeamHttpMultiWorkspace.fromPersonalAccessToken('ey'), { + instanceOf: SeamHttpInvalidTokenError, + message: /JWT/, + }) +})