Skip to content

Commit 5ccfd90

Browse files
authored
Connectors | Add getConnection method - for getting access token and connection config (#135)
* Connectors | Add getConnection method - for getting access token and connection config * add deprecation notice on getAccessToken * remove deprecation console warning * add test
1 parent ead5bf3 commit 5ccfd90

3 files changed

Lines changed: 179 additions & 4 deletions

File tree

src/modules/connectors.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AxiosInstance } from "axios";
22
import {
33
ConnectorIntegrationType,
44
ConnectorAccessTokenResponse,
5+
ConnectorConnectionResponse,
56
ConnectorsModule,
67
} from "./connectors.types.js";
78

@@ -18,8 +19,11 @@ export function createConnectorsModule(
1819
appId: string
1920
): ConnectorsModule {
2021
return {
21-
// Retrieve an OAuth access token for a specific external integration type
22-
// @ts-expect-error Return type mismatch with interface - implementation returns object, interface expects string
22+
/**
23+
* Retrieve an OAuth access token for a specific external integration type.
24+
* @deprecated Use getConnection(integrationType) and use the returned accessToken (and connectionConfig when needed) instead.
25+
*/
26+
// @ts-expect-error Return type mismatch with interface - implementation returns string, interface expects string but implementation is typed as ConnectorAccessTokenResponse
2327
async getAccessToken(
2428
integrationType: ConnectorIntegrationType
2529
): Promise<ConnectorAccessTokenResponse> {
@@ -34,5 +38,23 @@ export function createConnectorsModule(
3438
// @ts-expect-error
3539
return response.access_token;
3640
},
41+
42+
async getConnection(
43+
integrationType: ConnectorIntegrationType
44+
): Promise<ConnectorConnectionResponse> {
45+
if (!integrationType || typeof integrationType !== "string") {
46+
throw new Error("Integration type is required and must be a string");
47+
}
48+
49+
const response = await axios.get<ConnectorAccessTokenResponse>(
50+
`/apps/${appId}/external-auth/tokens/${integrationType}`
51+
);
52+
53+
const data = response as unknown as ConnectorAccessTokenResponse;
54+
return {
55+
accessToken: data.access_token,
56+
connectionConfig: data.connection_config ?? null,
57+
};
58+
},
3759
};
3860
}

src/modules/connectors.types.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ export interface ConnectorIntegrationTypeRegistry {}
1010
* ```typescript
1111
* // Using generated connector type names
1212
* // With generated types, you get autocomplete on integration types
13-
* const token = await base44.asServiceRole.connectors.getAccessToken('googlecalendar');
13+
* const connection = await base44.asServiceRole.connectors.getConnection('googlecalendar');
14+
* const token = connection.accessToken;
1415
* ```
1516
*/
1617
export type ConnectorIntegrationType = keyof ConnectorIntegrationTypeRegistry extends never
@@ -22,6 +23,18 @@ export type ConnectorIntegrationType = keyof ConnectorIntegrationTypeRegistry ex
2223
*/
2324
export interface ConnectorAccessTokenResponse {
2425
access_token: string;
26+
integration_type: string;
27+
connection_config: Record<string, string> | null;
28+
}
29+
30+
/**
31+
* Camel-cased connection details returned by {@linkcode ConnectorsModule.getConnection | getConnection()}.
32+
*/
33+
export interface ConnectorConnectionResponse {
34+
/** The OAuth access token for the external service. */
35+
accessToken: string;
36+
/** Key-value configuration for the connection, or `null` if the connector does not provide one. */
37+
connectionConfig: Record<string, string> | null;
2538
}
2639

2740
/**
@@ -40,12 +53,14 @@ export interface ConnectorAccessTokenResponse {
4053
*
4154
* ## Dynamic Types
4255
*
43-
* 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.
56+
* 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.
4457
*/
4558
export interface ConnectorsModule {
4659
/**
4760
* Retrieves an OAuth access token for a specific external integration type.
4861
*
62+
* @deprecated Use {@link getConnection} and use the returned `accessToken` (and `connectionConfig` when needed) instead.
63+
*
4964
* Returns the OAuth token string for an external service that an app builder
5065
* has connected to. This token represents the connected app builder's account
5166
* 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 {
87102
* ```
88103
*/
89104
getAccessToken(integrationType: ConnectorIntegrationType): Promise<string>;
105+
106+
/**
107+
* Retrieves the OAuth access token and connection configuration for a specific external integration type.
108+
*
109+
* Returns both the OAuth token and any additional connection configuration
110+
* that the connector provides. This is useful when the external service requires
111+
* extra parameters beyond the access token (e.g., a shop domain, account ID, or API base URL).
112+
*
113+
* @param integrationType - The type of integration, such as `'googlecalendar'`, `'slack'`, or `'github'`.
114+
* @returns Promise resolving to a {@link ConnectorConnectionResponse} with `accessToken` and `connectionConfig`.
115+
*
116+
* @example
117+
* ```typescript
118+
* // Basic usage
119+
* const connection = await base44.asServiceRole.connectors.getConnection('googlecalendar');
120+
* console.log(connection.accessToken);
121+
* console.log(connection.connectionConfig);
122+
* ```
123+
*
124+
* @example
125+
* ```typescript
126+
* // Shopify: connectionConfig has subdomain (e.g. "my-store" for my-store.myshopify.com)
127+
* const connection = await base44.asServiceRole.connectors.getConnection('shopify');
128+
* const { accessToken, connectionConfig } = connection;
129+
* const shop = connectionConfig?.subdomain
130+
* ? `https://${connectionConfig.subdomain}.myshopify.com`
131+
* : null;
132+
*
133+
* if (shop) {
134+
* const response = await fetch(
135+
* `${shop}/admin/api/2024-01/products.json?limit=10`,
136+
* { headers: { 'X-Shopify-Access-Token': accessToken } }
137+
* );
138+
* const { products } = await response.json();
139+
* }
140+
* ```
141+
*/
142+
getConnection(integrationType: ConnectorIntegrationType): Promise<ConnectorConnectionResponse>;
90143
}

tests/unit/connectors.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
2+
import nock from "nock";
3+
import { createClient } from "../../src/index.ts";
4+
5+
describe("Connectors module – getConnection", () => {
6+
const appId = "test-app-id";
7+
const serverUrl = "https://base44.app";
8+
const serviceToken = "service-token-123";
9+
let base44: ReturnType<typeof createClient>;
10+
let scope: nock.Scope;
11+
12+
beforeEach(() => {
13+
base44 = createClient({
14+
serverUrl,
15+
appId,
16+
serviceToken,
17+
});
18+
scope = nock(serverUrl);
19+
});
20+
21+
afterEach(() => {
22+
nock.cleanAll();
23+
});
24+
25+
test("extracts accessToken and connectionConfig from API response", async () => {
26+
const apiResponse = {
27+
access_token: "oauth-token-abc123",
28+
integration_type: "jira",
29+
connection_config: { subdomain: "my-company" },
30+
};
31+
32+
scope
33+
.get(`/api/apps/${appId}/external-auth/tokens/jira`)
34+
.reply(200, apiResponse);
35+
36+
const connection = await base44.asServiceRole.connectors.getConnection(
37+
"jira"
38+
);
39+
40+
expect(connection).toBeDefined();
41+
expect(connection.accessToken).toBe("oauth-token-abc123");
42+
expect(connection.connectionConfig).toEqual({
43+
subdomain: "my-company",
44+
});
45+
expect(scope.isDone()).toBe(true);
46+
});
47+
48+
test("returns connectionConfig as null when API omits connection_config", async () => {
49+
const apiResponse = {
50+
access_token: "token-only",
51+
integration_type: "slack",
52+
};
53+
54+
scope
55+
.get(`/api/apps/${appId}/external-auth/tokens/slack`)
56+
.reply(200, apiResponse);
57+
58+
const connection = await base44.asServiceRole.connectors.getConnection(
59+
"slack"
60+
);
61+
62+
expect(connection.accessToken).toBe("token-only");
63+
expect(connection.connectionConfig).toBeNull();
64+
expect(scope.isDone()).toBe(true);
65+
});
66+
67+
test("returns connectionConfig as null when API sends null connection_config", async () => {
68+
const apiResponse = {
69+
access_token: "token-only",
70+
integration_type: "github",
71+
connection_config: null,
72+
};
73+
74+
scope
75+
.get(`/api/apps/${appId}/external-auth/tokens/github`)
76+
.reply(200, apiResponse);
77+
78+
const connection = await base44.asServiceRole.connectors.getConnection(
79+
"github"
80+
);
81+
82+
expect(connection.accessToken).toBe("token-only");
83+
expect(connection.connectionConfig).toBeNull();
84+
expect(scope.isDone()).toBe(true);
85+
});
86+
87+
test("throws when integrationType is empty string", async () => {
88+
await expect(
89+
base44.asServiceRole.connectors.getConnection("")
90+
).rejects.toThrow("Integration type is required and must be a string");
91+
});
92+
93+
test("throws when integrationType is not a string", async () => {
94+
await expect(
95+
base44.asServiceRole.connectors.getConnection(
96+
null as unknown as string
97+
)
98+
).rejects.toThrow("Integration type is required and must be a string");
99+
});
100+
});

0 commit comments

Comments
 (0)