Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Basic client structure #3

Merged
merged 14 commits into from
Sep 22, 2023
1,169 changes: 474 additions & 695 deletions package-lock.json

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,23 @@
"node": ">=16.13.0",
"npm": ">= 8.1.0"
},
"peerDependencies": {
"@seamapi/types": "^1.0.0",
"type-fest": "^4.0.0"
},
"peerDependenciesMeta": {
"@seamapi/types": {
"optional": true
},
"type-fest": {
"optional": true
}
},
"dependencies": {
"@seamapi/types": "^1.12.0"
"axios": "^1.5.0"
codetheweb marked this conversation as resolved.
Show resolved Hide resolved
},
"devDependencies": {
"@seamapi/types": "^1.14.0",
"@types/node": "^18.11.18",
"ava": "^5.0.1",
"c8": "^8.0.0",
Expand All @@ -90,6 +103,7 @@
"tsc-alias": "^1.8.2",
"tsup": "^7.2.0",
"tsx": "^3.12.1",
"type-fest": "^4.3.1",
"typescript": "^5.1.0"
}
}
86 changes: 86 additions & 0 deletions src/lib/seam/connect/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
InvalidSeamHttpOptionsError,
isSeamHttpOptionsWithApiKey,
isSeamHttpOptionsWithClientSessionToken,
type SeamHttpOptions,
type SeamHttpOptionsWithApiKey,
type SeamHttpOptionsWithClientSessionToken,
} from './client-options.js'

type Headers = Record<string, string>

export const getAuthHeaders = (options: SeamHttpOptions): Headers => {
if (isSeamHttpOptionsWithApiKey(options)) {
return getAuthHeadersForApiKey(options)
}

if (isSeamHttpOptionsWithClientSessionToken(options)) {
return getAuthHeadersForClientSessionToken(options)
}

throw new InvalidSeamHttpOptionsError(
'Must specify an apiKey or clientSessionToken',
)
}

const getAuthHeadersForApiKey = ({
apiKey,
}: SeamHttpOptionsWithApiKey): Headers => {
if (isClientSessionToken(apiKey)) {
throw new InvalidSeamTokenError(
'A Client Session Token cannot be used as an apiKey',
)
}

if (isAccessToken(apiKey)) {
throw new InvalidSeamTokenError(
'An access token cannot be used as an apiKey',
)
}

if (isJwt(apiKey) || !isSeamToken(apiKey)) {
throw new InvalidSeamTokenError(
`Unknown or invalid apiKey format, expected token to start with ${tokenPrefix}`,
)
}

return {
authorization: `Bearer ${apiKey}`,
}
}

const getAuthHeadersForClientSessionToken = ({
clientSessionToken,
}: SeamHttpOptionsWithClientSessionToken): Headers => {
if (!isClientSessionToken(clientSessionToken)) {
throw new InvalidSeamTokenError(
`Unknown or invalid clientSessionToken format, expected token to start with ${clientSessionTokenPrefix}`,
)
}

return {
authorization: `Bearer ${clientSessionToken}`,
'client-session-token': clientSessionToken,
}
}

export class InvalidSeamTokenError extends Error {
constructor(message: string) {
super(`SeamHttp received an invalid token: ${message}`)
this.name = this.constructor.name
Error.captureStackTrace(this, this.constructor)
}
}

const tokenPrefix = 'seam_'

const clientSessionTokenPrefix = 'seam_cst'

const isClientSessionToken = (token: string): boolean =>
token.startsWith(clientSessionTokenPrefix)

const isAccessToken = (token: string): boolean => token.startsWith('seam_at')

const isJwt = (token: string): boolean => token.startsWith('ey')

const isSeamToken = (token: string): boolean => token.startsWith(tokenPrefix)
23 changes: 23 additions & 0 deletions src/lib/seam/connect/axios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import axios, { type Axios } from 'axios'

import { getAuthHeaders } from './auth.js'
import {
isSeamHttpOptionsWithClientSessionToken,
type SeamHttpOptions,
} from './client-options.js'

