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)
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'))
})
101 changes: 91 additions & 10 deletions src/lib/seam/connect/client.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,100 @@
import axios, { type Axios } from 'axios'

import { getAuthHeaders } from './auth.js'
import {
InvalidSeamHttpOptionsError,
isSeamHttpOptionsWithApiKey,
isSeamHttpOptionsWithClientSessionToken,
type SeamHttpOptions,
type SeamHttpOptionsWithApiKey,
type SeamHttpOptionsWithClientSessionToken,
} from './client-options.js'
import { LegacyWorkspaces } from './legacy/workspaces.js'
import { Workspaces } 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(
typeof apiKeyOrOptions === 'string'
? { apiKey: apiKeyOrOptions }
: apiKeyOrOptions,
)

this.#legacy = options.enableLegacyMethodBehaivor

// TODO: axiosRetry? Allow options to configure this if so
this.client = axios.create({
baseURL: options.endpoint,
withCredentials: isSeamHttpOptionsWithClientSessionToken(options),
...options.axiosOptions,
headers: {
...getAuthHeaders(options),
...options.axiosOptions.headers,
// TODO: User-Agent
},
})
}

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(): Workspaces {
const workspaces = new Workspaces(this.client)
if (this.#legacy) return new LegacyWorkspaces(this.client)
return workspaces
}
}

const parseOptions = (options: SeamHttpOptions): Required<SeamHttpOptions> => {
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,
}
}
31 changes: 31 additions & 0 deletions src/lib/seam/connect/legacy/workspaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// TODO: Example of non-generated overrides to methods to preserve legacy behavior
import type { Routes } from '@seamapi/types/connect'
import type { SetNonNullable } from 'type-fest'

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

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

export type WorkspacesGetParams = SetNonNullable<
Required<Routes['/workspaces/get']['commonParams']>
>

export type WorkspacesGetResponse = SetNonNullable<
Required<Routes['/workspaces/get']['jsonResponse']>
>

// UPSTREAM: Should come from @seamapi/types/connect
// import type { Workspace } from @seamapi/types
// export type { Workspace } from '@seamapi/types/connect'
export interface Workspace {
workspace_id: string
}
36 changes: 36 additions & 0 deletions src/lib/seam/connect/routes/workspaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// TODO: This file is generated from route spec
import type { Routes } from '@seamapi/types/connect'
import type { Axios } from 'axios'
import type { SetNonNullable } from 'type-fest'

export class Workspaces {
razor-x marked this conversation as resolved.
Show resolved Hide resolved
client: Axios

constructor(client: Axios) {
this.client = client
}

async get(params: WorkspacesGetParams = {}): Promise<Workspace> {
const {
data: { workspace },
razor-x marked this conversation as resolved.
Show resolved Hide resolved
} = await this.client.get<WorkspacesGetResponse>('/workspaces/get', {
params,
})
return workspace
}
}

export type WorkspacesGetParams = SetNonNullable<
Required<Routes['/workspaces/get']['commonParams']>
>

export type WorkspacesGetResponse = SetNonNullable<
razor-x marked this conversation as resolved.
Show resolved Hide resolved
Required<Routes['/workspaces/get']['jsonResponse']>
>
razor-x marked this conversation as resolved.
Show resolved Hide resolved

// UPSTREAM: Should come from @seamapi/types/connect
// import type { Workspace } from @seamapi/types
razor-x marked this conversation as resolved.
Show resolved Hide resolved
// export type { Workspace } from '@seamapi/types/connect'
export interface Workspace {
workspace_id: string
}
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'))
})