diff --git a/src/modules/connectors.ts b/src/modules/connectors.ts index 5d99a3c..2d3ab19 100644 --- a/src/modules/connectors.ts +++ b/src/modules/connectors.ts @@ -2,6 +2,7 @@ import { AxiosInstance } from "axios"; import { ConnectorIntegrationType, ConnectorAccessTokenResponse, + ConnectorConnectionResponse, ConnectorsModule, } from "./connectors.types.js"; @@ -18,8 +19,11 @@ export function createConnectorsModule( appId: string ): ConnectorsModule { return { - // Retrieve an OAuth access token for a specific external integration type - // @ts-expect-error Return type mismatch with interface - implementation returns object, interface expects string + /** + * Retrieve an OAuth access token for a specific external integration type. + * @deprecated Use getConnection(integrationType) and use the returned accessToken (and connectionConfig when needed) instead. + */ + // @ts-expect-error Return type mismatch with interface - implementation returns string, interface expects string but implementation is typed as ConnectorAccessTokenResponse async getAccessToken( integrationType: ConnectorIntegrationType ): Promise { @@ -34,5 +38,23 @@ export function createConnectorsModule( // @ts-expect-error return response.access_token; }, + + async getConnection( + integrationType: ConnectorIntegrationType + ): Promise { + if (!integrationType || typeof integrationType !== "string") { + throw new Error("Integration type is required and must be a string"); + } + + const response = await axios.get( + `/apps/${appId}/external-auth/tokens/${integrationType}` + ); + + const data = response as unknown as ConnectorAccessTokenResponse; + return { + accessToken: data.access_token, + connectionConfig: data.connection_config ?? null, + }; + }, }; } diff --git a/src/modules/connectors.types.ts b/src/modules/connectors.types.ts index 46f9e92..9360f3d 100644 --- a/src/modules/connectors.types.ts +++ b/src/modules/connectors.types.ts @@ -10,7 +10,8 @@ export interface ConnectorIntegrationTypeRegistry {} * ```typescript * // Using generated connector type names * // With generated types, you get autocomplete on integration types - * const token = await base44.asServiceRole.connectors.getAccessToken('googlecalendar'); + * const connection = await base44.asServiceRole.connectors.getConnection('googlecalendar'); + * const token = connection.accessToken; * ``` */ export type ConnectorIntegrationType = keyof ConnectorIntegrationTypeRegistry extends never @@ -22,6 +23,18 @@ export type ConnectorIntegrationType = keyof ConnectorIntegrationTypeRegistry ex */ export interface ConnectorAccessTokenResponse { access_token: string; + integration_type: string; + connection_config: Record | null; +} + +/** + * Camel-cased connection details returned by {@linkcode ConnectorsModule.getConnection | getConnection()}. + */ +export interface ConnectorConnectionResponse { + /** The OAuth access token for the external service. */ + accessToken: string; + /** Key-value configuration for the connection, or `null` if the connector does not provide one. */ + connectionConfig: Record | null; } /** @@ -40,12 +53,14 @@ export interface ConnectorAccessTokenResponse { * * ## Dynamic Types * - * If you're working in a TypeScript project, you can generate types from your app's connector configurations to get autocomplete on integration type names when calling `getAccessToken()`. See the [Dynamic Types](/developers/references/sdk/getting-started/dynamic-types) guide to get started. + * If you're working in a TypeScript project, you can generate types from your app's connector configurations to get autocomplete on integration type names when calling `getConnection()`. See the [Dynamic Types](/developers/references/sdk/getting-started/dynamic-types) guide to get started. */ export interface ConnectorsModule { /** * Retrieves an OAuth access token for a specific external integration type. * + * @deprecated Use {@link getConnection} and use the returned `accessToken` (and `connectionConfig` when needed) instead. + * * Returns the OAuth token string for an external service that an app builder * has connected to. This token represents the connected app builder's account * and can be used to make authenticated API calls to that external service on behalf of the app. @@ -87,4 +102,42 @@ export interface ConnectorsModule { * ``` */ getAccessToken(integrationType: ConnectorIntegrationType): Promise; + + /** + * Retrieves the OAuth access token and connection configuration for a specific external integration type. + * + * Returns both the OAuth token and any additional connection configuration + * that the connector provides. This is useful when the external service requires + * extra parameters beyond the access token (e.g., a shop domain, account ID, or API base URL). + * + * @param integrationType - The type of integration, such as `'googlecalendar'`, `'slack'`, or `'github'`. + * @returns Promise resolving to a {@link ConnectorConnectionResponse} with `accessToken` and `connectionConfig`. + * + * @example + * ```typescript + * // Basic usage + * const connection = await base44.asServiceRole.connectors.getConnection('googlecalendar'); + * console.log(connection.accessToken); + * console.log(connection.connectionConfig); + * ``` + * + * @example + * ```typescript + * // Shopify: connectionConfig has subdomain (e.g. "my-store" for my-store.myshopify.com) + * const connection = await base44.asServiceRole.connectors.getConnection('shopify'); + * const { accessToken, connectionConfig } = connection; + * const shop = connectionConfig?.subdomain + * ? `https://${connectionConfig.subdomain}.myshopify.com` + * : null; + * + * if (shop) { + * const response = await fetch( + * `${shop}/admin/api/2024-01/products.json?limit=10`, + * { headers: { 'X-Shopify-Access-Token': accessToken } } + * ); + * const { products } = await response.json(); + * } + * ``` + */ + getConnection(integrationType: ConnectorIntegrationType): Promise; } diff --git a/tests/unit/connectors.test.ts b/tests/unit/connectors.test.ts new file mode 100644 index 0000000..47d5761 --- /dev/null +++ b/tests/unit/connectors.test.ts @@ -0,0 +1,100 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import nock from "nock"; +import { createClient } from "../../src/index.ts"; + +describe("Connectors module – getConnection", () => { + const appId = "test-app-id"; + const serverUrl = "https://base44.app"; + const serviceToken = "service-token-123"; + let base44: ReturnType; + let scope: nock.Scope; + + beforeEach(() => { + base44 = createClient({ + serverUrl, + appId, + serviceToken, + }); + scope = nock(serverUrl); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + test("extracts accessToken and connectionConfig from API response", async () => { + const apiResponse = { + access_token: "oauth-token-abc123", + integration_type: "jira", + connection_config: { subdomain: "my-company" }, + }; + + scope + .get(`/api/apps/${appId}/external-auth/tokens/jira`) + .reply(200, apiResponse); + + const connection = await base44.asServiceRole.connectors.getConnection( + "jira" + ); + + expect(connection).toBeDefined(); + expect(connection.accessToken).toBe("oauth-token-abc123"); + expect(connection.connectionConfig).toEqual({ + subdomain: "my-company", + }); + expect(scope.isDone()).toBe(true); + }); + + test("returns connectionConfig as null when API omits connection_config", async () => { + const apiResponse = { + access_token: "token-only", + integration_type: "slack", + }; + + scope + .get(`/api/apps/${appId}/external-auth/tokens/slack`) + .reply(200, apiResponse); + + const connection = await base44.asServiceRole.connectors.getConnection( + "slack" + ); + + expect(connection.accessToken).toBe("token-only"); + expect(connection.connectionConfig).toBeNull(); + expect(scope.isDone()).toBe(true); + }); + + test("returns connectionConfig as null when API sends null connection_config", async () => { + const apiResponse = { + access_token: "token-only", + integration_type: "github", + connection_config: null, + }; + + scope + .get(`/api/apps/${appId}/external-auth/tokens/github`) + .reply(200, apiResponse); + + const connection = await base44.asServiceRole.connectors.getConnection( + "github" + ); + + expect(connection.accessToken).toBe("token-only"); + expect(connection.connectionConfig).toBeNull(); + expect(scope.isDone()).toBe(true); + }); + + test("throws when integrationType is empty string", async () => { + await expect( + base44.asServiceRole.connectors.getConnection("") + ).rejects.toThrow("Integration type is required and must be a string"); + }); + + test("throws when integrationType is not a string", async () => { + await expect( + base44.asServiceRole.connectors.getConnection( + null as unknown as string + ) + ).rejects.toThrow("Integration type is required and must be a string"); + }); +});