export const createAxiosClient = (
options: Required<SeamHttpOptions>,
): Axios => {
// TODO: axiosRetry? Allow options to configure this if so
return axios.create({
baseURL: options.endpoint,
withCredentials: isSeamHttpOptionsWithClientSessionToken(options),
...options.axiosOptions,
headers: {
...getAuthHeaders(options),
...options.axiosOptions.headers,
// TODO: User-Agent
},
})
}
62 changes: 62 additions & 0 deletions src/lib/seam/connect/client-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { AxiosRequestConfig } from 'axios'

export type SeamHttpOptions =
| SeamHttpOptionsWithApiKey
| SeamHttpOptionsWithClientSessionToken

interface SeamHttpCommonOptions {
endpoint?: string
axiosOptions?: AxiosRequestConfig
enableLegacyMethodBehaivor?: boolean
}

export interface SeamHttpOptionsWithApiKey extends SeamHttpCommonOptions {
apiKey: string
}

export const isSeamHttpOptionsWithApiKey = (
options: SeamHttpOptions,
): options is SeamHttpOptionsWithApiKey => {
if (!('apiKey' in options)) return false

if ('clientSessionToken' in options && options.clientSessionToken != null) {
throw new InvalidSeamHttpOptionsError(
'The clientSessionToken option cannot be used with the apiKey option.',
)
}

return true
}

export interface SeamHttpOptionsWithClientSessionToken
extends SeamHttpCommonOptions {
clientSessionToken: string
}

export const isSeamHttpOptionsWithClientSessionToken = (
options: SeamHttpOptions,
): options is SeamHttpOptionsWithClientSessionToken => {
if (!('clientSessionToken' in options)) return false

if ('apiKey' in options && options.apiKey != null) {
throw new InvalidSeamHttpOptionsError(
'The clientSessionToken option cannot be used with the apiKey option.',
)
}

return true
}

export class InvalidSeamHttpOptionsError extends Error {
constructor(message: string) {
super(`SeamHttp received invalid options: ${message}`)
this.name = this.constructor.name
Error.captureStackTrace(this, this.constructor)
}
}

// TODO: withSessionToken { sessionToken } or withMultiWorkspaceApiKey { apiKey }?
// export interface SeamHttpOptionsWithSessionToken extends SeamHttpCommonOptions {
// workspaceId: string
// apiKey: string
// }
2 changes: 1 addition & 1 deletion src/lib/seam/connect/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ import test from 'ava'
import { SeamHttp } from './client.js'

test('SeamHttp: fromApiKey', (t) => {
t.truthy(SeamHttp.fromApiKey('some-api-key'))
t.truthy(SeamHttp.fromApiKey('seam_some-api-key'))
})
64 changes: 54 additions & 10 deletions src/lib/seam/connect/client.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,63 @@
import type { Axios } from 'axios'

import { createAxiosClient } from './axios.js'
import {
InvalidSeamHttpOptionsError,
isSeamHttpOptionsWithApiKey,
isSeamHttpOptionsWithClientSessionToken,
type SeamHttpOptions,
type SeamHttpOptionsWithApiKey,
type SeamHttpOptionsWithClientSessionToken,
} from './client-options.js'
import { LegacyWorkspacesHttp } from './legacy/workspaces.js'
import { parseOptions } from './parse-options.js'
import { WorkspacesHttp } from './routes/workspaces.js'

