Skip to content

Commit 141f521

Browse files
ChenMachBaseclaude
andauthored
feat(connectors): callApi for metered connectors (#256)
* feat(connectors): callApi for metered connectors Some connectors are backed by paid third-party APIs that charge Base44 per call. For those the OAuth token is not available to app code — getConnection and its siblings reject with a 403 — because the Base44 proxy is the only place those calls can be counted. Adds the three proxy methods that replace them: - callApi(integrationType, request) — shared platform connector - callWorkspaceApi(connectorId, request) — workspace-registered connector - callCurrentAppUserApi(connectorId, request) — per-app-user connector Each mirrors its getConnection counterpart, so the identifier you already use carries over. Two deliberate shape decisions: - An upstream 4xx/5xx resolves with `success: false` and the provider's own `status`/`data` rather than throwing. It is a normal outcome of a call Base44 completed and billed; only Base44-side failures (no connection, credits exhausted, a rejected request) reject. - `query` is always sent, never dropped. The server prices the merged query string, so a client that accepted the field and then discarded it would make the quoted price and the real request disagree. Responses carry `creditsCharged` so callers can see what a call actually cost, and the module docs call out that cost varies sharply by endpoint — an expensive call inside a loop is the failure mode worth warning about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(connectors): callApi only — metering follows whose OAuth app it is Drops callWorkspaceApi and callCurrentAppUserApi. They implied that workspace-registered and app user connectors can be metered, and they can't: both run on the workspace's *own* OAuth app, so the provider invoices the workspace directly. Proxying them would have billed the customer credits on top of a vendor bill they already pay. Only a platform connector runs on Base44's OAuth app, so callApi is the only one of the three that ever had something to meter. The backend's matching routes are gone too (base44-dev/apper#19753). The module docs now say which connectors this applies to and, more usefully, why the other two don't — so the next person doesn't re-add the methods on the assumption they were an oversight. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(connectors): align proxy response contract * feat(connectors): cover host selection and binary responses in callApi The proxy grew two capabilities after this module was written, and `proxyCall` builds its payload field by field — so `host` was dropped on the floor even when a caller passed it, and a binary response arrived with `data: null` and no way to reach the bytes. - `host` is forwarded when set and omitted when not, so the proxy applies the connector's declared default rather than receiving an explicit null. - `dataBase64` / `contentType` are mapped from the wire, for the media types a connector declares as binary. Set instead of `data`, never alongside it. - `body` no longer claims to be ignored for DELETE; the proxy forwards it, and several provider APIs require it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8c2ea8e commit 141f521

9 files changed

Lines changed: 570 additions & 4 deletions

File tree

scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-returns.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,10 @@ export function extractSignatureInfo(
128128
for (let i = 0; i < lines.length; i++) {
129129
const line = lines[i];
130130
// Match function signature: > **methodName**(...): `returnType` or `returnType`\<`generic`\>
131+
// Method-level generics appear between the bold name and arguments.
131132
// Handle both simple types and generic types like `Promise`\<`any`\> or `Promise`\<[`TypeName`](link)\>
132133
const sigMatch = line.match(
133-
/^>\s*\*\*(\w+)\*\*\([^)]*\):\s*`([^`]+)`(?:\\<(.+?)\\>)?/
134+
/^>\s*\*\*(\w+)\*\*(?:\\<.*?\\>)?\([^)]*\):\s*`([^`]+)`(?:\\<(.+?)\\>)?/
134135
);
135136
if (sigMatch) {
136137
const methodName = sigMatch[1];
@@ -368,7 +369,9 @@ function rewriteReturnSections(content, options) {
368369
let sigLineIdx = i - 2; // Go back past the Returns heading
369370
while (
370371
sigLineIdx >= 0 &&
371-
!lines[sigLineIdx].match(/^>\s*\*\*\w+\*\*\(/)
372+
!lines[sigLineIdx].match(
373+
/^>\s*\*\*\w+\*\*(?:\\<.*?\\>)?\(/
374+
)
372375
) {
373376
sigLineIdx--;
374377
}
@@ -467,7 +470,9 @@ function rewriteReturnSections(content, options) {
467470
let sigLineIdx = i - 2; // Go back past the Returns heading
468471
while (
469472
sigLineIdx >= 0 &&
470-
!lines[sigLineIdx].match(/^>\s*\*\*\w+\*\*\(/)
473+
!lines[sigLineIdx].match(
474+
/^>\s*\*\*\w+\*\*(?:\\<.*?\\>)?\(/
475+
)
471476
) {
472477
sigLineIdx--;
473478
}

scripts/mintlify-post-processing/types-to-expose.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
"AnalyticsModule",
88
"AppLogsModule",
99
"AuthModule",
10+
"ConnectorApiRequest",
11+
"ConnectorApiResponse",
12+
"ConnectorApiResponsePhase",
1013
"ConnectorIntegrationType",
1114
"ConnectorIntegrationTypeRegistry",
1215
"ConnectorsModule",

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ export { Actor, type Conn } from "./actor.js";
125125
export type {
126126
ConnectorsModule,
127127
UserConnectorsModule,
128+
ConnectorApiRequest,
129+
ConnectorApiResponse,
130+
ConnectorApiResponsePhase,
128131
} from "./modules/connectors.types.js";
129132

130133
export type {

src/modules/connectors.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,24 @@ import { AxiosInstance } from "axios";
22
import {
33
ConnectorIntegrationType,
44
ConnectorAccessTokenResponse,
5+
ConnectorApiRequest,
6+
ConnectorApiResponse,
57
ConnectorConnectionResponse,
8+
ConnectorProxyRawResponse,
69
AppUserConnectorConnectionResponse,
710
ConnectorsModule,
811
UserConnectorsModule,
912
} from "./connectors.types.js";
1013

14+
const CONNECTOR_API_METHODS = new Set([
15+
"GET",
16+
"POST",
17+
"PUT",
18+
"PATCH",
19+
"DELETE",
20+
"HEAD",
21+
]);
22+
1123
/**
1224
* Creates the Connectors module for the Base44 SDK.
1325
*
@@ -112,6 +124,73 @@ export function createConnectorsModule(
112124
connectionConfig: data.connection_config ?? null,
113125
};
114126
},
127+
128+
async callApi<T = unknown>(
129+
integrationType: ConnectorIntegrationType,
130+
request: ConnectorApiRequest
131+
): Promise<ConnectorApiResponse<T>> {
132+
assertNonEmptyString(integrationType, "Integration type");
133+
return proxyCall<T>(
134+
axios,
135+
`/apps/${appId}/connectors/${integrationType}/call`,
136+
request
137+
);
138+
},
139+
};
140+
}
141+
142+
function assertNonEmptyString(value: unknown, label: string): void {
143+
if (!value || typeof value !== "string") {
144+
throw new Error(`${label} is required and must be a string`);
145+
}
146+
}
147+
148+
/**
149+
* POST a request to the connector proxy and normalize the response.
150+
*
151+
* The proxy reports upstream outcomes in the body rather than as HTTP status, so
152+
* a provider 4xx/5xx arrives here as a resolved response with `success: false` —
153+
* only Base44-side failures reject through the axios error interceptor.
154+
*
155+
* @internal
156+
*/
157+
async function proxyCall<T>(
158+
axios: AxiosInstance,
159+
url: string,
160+
request: ConnectorApiRequest
161+
): Promise<ConnectorApiResponse<T>> {
162+
if (!request || typeof request !== "object") {
163+
throw new Error("Request is required and must be an object");
164+
}
165+
assertNonEmptyString(request.path, "Request path");
166+
const method = request.method ?? "GET";
167+
if (!CONNECTOR_API_METHODS.has(method)) {
168+
throw new Error(
169+
"Request method must be one of GET, POST, PUT, PATCH, DELETE, or HEAD"
170+
);
171+
}
172+
173+
const response = await axios.post(url, {
174+
method,
175+
// Omitted rather than sent as null so the proxy applies the connector's
176+
// declared default host.
177+
...(request.host === undefined ? {} : { host: request.host }),
178+
path: request.path,
179+
query: request.query ?? {},
180+
headers: request.headers ?? {},
181+
body: request.body ?? null,
182+
});
183+
184+
const data = response as unknown as ConnectorProxyRawResponse;
185+
return {
186+
success: data.success,
187+
phase: data.phase,
188+
status: data.status_code ?? null,
189+
data: data.data as T,
190+
dataBase64: data.data_base64 ?? null,
191+
contentType: data.content_type ?? null,
192+
headers: data.headers ?? {},
193+
creditsCharged: data.credits_charged ?? 0,
115194
};
116195
}
117196

src/modules/connectors.types.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,89 @@ export interface AppUserConnectorConnectionResponse {
4848
connectionConfig: Record<string, string> | null;
4949
}
5050

51+
/**
52+
* How far a metered connector call progressed through the Base44 proxy.
53+
*
54+
* Only `not_sent` proves that the provider did not execute the request.
55+
* `timed_out` and `sent_unconfirmed` may have executed upstream, so do not
56+
* automatically retry non-idempotent requests based on those phases.
57+
*/
58+
export type ConnectorApiResponsePhase =
59+
| "not_sent"
60+
| "responded"
61+
| "timed_out"
62+
| "sent_unconfirmed";
63+
64+
/**
65+
* A request to forward to a metered connector's API through the Base44 proxy.
66+
*/
67+
export interface ConnectorApiRequest {
68+
/** HTTP method for the upstream request. Defaults to `'GET'`. */
69+
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
70+
/**
71+
* Which of the connector's API hosts to call, by the name it declares.
72+
* Omit for its default host (the first one declared). Only relevant for
73+
* connectors that expose more than one host.
74+
*/
75+
host?: string;
76+
/**
77+
* Path relative to the connector's API root, starting with `/`, such as `'/2/tweets'`.
78+
*
79+
* Must not be an absolute URL. Query parameters may be included here or passed
80+
* separately as {@link query}; either way they are forwarded and priced identically.
81+
*/
82+
path: string;
83+
/** Query parameters. Merged into the request URL alongside any already present in {@link path}. */
84+
query?: Record<string, string | number | boolean | Array<string | number>>;
85+
/** Extra request headers. Only headers the connector explicitly allows are forwarded; the rest are dropped. */
86+
headers?: Record<string, string>;
87+
/** JSON request body. Ignored for `GET` and `HEAD`. */
88+
body?: unknown;
89+
}
90+
91+
/**
92+
* The upstream API's response, as returned by the Base44 connector proxy.
93+
*/
94+
export interface ConnectorApiResponse<T = unknown> {
95+
/** `true` only when the upstream API returned a 2xx status. Proxy and upstream errors are `false`. */
96+
success: boolean;
97+
/** How far the call progressed. Only `not_sent` proves the provider did not execute it. */
98+
phase: ConnectorApiResponsePhase;
99+
/** The upstream HTTP status code, or `null` when no response was received. */
100+
status: number | null;
101+
/**
102+
* The parsed upstream response body, or proxy error details when no response
103+
* was received. `null` when the response was binary — see {@link dataBase64}.
104+
*/
105+
data: T;
106+
/**
107+
* The response body base64-encoded, for the media types the connector declares
108+
* as binary (images, PDFs). Set instead of {@link data}, never alongside it.
109+
*/
110+
dataBase64: string | null;
111+
/** The response media type, set only alongside {@link dataBase64}. */
112+
contentType: string | null;
113+
/** The subset of upstream response headers the connector exposes, typically rate-limit counters. */
114+
headers: Record<string, string>;
115+
/** Integration credits billed to the workspace for this call. */
116+
creditsCharged: number;
117+
}
118+
119+
/**
120+
* Raw proxy response shape. Mapped to {@link ConnectorApiResponse} before being returned.
121+
* @internal
122+
*/
123+
export interface ConnectorProxyRawResponse {
124+
success: boolean;
125+
phase: ConnectorApiResponsePhase;
126+
status_code: number | null;
127+
data: unknown;
128+
data_base64: string | null;
129+
content_type: string | null;
130+
headers: Record<string, string>;
131+
credits_charged: number;
132+
}
133+
51134
/**
52135
* Connectors module for managing OAuth tokens for external services.
53136
*
@@ -78,6 +161,18 @@ export interface AppUserConnectorConnectionResponse {
78161
* 3. In a backend function, call {@linkcode getCurrentAppUserConnection | getCurrentAppUserConnection()} using the service role client (`base44.asServiceRole.connectors`) with the connector ID to retrieve the app user's token.
79162
* 4. Use the returned `accessToken` to call the external service's API directly. Some connectors also return a `connectionConfig` with additional values such as a subdomain for building the API URL.
80163
*
164+
* ## Metered connectors
165+
*
166+
* A few [platform connectors](#shared-connectors) are backed by paid third-party APIs that charge Base44 per call. For those, the OAuth token is **not** available to your code — {@linkcode getConnection | getConnection()} rejects with a `403`. Call them with {@linkcode callApi | callApi()} instead: Base44 attaches the credential server-side, forwards the request, and bills your workspace's integration credits for the call.
167+
*
168+
* This applies to platform connectors only. A workspace-registered or app user connector runs on **your own** OAuth app, so the provider invoices you directly and there is nothing for Base44 to meter — those keep normal token access via {@linkcode getWorkspaceConnection | getWorkspaceConnection()} and {@linkcode getCurrentAppUserConnection | getCurrentAppUserConnection()}.
169+
*
170+
* Two things to keep in mind when writing against a metered connector:
171+
*
172+
* - **Cost varies by endpoint, sometimes sharply.** The same connector can charge two orders of magnitude more for one endpoint than another, so avoid putting an expensive call inside a loop and batch wherever the provider supports it. Each response reports what it actually cost as `creditsCharged`.
173+
* - **Provider and transport outcomes are returned, not thrown.** A provider `4xx`/`5xx` or a connection failure comes back as `success: false` with its `phase`; authorization, quota, and invalid proxy requests reject the promise.
174+
* - **Only `phase: 'not_sent'` proves the provider did not execute the request.** A timeout or in-flight failure may have executed upstream, so do not automatically retry a non-idempotent call unless the provider supports an idempotency key.
175+
*
81176
* ## Available connectors
82177
*
83178
* The connectors below can be used as shared connectors or as app user connectors. For a shared platform connector, pass the integration type string to {@linkcode getConnection | getConnection()}. For a connector you register in Workspace Settings with your own OAuth app, use the connector ID with {@linkcode getWorkspaceConnection | getWorkspaceConnection()} for a shared token, or with {@linkcode getCurrentAppUserConnection | getCurrentAppUserConnection()} for a per-user token.
@@ -345,6 +440,45 @@ export interface ConnectorsModule {
345440
getCurrentAppUserConnection(
346441
connectorId: string,
347442
): Promise<AppUserConnectorConnectionResponse>;
443+
444+
/**
445+
* Calls a [metered connector's](#metered-connectors) API through the Base44 proxy.
446+
*
447+
* Use this for a shared platform connector identified by an integration type. Base44 adds the OAuth credential to the outgoing request, forwards it, and bills the workspace for the call, so you never handle the token yourself.
448+
*
449+
* @param integrationType - The type of integration, such as `'x'`. See [Available connectors](#available-connectors).
450+
* @param request - The upstream request to forward. See {@link ConnectorApiRequest}.
451+
* @returns Promise resolving to a {@link ConnectorApiResponse}. Note that an upstream error is reported in `success` and `status`, not thrown — only Base44-side failures reject.
452+
*
453+
* @example
454+
* ```typescript
455+
* // Post to X
456+
* const res = await base44.asServiceRole.connectors.callApi('x', {
457+
* method: 'POST',
458+
* path: '/2/tweets',
459+
* body: { text: 'Shipped!' },
460+
* });
461+
*
462+
* if (!res.success) {
463+
* console.error('X rejected the post', res.status, res.data);
464+
* }
465+
* ```
466+
*
467+
* @example
468+
* ```typescript
469+
* // Read, with query parameters and a look at what the call cost
470+
* const res = await base44.asServiceRole.connectors.callApi('x', {
471+
* path: '/2/tweets/search/recent',
472+
* query: { query: 'base44', max_results: 10 },
473+
* });
474+
*
475+
* console.log(`${res.creditsCharged} credits`, res.data);
476+
* ```
477+
*/
478+
callApi<T = unknown>(
479+
integrationType: ConnectorIntegrationType,
480+
request: ConnectorApiRequest,
481+
): Promise<ConnectorApiResponse<T>>;
348482
}
349483

350484
/**

src/utils/axios-client.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,9 @@ export function createAxiosClient({
246246
const base44Error = new Base44Error(
247247
message,
248248
error.response?.status,
249-
error.response?.data?.code,
249+
error.response?.data?.code ??
250+
error.response?.headers?.get?.("x-base44-connector-error") ??
251+
error.response?.headers?.["x-base44-connector-error"],
250252
error.response?.data,
251253
error
252254
);

tests/types/connectors.types.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type {
2+
ConnectorApiRequest,
3+
ConnectorApiResponse,
4+
ConnectorApiResponsePhase,
5+
} from "../../src/index.js";
6+
7+
const phase = "sent_unconfirmed" satisfies ConnectorApiResponsePhase;
8+
9+
const request = {
10+
method: "POST",
11+
path: "/2/tweets",
12+
} satisfies ConnectorApiRequest;
13+
14+
const response = {
15+
success: false,
16+
phase,
17+
status: null,
18+
data: { error: "request outcome unknown" },
19+
dataBase64: null,
20+
contentType: null,
21+
headers: {},
22+
creditsCharged: 3,
23+
} satisfies ConnectorApiResponse;
24+
25+
// A binary response carries the bytes instead of a parsed body.
26+
const binaryResponse = {
27+
success: true,
28+
phase: "responded",
29+
status: 200,
30+
data: null,
31+
dataBase64: "iVBORw0KGgo=",
32+
contentType: "image/png",
33+
headers: {},
34+
creditsCharged: 1,
35+
} satisfies ConnectorApiResponse;
36+
37+
// Host selection is optional and named, not a URL.
38+
const hostedRequest = {
39+
method: "GET",
40+
host: "places",
41+
path: "/v1/places:searchText",
42+
} satisfies ConnectorApiRequest;
43+
44+
const rejectsLowercaseMethod = {
45+
// @ts-expect-error Connector methods use the uppercase wire values.
46+
method: "post",
47+
path: "/2/tweets",
48+
} satisfies ConnectorApiRequest;
49+
50+
void request;
51+
void response;
52+
void binaryResponse;
53+
void hostedRequest;
54+
void rejectsLowercaseMethod;

0 commit comments

Comments
 (0)