Skip to content

Commit 102ceb6

Browse files
ChenMachBaseclaude
andcommitted
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>
1 parent 68f62bd commit 102ceb6

4 files changed

Lines changed: 105 additions & 2 deletions

File tree

src/modules/connectors.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,9 @@ async function proxyCall<T>(
172172

173173
const response = await axios.post(url, {
174174
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 }),
175178
path: request.path,
176179
query: request.query ?? {},
177180
headers: request.headers ?? {},
@@ -184,6 +187,8 @@ async function proxyCall<T>(
184187
phase: data.phase,
185188
status: data.status_code ?? null,
186189
data: data.data as T,
190+
dataBase64: data.data_base64 ?? null,
191+
contentType: data.content_type ?? null,
187192
headers: data.headers ?? {},
188193
creditsCharged: data.credits_charged ?? 0,
189194
};

src/modules/connectors.types.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ export type ConnectorApiResponsePhase =
6767
export interface ConnectorApiRequest {
6868
/** HTTP method for the upstream request. Defaults to `'GET'`. */
6969
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;
7076
/**
7177
* Path relative to the connector's API root, starting with `/`, such as `'/2/tweets'`.
7278
*
@@ -78,7 +84,7 @@ export interface ConnectorApiRequest {
7884
query?: Record<string, string | number | boolean | Array<string | number>>;
7985
/** Extra request headers. Only headers the connector explicitly allows are forwarded; the rest are dropped. */
8086
headers?: Record<string, string>;
81-
/** JSON request body. Ignored for `GET`, `HEAD`, and `DELETE`. */
87+
/** JSON request body. Ignored for `GET` and `HEAD`. */
8288
body?: unknown;
8389
}
8490

@@ -92,8 +98,18 @@ export interface ConnectorApiResponse<T = unknown> {
9298
phase: ConnectorApiResponsePhase;
9399
/** The upstream HTTP status code, or `null` when no response was received. */
94100
status: number | null;
95-
/** The parsed upstream response body, or proxy error details when no response was received. */
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+
*/
96105
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;
97113
/** The subset of upstream response headers the connector exposes, typically rate-limit counters. */
98114
headers: Record<string, string>;
99115
/** Integration credits billed to the workspace for this call. */
@@ -109,6 +125,8 @@ export interface ConnectorProxyRawResponse {
109125
phase: ConnectorApiResponsePhase;
110126
status_code: number | null;
111127
data: unknown;
128+
data_base64: string | null;
129+
content_type: string | null;
112130
headers: Record<string, string>;
113131
credits_charged: number;
114132
}

tests/types/connectors.types.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,31 @@ const response = {
1616
phase,
1717
status: null,
1818
data: { error: "request outcome unknown" },
19+
dataBase64: null,
20+
contentType: null,
1921
headers: {},
2022
creditsCharged: 3,
2123
} satisfies ConnectorApiResponse;
2224

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+
2344
const rejectsLowercaseMethod = {
2445
// @ts-expect-error Connector methods use the uppercase wire values.
2546
method: "post",
@@ -28,4 +49,6 @@ const rejectsLowercaseMethod = {
2849

2950
void request;
3051
void response;
52+
void binaryResponse;
53+
void hostedRequest;
3154
void rejectsLowercaseMethod;

tests/unit/connectors-proxy.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,63 @@ describe("Connectors module – metered connector proxy", () => {
5151
expect(received.headers).toEqual({});
5252
});
5353

54+
test("forwards a named host, and omits it entirely when unset", async () => {
55+
// The payload is built field by field, so anything not explicitly forwarded
56+
// is silently dropped — which is what happened to `host` before this.
57+
const bodies: any[] = [];
58+
scope
59+
.post(`/api/apps/${appId}/connectors/googlemaps/call`, (body) => {
60+
bodies.push(body);
61+
return true;
62+
})
63+
.twice()
64+
.reply(200, proxyResponse);
65+
66+
await base44.asServiceRole.connectors.callApi("googlemaps", {
67+
host: "places",
68+
path: "/v1/places:searchText",
69+
});
70+
await base44.asServiceRole.connectors.callApi("googlemaps", {
71+
path: "/maps/api/geocode/json",
72+
});
73+
74+
expect(bodies[0].host).toBe("places");
75+
// Absent rather than null, so the proxy picks the connector's default host.
76+
expect("host" in bodies[1]).toBe(false);
77+
});
78+
79+
test("maps a binary response to dataBase64 + contentType", async () => {
80+
scope.post(`/api/apps/${appId}/connectors/googlemaps/call`).reply(200, {
81+
success: true,
82+
phase: "responded",
83+
status_code: 200,
84+
data: null,
85+
data_base64: "iVBORw0KGgo=",
86+
content_type: "image/png",
87+
headers: {},
88+
credits_charged: 1,
89+
});
90+
91+
const res = await base44.asServiceRole.connectors.callApi("googlemaps", {
92+
path: "/maps/api/staticmap",
93+
});
94+
95+
expect(res.dataBase64).toBe("iVBORw0KGgo=");
96+
expect(res.contentType).toBe("image/png");
97+
expect(res.data).toBeNull();
98+
});
99+
100+
test("leaves dataBase64 and contentType null for a JSON response", async () => {
101+
scope.post(`/api/apps/${appId}/connectors/x/call`).reply(200, proxyResponse);
102+
103+
const res = await base44.asServiceRole.connectors.callApi("x", {
104+
path: "/2/users/me",
105+
});
106+
107+
expect(res.dataBase64).toBeNull();
108+
expect(res.contentType).toBeNull();
109+
});
110+
54111
test("defaults the method to GET", async () => {
55112
let received: any;
56113
scope

0 commit comments

Comments
 (0)