export class SeamHttp {
static fromApiKey(_apiKey: string): SeamHttp {
return new SeamHttp()
client: Axios

#legacy: boolean

constructor(apiKeyOrOptions: string | SeamHttpOptions) {
const options = parseOptions(apiKeyOrOptions)
this.#legacy = options.enableLegacyMethodBehaivor
this.client = createAxiosClient(options)
}

static fromClientSessionToken(): SeamHttp {
return new SeamHttp()
static fromApiKey(
apiKey: SeamHttpOptionsWithApiKey['apiKey'],
options: Omit<SeamHttpOptionsWithApiKey, 'apiKey'> = {},
): SeamHttp {
const opts = { ...options, apiKey }
if (!isSeamHttpOptionsWithApiKey(opts)) {
throw new InvalidSeamHttpOptionsError('Missing apiKey')
}
return new SeamHttp(opts)
}

static async fromPublishableKey(): Promise<SeamHttp> {
return new SeamHttp()
static fromClientSessionToken(
clientSessionToken: SeamHttpOptionsWithClientSessionToken['clientSessionToken'],
options: Omit<
SeamHttpOptionsWithClientSessionToken,
'clientSessionToken'
> = {},
): SeamHttp {
const opts = { ...options, clientSessionToken }
if (!isSeamHttpOptionsWithClientSessionToken(opts)) {
throw new InvalidSeamHttpOptionsError('Missing clientSessionToken')
}
return new SeamHttp(opts)
}

workspaces = {
async get(): Promise<null> {
return null
},
// TODO
// static fromPublishableKey and deprecate getClientSessionToken

// TODO: Should we keep makeRequest?
// Better to implement error handling and wrapping in an error handler.
// makeRequest

get workspaces(): WorkspacesHttp {
if (this.#legacy) return new LegacyWorkspacesHttp(this.client)
return new WorkspacesHttp(this.client)
}
}
26 changes: 26 additions & 0 deletions src/lib/seam/connect/legacy/workspaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { RouteRequestParams, RouteResponse } from '@seamapi/types/connect'
import type { SetNonNullable } from 'type-fest'

import { WorkspacesHttp } from 'lib/seam/connect/routes/workspaces.js'

export class LegacyWorkspacesHttp extends WorkspacesHttp {
override async get(
params: WorkspacesGetParams = {},
): Promise<WorkspacesGetResponse['workspace']> {
const { data } = await this.client.get<WorkspacesGetResponse>(
'/workspaces/get',
{
params,
},
)
return data.workspace
}
}

export type WorkspacesGetParams = SetNonNullable<
Required<RouteRequestParams<'/workspaces/get'>>
>

export type WorkspacesGetResponse = SetNonNullable<
Required<RouteResponse<'/workspaces/get'>>
>
28 changes: 28 additions & 0 deletions src/lib/seam/connect/parse-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { SeamHttpOptions } from './client-options.js'
export const parseOptions = (
apiKeyOrOptions: string | SeamHttpOptions,
): Required<SeamHttpOptions> => {
const options =
typeof apiKeyOrOptions === 'string'
? { apiKey: apiKeyOrOptions }
: apiKeyOrOptions

const endpoint =
options.endpoint ??
globalThis.process?.env?.['SEAM_ENDPOINT'] ??
globalThis.process?.env?.['SEAM_API_URL'] ??
'https://connect.getseam.com'

const apiKey =
'apiKey' in options
? options.apiKey
: globalThis.process?.env?.['SEAM_API_KEY']

return {
...options,
...(apiKey != null ? { apiKey } : {}),
endpoint,
axiosOptions: options.axiosOptions ?? {},
enableLegacyMethodBehaivor: false,
}
}
41 changes: 41 additions & 0 deletions src/lib/seam/connect/routes/workspaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { RouteRequestParams, RouteResponse } from '@seamapi/types/connect'
import { Axios } from 'axios'
import type { SetNonNullable } from 'type-fest'

import { createAxiosClient } from 'lib/seam/connect/axios.js'
import type { SeamHttpOptions } from 'lib/seam/connect/client-options.js'
import { parseOptions } from 'lib/seam/connect/parse-options.js'

export class WorkspacesHttp {
client: Axios

constructor(apiKeyOrOptionsOrClient: Axios | string | SeamHttpOptions) {
if (apiKeyOrOptionsOrClient instanceof Axios) {
this.client = apiKeyOrOptionsOrClient
return
}

const options = parseOptions(apiKeyOrOptionsOrClient)
this.client = createAxiosClient(options)
}

async get(
params: WorkspacesGetParams = {},
): Promise<WorkspacesGetResponse['workspace']> {
const { data } = await this.client.get<WorkspacesGetResponse>(
'/workspaces/get',
{
params,
},
)
return data.workspace
}
}

export type WorkspacesGetParams = SetNonNullable<
Required<RouteRequestParams<'/workspaces/get'>>
>

export type WorkspacesGetResponse = SetNonNullable<
razor-x marked this conversation as resolved.
Show resolved Hide resolved
Required<RouteResponse<'/workspaces/get'>>
>
2 changes: 1 addition & 1 deletion test/seam/connect/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ import test from 'ava'
import { SeamHttp } from '@seamapi/http/connect'

test('SeamHttp: fromApiKey', (t) => {
t.truthy(SeamHttp.fromApiKey('some-api-key'))
t.truthy(SeamHttp.fromApiKey('seam_some-api-key'))
})