diff --git a/src/lib/seam/connect/seam-http-request.ts b/src/lib/seam/connect/seam-http-request.ts index e5b64bbd..357914db 100644 --- a/src/lib/seam/connect/seam-http-request.ts +++ b/src/lib/seam/connect/seam-http-request.ts @@ -46,22 +46,22 @@ export class SeamHttpRequest< public get url(): URL { const { client } = this.#parent - const baseUrl = client.defaults.baseURL - if (baseUrl === undefined) { - throw new Error('baseUrl is required') - } - - const params = this.#config.params - if (params === undefined) { - return new URL(this.#config.path, baseUrl) - } + const { params } = this.#config const serializer = typeof client.defaults.paramsSerializer === 'function' ? client.defaults.paramsSerializer : serializeUrlSearchParams - return new URL(`${this.#config.path}?${serializer(params)}`, baseUrl) + const origin = getUrlPrefix(client.defaults.baseURL ?? '') + + const pathname = this.#config.path.startsWith('/') + ? this.#config.path + : `/${this.#config.path}` + + const path = params == null ? pathname : `${pathname}?${serializer(params)}` + + return new URL(`${origin}${path}`) } public get method(): Method { @@ -128,3 +128,18 @@ export class SeamHttpRequest< return this.execute().then(onfulfilled, onrejected) } } + +const getUrlPrefix = (input: string): string => { + if (URL.canParse(input)) { + const url = new URL(input).toString() + if (url.endsWith('/')) return url.slice(0, -1) + return url + } + if (globalThis.location != null) { + const pathname = input.startsWith('/') ? input : `/${input}` + return new URL(`${globalThis.location.origin}${pathname}`).toString() + } + throw new Error( + `Cannot resolve origin from ${input} in a non-browser environment`, + ) +} diff --git a/test/seam/connect/seam-http-request.test.ts b/test/seam/connect/seam-http-request.test.ts index e9bfa3d1..073d6c2a 100644 --- a/test/seam/connect/seam-http-request.test.ts +++ b/test/seam/connect/seam-http-request.test.ts @@ -5,55 +5,163 @@ import { SeamHttp } from '@seamapi/http/connect' import { SeamHttpRequest } from 'lib/seam/connect/seam-http-request.js' -test('returns a SeamHttpRequest', async (t) => { +test('SeamHttp: returns a SeamHttpRequest', async (t) => { const { seed, endpoint } = await getTestServer(t) const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) - const deviceRequest = seam.devices.get({ device_id: seed.august_device_1 }) + const request = seam.devices.get({ device_id: seed.august_device_1 }) - t.true(deviceRequest instanceof SeamHttpRequest) - t.is(deviceRequest.url.pathname, '/devices/get') - t.deepEqual(deviceRequest.body, { + t.true(request instanceof SeamHttpRequest) + t.truthy(request.url) + t.is(request.responseKey, 'device') + t.deepEqual(request.body, { device_id: seed.august_device_1, }) - t.is(deviceRequest.responseKey, 'device') - const device = await deviceRequest + + const device = await request t.is(device.workspace_id, seed.seed_workspace_1) t.is(device.device_id, seed.august_device_1) // Ensure that the type of the response is correct. - type Expected = ResponseFromSeamHttpRequest - + type Expected = ResponseFromSeamHttpRequest const validDeviceType: Expected['device_type'] = 'august_lock' t.truthy(validDeviceType) - // @ts-expect-error because it's an invalid device type. + // @ts-expect-error invalid device type. const invalidDeviceType: Expected['device_type'] = 'invalid_device_type' t.truthy(invalidDeviceType) }) -test("populates SeamHttpRequest's url property", async (t) => { +test('SeamHttpRequest: url is a URL for post requests', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) + + const { url } = seam.devices.get({ device_id: 'abc123' }) + + t.true(url instanceof URL) + t.deepEqual( + toPlainUrlObject(url), + toPlainUrlObject(new URL(`${endpoint}/devices/get`)), + ) +}) + +test('SeamHttpRequest: url is a URL for get requests', async (t) => { const { seed, endpoint } = await getTestServer(t) const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) - const deviceRequest = seam.devices.get({ device_id: 'abc123' }) + const { url } = seam.connectWebviews.view({ + connect_webview_id: 'connect_webview_1', + auth_token: 'auth_token_1', + }) + + t.true(url instanceof URL) + t.deepEqual( + toPlainUrlObject(url), + toPlainUrlObject( + new URL( + `${endpoint}/connect_webviews/view?auth_token=auth_token_1&connect_webview_id=connect_webview_1`, + ), + ), + ) +}) + +test('SeamHttpRequest: url is a URL when endpoint is a url without a path', async (t) => { + const { seed } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { + endpoint: 'https://example.com', + }) + + const { url } = seam.devices.get({ device_id: 'abc123' }) - t.is(deviceRequest.url.pathname, '/devices/get') - t.is(deviceRequest.url.search, '') + t.true(url instanceof URL) + t.deepEqual( + toPlainUrlObject(url), + toPlainUrlObject(new URL('https://example.com/devices/get')), + ) +}) - const connectWebviewsViewRequest = seam.connectWebviews.view({ - connect_webview_id: 'abc123', - auth_token: 'invalid', +test('SeamHttpRequest: url is a URL when endpoint is a url with a path', async (t) => { + const { seed } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { + endpoint: 'https://example.com/some/sub/path', }) - t.is(connectWebviewsViewRequest.url.pathname, '/connect_webviews/view') - t.is(connectWebviewsViewRequest.url.searchParams.get('auth_token'), 'invalid') - t.is( - connectWebviewsViewRequest.url.searchParams.get('connect_webview_id'), - 'abc123', + const { url } = seam.devices.get({ device_id: 'abc123' }) + + t.true(url instanceof URL) + t.deepEqual( + toPlainUrlObject(url), + toPlainUrlObject(new URL('https://example.com/some/sub/path/devices/get')), ) }) +test.failing( + 'SeamHttpRequest: url is a URL when endpoint is path', + async (t) => { + const { seed } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { + endpoint: '/some/sub/path', + }) + + const { url } = seam.devices.get({ device_id: 'abc123' }) + + t.true(url instanceof URL) + t.deepEqual( + toPlainUrlObject(url), + toPlainUrlObject( + new URL('https://example.com/some/sub/path/devices/get'), + ), + ) + }, +) + +test.failing( + 'SeamHttpRequest: url is a URL when endpoint is empty', + async (t) => { + const { seed } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { + endpoint: '', + }) + + // TODO: Set globalThis.location.origin = 'https://example.com' + + const { url } = seam.devices.get({ device_id: 'abc123' }) + + t.true(url instanceof URL) + t.deepEqual( + toPlainUrlObject(url), + toPlainUrlObject(new URL('https://example.com/devices/get')), + ) + }, +) + +test('SeamHttpRequest: url throws if unable to resolve origin', async (t) => { + const { seed } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { + endpoint: '', + }) + + const request = seam.devices.get({ device_id: 'abc123' }) + + t.throws(() => request.url, { message: /Cannot resolve origin/ }) +}) + +const toPlainUrlObject = (url: URL): Omit => { + return { + pathname: url.pathname, + hash: url.hash, + hostname: url.hostname, + protocol: url.protocol, + username: url.username, + port: url.port, + password: url.password, + host: url.host, + href: url.href, + origin: url.origin, + search: url.search, + } +} + type ResponseFromSeamHttpRequest = T extends SeamHttpRequest ? TResponseKey extends keyof TResponse