Skip to content

Commit a83b1c4

Browse files
authored
Merge pull request #3 from seamapi/basic-structure
2 parents 396edf5 + 2a9ad4a commit a83b1c4

File tree

11 files changed

+811
-708
lines changed

11 files changed

+811
-708
lines changed

package-lock.json

Lines changed: 474 additions & 695 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,23 @@
7070
"node": ">=16.13.0",
7171
"npm": ">= 8.1.0"
7272
},
73+
"peerDependencies": {
74+
"@seamapi/types": "^1.0.0",
75+
"type-fest": "^4.0.0"
76+
},
77+
"peerDependenciesMeta": {
78+
"@seamapi/types": {
79+
"optional": true
80+
},
81+
"type-fest": {
82+
"optional": true
83+
}
84+
},
7385
"dependencies": {
74-
"@seamapi/types": "^1.12.0"
86+
"axios": "^1.5.0"
7587
},
7688
"devDependencies": {
89+
"@seamapi/types": "^1.14.0",
7790
"@types/node": "^18.11.18",
7891
"ava": "^5.0.1",
7992
"c8": "^8.0.0",
@@ -90,6 +103,7 @@
90103
"tsc-alias": "^1.8.2",
91104
"tsup": "^7.2.0",
92105
"tsx": "^3.12.1",
106+
"type-fest": "^4.3.1",
93107
"typescript": "^5.1.0"
94108
}
95109
}

src/lib/seam/connect/auth.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import {
2+
InvalidSeamHttpOptionsError,
3+
isSeamHttpOptionsWithApiKey,
4+
isSeamHttpOptionsWithClientSessionToken,
5+
type SeamHttpOptions,
6+
type SeamHttpOptionsWithApiKey,
7+
type SeamHttpOptionsWithClientSessionToken,
8+
} from './client-options.js'
9+
10+
type Headers = Record<string, string>
11+
12+
export const getAuthHeaders = (options: SeamHttpOptions): Headers => {
13+
if (isSeamHttpOptionsWithApiKey(options)) {
14+
return getAuthHeadersForApiKey(options)
15+
}
16+
17+
if (isSeamHttpOptionsWithClientSessionToken(options)) {
18+
return getAuthHeadersForClientSessionToken(options)
19+
}
20+
21+
throw new InvalidSeamHttpOptionsError(
22+
'Must specify an apiKey or clientSessionToken',
23+
)
24+
}
25+
26+
const getAuthHeadersForApiKey = ({
27+
apiKey,
28+
}: SeamHttpOptionsWithApiKey): Headers => {
29+
if (isClientSessionToken(apiKey)) {
30+
throw new InvalidSeamTokenError(
31+
'A Client Session Token cannot be used as an apiKey',
32+
)
33+
}
34+
35+
if (isAccessToken(apiKey)) {
36+
throw new InvalidSeamTokenError(
37+
'An access token cannot be used as an apiKey',
38+
)
39+
}
40+
41+
if (isJwt(apiKey) || !isSeamToken(apiKey)) {
42+
throw new InvalidSeamTokenError(
43+
`Unknown or invalid apiKey format, expected token to start with ${tokenPrefix}`,
44+
)
45+
}
46+
47+
return {
48+
authorization: `Bearer ${apiKey}`,
49+
}
50+
}
51+
52+
const getAuthHeadersForClientSessionToken = ({
53+
clientSessionToken,
54+
}: SeamHttpOptionsWithClientSessionToken): Headers => {
55+
if (!isClientSessionToken(clientSessionToken)) {
56+
throw new InvalidSeamTokenError(
57+
`Unknown or invalid clientSessionToken format, expected token to start with ${clientSessionTokenPrefix}`,
58+
)
59+
}
60+
61+
return {
62+
authorization: `Bearer ${clientSessionToken}`,
63+
'client-session-token': clientSessionToken,
64+
}
65+
}
66+
67+
export class InvalidSeamTokenError extends Error {
68+
constructor(message: string) {
69+
super(`SeamHttp received an invalid token: ${message}`)
70+
this.name = this.constructor.name
71+
Error.captureStackTrace(this, this.constructor)
72+
}
73+
}
74+
75+
const tokenPrefix = 'seam_'
76+
77+
const clientSessionTokenPrefix = 'seam_cst'
78+
79+
const isClientSessionToken = (token: string): boolean =>
80+
token.startsWith(clientSessionTokenPrefix)
81+
82+
const isAccessToken = (token: string): boolean => token.startsWith('seam_at')
83+
84+
const isJwt = (token: string): boolean => token.startsWith('ey')
85+
86+
const isSeamToken = (token: string): boolean => token.startsWith(tokenPrefix)

