Skip to content

Commit

Permalink
Merge pull request #3 from seamapi/basic-structure
Browse files Browse the repository at this point in the history
  • Loading branch information
razor-x authored Sep 22, 2023
2 parents 396edf5 + 2a9ad4a commit a83b1c4
Show file tree
Hide file tree
Showing 11 changed files with 811 additions and 708 deletions.
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"
},
"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<
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'))
})

0 comments on commit a83b1c4

Please sign in to comment.