src/lib/seam/connect/axios.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import axios, { type Axios } from 'axios'
2+
3+
import { getAuthHeaders } from './auth.js'
4+
import {
5+
isSeamHttpOptionsWithClientSessionToken,
6+
type SeamHttpOptions,
7+
} from './client-options.js'
8+
9+
export const createAxiosClient = (
10+
options: Required<SeamHttpOptions>,
11+
): Axios => {
12+
// TODO: axiosRetry? Allow options to configure this if so
13+
return axios.create({
14+
baseURL: options.endpoint,
15+
withCredentials: isSeamHttpOptionsWithClientSessionToken(options),
16+
...options.axiosOptions,
17+
headers: {
18+
...getAuthHeaders(options),
19+
...options.axiosOptions.headers,
20+
// TODO: User-Agent
21+
},
22+
})
23+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { AxiosRequestConfig } from 'axios'
2+
3+
export type SeamHttpOptions =
4+
| SeamHttpOptionsWithApiKey
5+
| SeamHttpOptionsWithClientSessionToken
6+
7+
interface SeamHttpCommonOptions {
8+
endpoint?: string
9+
axiosOptions?: AxiosRequestConfig
10+
enableLegacyMethodBehaivor?: boolean
11+
}
12+
13+
export interface SeamHttpOptionsWithApiKey extends SeamHttpCommonOptions {
14+
apiKey: string
15+
}
16+
17+
export const isSeamHttpOptionsWithApiKey = (
18+
options: SeamHttpOptions,
19+
): options is SeamHttpOptionsWithApiKey => {
20+
if (!('apiKey' in options)) return false
21+
22+
if ('clientSessionToken' in options && options.clientSessionToken != null) {
23+
throw new InvalidSeamHttpOptionsError(
24+
'The clientSessionToken option cannot be used with the apiKey option.',
25+
)
26+
}
27+
28+
return true
29+
}
30+
31+
export interface SeamHttpOptionsWithClientSessionToken
32+
extends SeamHttpCommonOptions {
33+
clientSessionToken: string
34+
}
35+
36+
export const isSeamHttpOptionsWithClientSessionToken = (
37+
options: SeamHttpOptions,
38+
): options is SeamHttpOptionsWithClientSessionToken => {
39+
if (!('clientSessionToken' in options)) return false
40+
41+
if ('apiKey' in options && options.apiKey != null) {
42+
throw new InvalidSeamHttpOptionsError(
43+
'The clientSessionToken option cannot be used with the apiKey option.',
44+
)
45+
}
46+
47+
return true
48+
}
49+
50+
export class InvalidSeamHttpOptionsError extends Error {
51+
constructor(message: string) {
52+
super(`SeamHttp received invalid options: ${message}`)
53+
this.name = this.constructor.name
54+
Error.captureStackTrace(this, this.constructor)
55+
}
56+
}
57+
58+
// TODO: withSessionToken { sessionToken } or withMultiWorkspaceApiKey { apiKey }?
59+
// export interface SeamHttpOptionsWithSessionToken extends SeamHttpCommonOptions {
60+
// workspaceId: string
61+
// apiKey: string
62+
// }

src/lib/seam/connect/client.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@ import test from 'ava'
33
import { SeamHttp } from './client.js'
44

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

src/lib/seam/connect/client.ts

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,63 @@
1+
import type { Axios } from 'axios'
2+
3+
import { createAxiosClient } from './axios.js'
4+
import {
5+
InvalidSeamHttpOptionsError,
6+
isSeamHttpOptionsWithApiKey,
7+
isSeamHttpOptionsWithClientSessionToken,
8+
type SeamHttpOptions,
9+
type SeamHttpOptionsWithApiKey,
10+
type SeamHttpOptionsWithClientSessionToken,
11+
} from './client-options.js'
12+
import { LegacyWorkspacesHttp } from './legacy/workspaces.js'
13+
import { parseOptions } from './parse-options.js'
14+
import { WorkspacesHttp } from './routes/workspaces.js'
15+
116
export class SeamHttp {
2-
static fromApiKey(_apiKey: string): SeamHttp {
3-
return new SeamHttp()
17+
client: Axios
18+
19+
#legacy: boolean
20+
21+
constructor(apiKeyOrOptions: string | SeamHttpOptions) {
22+
const options = parseOptions(apiKeyOrOptions)
23+
this.#legacy = options.enableLegacyMethodBehaivor
24+
this.client = createAxiosClient(options)
425
}
526

6-
static fromClientSessionToken(): SeamHttp {
7-
return new SeamHttp()
27+
static fromApiKey(
28+
apiKey: SeamHttpOptionsWithApiKey['apiKey'],
29+
options: Omit<SeamHttpOptionsWithApiKey, 'apiKey'> = {},
30+
): SeamHttp {
31+
const opts = { ...options, apiKey }
32+
if (!isSeamHttpOptionsWithApiKey(opts)) {
33+
throw new InvalidSeamHttpOptionsError('Missing apiKey')
34+
}
35+
return new SeamHttp(opts)
836
}
937

10-
static async fromPublishableKey(): Promise<SeamHttp> {
11-
return new SeamHttp()
38+
static fromClientSessionToken(
39+
clientSessionToken: SeamHttpOptionsWithClientSessionToken['clientSessionToken'],
40+
options: Omit<
41+
SeamHttpOptionsWithClientSessionToken,
42+
'clientSessionToken'
43+
> = {},
44+
): SeamHttp {
45+
const opts = { ...options, clientSessionToken }
46+
if (!isSeamHttpOptionsWithClientSessionToken(opts)) {
47+
throw new InvalidSeamHttpOptionsError('Missing clientSessionToken')
48+
}
49+
return new SeamHttp(opts)
1250
}
1351

14-
workspaces = {
15-
async get(): Promise<null> {
16-
return null
17-
},
52+
// TODO
53+
// static fromPublishableKey and deprecate getClientSessionToken
54+
55+
// TODO: Should we keep makeRequest?
56+
// Better to implement error handling and wrapping in an error handler.
57+
// makeRequest
58+
59+
get workspaces(): WorkspacesHttp {
60+
if (this.#legacy) return new LegacyWorkspacesHttp(this.client)
61+
return new WorkspacesHttp(this.client)
1862
}
1963
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { RouteRequestParams, RouteResponse } from '@seamapi/types/connect'
2+
import type { SetNonNullable } from 'type-fest'
3+
4+
import { WorkspacesHttp } from 'lib/seam/connect/routes/workspaces.js'
5+
6+
export class LegacyWorkspacesHttp extends WorkspacesHttp {
7+
override async get(
8+
params: WorkspacesGetParams = {},
9+
): Promise<WorkspacesGetResponse['workspace']> {
10+
const { data } = await this.client.get<WorkspacesGetResponse>(
11+
'/workspaces/get',
12+
{
13+
params,
14+
},
15+
)
16+
return data.workspace
17+
}
18+
}
19+
20+
export type WorkspacesGetParams = SetNonNullable<
21+
Required<RouteRequestParams<'/workspaces/get'>>
22+
>
23+
24+
export type WorkspacesGetResponse = SetNonNullable<
25+
Required<RouteResponse<'/workspaces/get'>>
26+
>

src/lib/seam/connect/parse-options.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { SeamHttpOptions } from './client-options.js'
2+
export const parseOptions = (
3+
apiKeyOrOptions: string | SeamHttpOptions,
4+
): Required<SeamHttpOptions> => {
5+
const options =
6+
typeof apiKeyOrOptions === 'string'
7+
? { apiKey: apiKeyOrOptions }
8+
: apiKeyOrOptions
9+
10+
const endpoint =
11+
options.endpoint ??
12+
globalThis.process?.env?.['SEAM_ENDPOINT'] ??
13+
globalThis.process?.env?.['SEAM_API_URL'] ??
14+
'https://connect.getseam.com'
15+
16+
const apiKey =
17+
'apiKey' in options
18+
? options.apiKey
19+
: globalThis.process?.env?.['SEAM_API_KEY']
20+
21+
return {
22+
...options,
23+
...(apiKey != null ? { apiKey } : {}),
24+
endpoint,
25+
axiosOptions: options.axiosOptions ?? {},
26+
enableLegacyMethodBehaivor: false,
27+
}
28+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { RouteRequestParams, RouteResponse } from '@seamapi/types/connect'
2+
import { Axios } from 'axios'
3+
import type { SetNonNullable } from 'type-fest'
4+
5+
import { createAxiosClient } from 'lib/seam/connect/axios.js'
6+
import type { SeamHttpOptions } from 'lib/seam/connect/client-options.js'
7+
import { parseOptions } from 'lib/seam/connect/parse-options.js'
8+
9+
export class WorkspacesHttp {
10+
client: Axios
11+
12+
constructor(apiKeyOrOptionsOrClient: Axios | string | SeamHttpOptions) {
13+
if (apiKeyOrOptionsOrClient instanceof Axios) {
14+
this.client = apiKeyOrOptionsOrClient
15+
return
16+
}
17+
18+
const options = parseOptions(apiKeyOrOptionsOrClient)
19+
this.client = createAxiosClient(options)
20+
}
21+
22+
async get(
23+
params: WorkspacesGetParams = {},
24+
): Promise<WorkspacesGetResponse['workspace']> {
25+
const { data } = await this.client.get<WorkspacesGetResponse>(
26+
'/workspaces/get',
27+
{
28+
params,
29+
},
30+
)
31+
return data.workspace
32+
}
33+
}
34+
35+
export type WorkspacesGetParams = SetNonNullable<
36+
Required<RouteRequestParams<'/workspaces/get'>>
37+
>
38+
39+
export type WorkspacesGetResponse = SetNonNullable<
40+
Required<RouteResponse<'/workspaces/get'>>
41+
>

0 commit comments

Comments
 (0)