@superblocksteam/sdk-api 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/errors.d.ts +6 -6
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +23 -2
- package/dist/errors.js.map +1 -1
- package/dist/integrations/base/decode-worker-binary-response.d.ts +4 -0
- package/dist/integrations/base/decode-worker-binary-response.d.ts.map +1 -0
- package/dist/integrations/base/decode-worker-binary-response.js +49 -0
- package/dist/integrations/base/decode-worker-binary-response.js.map +1 -0
- package/dist/integrations/base/decode-worker-binary-response.test.d.ts +2 -0
- package/dist/integrations/base/decode-worker-binary-response.test.d.ts.map +1 -0
- package/dist/integrations/base/decode-worker-binary-response.test.js +81 -0
- package/dist/integrations/base/decode-worker-binary-response.test.js.map +1 -0
- package/dist/integrations/base/index.d.ts +1 -1
- package/dist/integrations/base/index.d.ts.map +1 -1
- package/dist/integrations/base/rest-api-client-base.d.ts +0 -27
- package/dist/integrations/base/rest-api-client-base.d.ts.map +1 -1
- package/dist/integrations/base/rest-api-client-base.js +34 -37
- package/dist/integrations/base/rest-api-client-base.js.map +1 -1
- package/dist/integrations/base/rest-api-integration-client.d.ts +11 -17
- package/dist/integrations/base/rest-api-integration-client.d.ts.map +1 -1
- package/dist/integrations/base/rest-api-integration-client.js +18 -17
- package/dist/integrations/base/rest-api-integration-client.js.map +1 -1
- package/dist/integrations/base/types.d.ts +72 -13
- package/dist/integrations/base/types.d.ts.map +1 -1
- package/dist/integrations/base/types.js +1 -4
- package/dist/integrations/base/types.js.map +1 -1
- package/dist/integrations/documentation-resolver.test.js +173 -1
- package/dist/integrations/documentation-resolver.test.js.map +1 -1
- package/dist/integrations/documentation.d.ts +1 -0
- package/dist/integrations/documentation.d.ts.map +1 -1
- package/dist/integrations/documentation.js +31 -8
- package/dist/integrations/documentation.js.map +1 -1
- package/dist/integrations/postgres/client.d.ts +0 -7
- package/dist/integrations/postgres/client.d.ts.map +1 -1
- package/dist/integrations/restapiintegration/client.test.d.ts +2 -0
- package/dist/integrations/restapiintegration/client.test.d.ts.map +1 -0
- package/dist/integrations/restapiintegration/client.test.js +313 -0
- package/dist/integrations/restapiintegration/client.test.js.map +1 -0
- package/dist/integrations/slack/client.test.js +26 -1
- package/dist/integrations/slack/client.test.js.map +1 -1
- package/dist/integrations/slack/types.d.ts +2 -2
- package/dist/integrations/slack/types.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/errors.ts +33 -5
- package/src/integrations/base/decode-worker-binary-response.test.ts +107 -0
- package/src/integrations/base/decode-worker-binary-response.ts +62 -0
- package/src/integrations/base/index.ts +1 -0
- package/src/integrations/base/rest-api-client-base.ts +42 -46
- package/src/integrations/base/rest-api-integration-client.ts +51 -21
- package/src/integrations/base/types.ts +85 -15
- package/src/integrations/documentation-resolver.test.ts +197 -1
- package/src/integrations/documentation.ts +63 -11
- package/src/integrations/graphql/docs.manifest.json +6 -1
- package/src/integrations/graphql/overlays/dynamic-headers.md +34 -0
- package/src/integrations/postgres/client.ts +1 -1
- package/src/integrations/restapiintegration/client.test.ts +480 -0
- package/src/integrations/restapiintegration/docs.manifest.json +14 -1
- package/src/integrations/restapiintegration/overlays/response-types-binary.md +51 -0
- package/src/integrations/restapiintegration/overlays/response-types-unsupported.md +7 -0
- package/src/integrations/restapiintegration/overlays/response-types.md +26 -0
- package/src/integrations/slack/client.test.ts +36 -1
- package/src/integrations/slack/types.ts +2 -2
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it, vi } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
import type { ActionResponseType } from "@superblocksteam/shared";
|
|
5
|
+
|
|
6
|
+
import { RestApiValidationError } from "../../errors.js";
|
|
7
|
+
import { REST_API_RESPONSE_TYPES } from "../base/types.js";
|
|
8
|
+
import type { IntegrationConfig } from "../types.js";
|
|
9
|
+
import { RestApiIntegrationPluginClientImpl } from "./client.js";
|
|
10
|
+
|
|
11
|
+
const TEST_CONFIG: IntegrationConfig = {
|
|
12
|
+
id: "restapi-test-id",
|
|
13
|
+
name: "Test REST API",
|
|
14
|
+
pluginId: "restapiintegration",
|
|
15
|
+
configuration: {},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const XML_RESPONSE = `<?xml version="1.0"?><note><body>hi</body></note>`;
|
|
19
|
+
const PDF_BYTES = [0x25, 0x50, 0x44, 0x46];
|
|
20
|
+
|
|
21
|
+
function workerBufferPayload(data: unknown): unknown {
|
|
22
|
+
return { type: "Buffer", data };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createClient(mockResult: unknown) {
|
|
26
|
+
const executeQuery = vi.fn().mockResolvedValue(mockResult);
|
|
27
|
+
const client = new RestApiIntegrationPluginClientImpl(
|
|
28
|
+
TEST_CONFIG,
|
|
29
|
+
executeQuery,
|
|
30
|
+
);
|
|
31
|
+
return { client, executeQuery };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function callApiRequest(
|
|
35
|
+
client: RestApiIntegrationPluginClientImpl,
|
|
36
|
+
...args: unknown[]
|
|
37
|
+
): Promise<unknown> {
|
|
38
|
+
return Reflect.apply(client.apiRequest, client, args);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("RestApiIntegrationPluginClientImpl", () => {
|
|
42
|
+
describe("default JSON behavior", () => {
|
|
43
|
+
it("sends responseType json when the option is omitted", async () => {
|
|
44
|
+
const { client, executeQuery } = createClient({ id: "1" });
|
|
45
|
+
|
|
46
|
+
await client.apiRequest(
|
|
47
|
+
{ method: "GET", path: "/users" },
|
|
48
|
+
{ response: z.object({ id: z.string() }) },
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
expect(executeQuery).toHaveBeenCalledWith(
|
|
52
|
+
expect.objectContaining({ responseType: "json" }),
|
|
53
|
+
undefined,
|
|
54
|
+
undefined,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("validates the response against the provided schema", async () => {
|
|
59
|
+
const { client } = createClient({ id: "1", extra: "stripped" });
|
|
60
|
+
|
|
61
|
+
const result = await client.apiRequest(
|
|
62
|
+
{ method: "GET", path: "/users" },
|
|
63
|
+
{ response: z.object({ id: z.string() }) },
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(result).toEqual({ id: "1" });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("throws RestApiValidationError when the response does not match the schema", async () => {
|
|
70
|
+
const payload = XML_RESPONSE;
|
|
71
|
+
const { client } = createClient(payload);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
await client.apiRequest(
|
|
75
|
+
{ method: "GET", path: "/users" },
|
|
76
|
+
{ response: z.object({ id: z.string() }) },
|
|
77
|
+
);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
expect(error).toBeInstanceOf(RestApiValidationError);
|
|
80
|
+
if (!(error instanceof RestApiValidationError)) {
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
expect(error.details.data).toBe(payload);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
throw new Error("Expected JSON response validation to fail");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("responseType passthrough", () => {
|
|
91
|
+
it("forwards responseType text to the orchestrator request", async () => {
|
|
92
|
+
const { client, executeQuery } = createClient(XML_RESPONSE);
|
|
93
|
+
|
|
94
|
+
await client.apiRequest({
|
|
95
|
+
method: "GET",
|
|
96
|
+
path: "/report.xml",
|
|
97
|
+
responseType: "text",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(executeQuery).toHaveBeenCalledWith(
|
|
101
|
+
expect.objectContaining({ responseType: "text" }),
|
|
102
|
+
undefined,
|
|
103
|
+
undefined,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns a raw XML string with responseType text and no response schema", async () => {
|
|
108
|
+
const { client } = createClient(XML_RESPONSE);
|
|
109
|
+
|
|
110
|
+
const result = await client.apiRequest({
|
|
111
|
+
method: "GET",
|
|
112
|
+
path: "/report.xml",
|
|
113
|
+
responseType: "text",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(result).toBe(XML_RESPONSE);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("still validates when a response schema is provided alongside responseType", async () => {
|
|
120
|
+
const { client, executeQuery } = createClient(XML_RESPONSE);
|
|
121
|
+
|
|
122
|
+
const result = await client.apiRequest(
|
|
123
|
+
{ method: "GET", path: "/report.xml", responseType: "text" },
|
|
124
|
+
{ response: z.string() },
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(executeQuery).toHaveBeenCalledWith(
|
|
128
|
+
expect.objectContaining({ responseType: "text" }),
|
|
129
|
+
undefined,
|
|
130
|
+
undefined,
|
|
131
|
+
);
|
|
132
|
+
expect(result).toBe(XML_RESPONSE);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("returns the raw response without validation when no schema is given", async () => {
|
|
136
|
+
const { client } = createClient({ ok: true, rows: [1, 2, 3] });
|
|
137
|
+
|
|
138
|
+
const result = await client.apiRequest({
|
|
139
|
+
method: "GET",
|
|
140
|
+
path: "/data",
|
|
141
|
+
responseType: "text",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(result).toEqual({ ok: true, rows: [1, 2, 3] });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("returns an empty string unchanged for responseType text", async () => {
|
|
148
|
+
const { client } = createClient("");
|
|
149
|
+
|
|
150
|
+
const result = await client.apiRequest({
|
|
151
|
+
method: "GET",
|
|
152
|
+
path: "/empty",
|
|
153
|
+
responseType: "text",
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(result).toBe("");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("rejects null and undefined results for every response type", async () => {
|
|
160
|
+
for (const badResult of [null, undefined]) {
|
|
161
|
+
const { client: textClient } = createClient(badResult);
|
|
162
|
+
await expect(
|
|
163
|
+
textClient.apiRequest({
|
|
164
|
+
method: "GET",
|
|
165
|
+
path: "/broken",
|
|
166
|
+
responseType: "text",
|
|
167
|
+
}),
|
|
168
|
+
).rejects.toThrow(RestApiValidationError);
|
|
169
|
+
|
|
170
|
+
const { client: binaryClient } = createClient(badResult);
|
|
171
|
+
await expect(
|
|
172
|
+
binaryClient.apiRequest({
|
|
173
|
+
method: "GET",
|
|
174
|
+
path: "/broken",
|
|
175
|
+
responseType: "binary",
|
|
176
|
+
}),
|
|
177
|
+
).rejects.toThrow(RestApiValidationError);
|
|
178
|
+
|
|
179
|
+
const { client: jsonClient } = createClient(badResult);
|
|
180
|
+
await expect(
|
|
181
|
+
jsonClient.apiRequest(
|
|
182
|
+
{ method: "GET", path: "/broken" },
|
|
183
|
+
{ response: z.object({}) },
|
|
184
|
+
),
|
|
185
|
+
).rejects.toThrow(RestApiValidationError);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("responseType binary", () => {
|
|
191
|
+
it("forwards responseType binary and returns decoded bytes", async () => {
|
|
192
|
+
const { client, executeQuery } = createClient(
|
|
193
|
+
workerBufferPayload(PDF_BYTES),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const result = await client.apiRequest({
|
|
197
|
+
method: "GET",
|
|
198
|
+
path: "/file.pdf",
|
|
199
|
+
responseType: "binary",
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(executeQuery).toHaveBeenCalledWith(
|
|
203
|
+
expect.objectContaining({ responseType: "binary" }),
|
|
204
|
+
undefined,
|
|
205
|
+
undefined,
|
|
206
|
+
);
|
|
207
|
+
expect(result).toBeInstanceOf(Uint8Array);
|
|
208
|
+
expect(result.constructor).toBe(Uint8Array);
|
|
209
|
+
expect(Array.from(result)).toEqual(PDF_BYTES);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("rejects malformed binary data before applying a response schema", async () => {
|
|
213
|
+
const malformed = workerBufferPayload([256]);
|
|
214
|
+
const { client, executeQuery } = createClient(malformed);
|
|
215
|
+
const workerShapeSchema = z.object({
|
|
216
|
+
type: z.literal("Buffer"),
|
|
217
|
+
data: z.array(z.number()),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await expect(
|
|
221
|
+
callApiRequest(
|
|
222
|
+
client,
|
|
223
|
+
{ method: "GET", path: "/file.pdf", responseType: "binary" },
|
|
224
|
+
{ response: workerShapeSchema },
|
|
225
|
+
),
|
|
226
|
+
).rejects.toThrow(RestApiValidationError);
|
|
227
|
+
expect(executeQuery).toHaveBeenCalled();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("accepts response schemas that transform bytes to bytes", async () => {
|
|
231
|
+
const { client } = createClient(workerBufferPayload(PDF_BYTES));
|
|
232
|
+
|
|
233
|
+
const result = await client.apiRequest(
|
|
234
|
+
{ method: "GET", path: "/file.pdf", responseType: "binary" },
|
|
235
|
+
{
|
|
236
|
+
response: z
|
|
237
|
+
.instanceof(Uint8Array)
|
|
238
|
+
.refine((bytes) => bytes[0] === 0x25)
|
|
239
|
+
.transform((bytes) => bytes.slice(1)),
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
expect(Array.from(result)).toEqual(PDF_BYTES.slice(1));
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("throws RestApiValidationError when the schema rejects normalized bytes", async () => {
|
|
247
|
+
const { client } = createClient(workerBufferPayload(PDF_BYTES));
|
|
248
|
+
|
|
249
|
+
await expect(
|
|
250
|
+
client.apiRequest(
|
|
251
|
+
{ method: "GET", path: "/file.pdf", responseType: "binary" },
|
|
252
|
+
{
|
|
253
|
+
response: z
|
|
254
|
+
.instanceof(Uint8Array)
|
|
255
|
+
.refine((bytes) => bytes.byteLength > 64),
|
|
256
|
+
},
|
|
257
|
+
),
|
|
258
|
+
).rejects.toThrow(RestApiValidationError);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("redacts binary data from response validation errors", async () => {
|
|
262
|
+
const bytes = Array.from({ length: 4096 }, () => 0);
|
|
263
|
+
const { client } = createClient(workerBufferPayload(bytes));
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
await client.apiRequest(
|
|
267
|
+
{ method: "GET", path: "/file.pdf", responseType: "binary" },
|
|
268
|
+
{
|
|
269
|
+
response: z
|
|
270
|
+
.instanceof(Uint8Array)
|
|
271
|
+
.refine((value) => value.byteLength < 1024),
|
|
272
|
+
},
|
|
273
|
+
);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
expect(error).toBeInstanceOf(RestApiValidationError);
|
|
276
|
+
if (!(error instanceof RestApiValidationError)) {
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
expect(error.details.data).toEqual({
|
|
280
|
+
dataType: "binary",
|
|
281
|
+
redacted: true,
|
|
282
|
+
});
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
throw new Error("Expected binary response validation to fail");
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe("request body validation", () => {
|
|
290
|
+
it("throws RestApiValidationError for an invalid body with a body-only schema", async () => {
|
|
291
|
+
const { client, executeQuery } = createClient({ ok: true });
|
|
292
|
+
|
|
293
|
+
const dynamicBody: unknown = JSON.parse('{"amount":"not-a-number"}');
|
|
294
|
+
|
|
295
|
+
await expect(
|
|
296
|
+
client.apiRequest(
|
|
297
|
+
{
|
|
298
|
+
method: "POST",
|
|
299
|
+
path: "/orders",
|
|
300
|
+
body: dynamicBody,
|
|
301
|
+
responseType: "text",
|
|
302
|
+
},
|
|
303
|
+
{ body: z.object({ amount: z.number() }) },
|
|
304
|
+
),
|
|
305
|
+
).rejects.toThrow(RestApiValidationError);
|
|
306
|
+
expect(executeQuery).not.toHaveBeenCalled();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("sends a valid body and returns the raw response with a body-only schema", async () => {
|
|
310
|
+
const { client, executeQuery } = createClient({ ok: true });
|
|
311
|
+
|
|
312
|
+
const result = await client.apiRequest(
|
|
313
|
+
{
|
|
314
|
+
method: "POST",
|
|
315
|
+
path: "/orders",
|
|
316
|
+
body: { amount: 42 },
|
|
317
|
+
responseType: "text",
|
|
318
|
+
},
|
|
319
|
+
{ body: z.object({ amount: z.number() }) },
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
expect(executeQuery).toHaveBeenCalledWith(
|
|
323
|
+
expect.objectContaining({
|
|
324
|
+
body: JSON.stringify({ amount: 42 }),
|
|
325
|
+
bodyType: "jsonBody",
|
|
326
|
+
}),
|
|
327
|
+
undefined,
|
|
328
|
+
undefined,
|
|
329
|
+
);
|
|
330
|
+
expect(result).toEqual({ ok: true });
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe("runtime contract enforcement", () => {
|
|
335
|
+
it("rejects a schema-less call in default JSON mode without issuing the request", async () => {
|
|
336
|
+
const { client, executeQuery } = createClient({ ok: true });
|
|
337
|
+
|
|
338
|
+
await expect(
|
|
339
|
+
callApiRequest(client, { method: "GET", path: "/users" }),
|
|
340
|
+
).rejects.toThrow(RestApiValidationError);
|
|
341
|
+
expect(executeQuery).not.toHaveBeenCalled();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("rejects a body-only schema in explicit JSON mode without issuing the request", async () => {
|
|
345
|
+
const { client, executeQuery } = createClient({ ok: true });
|
|
346
|
+
|
|
347
|
+
await expect(
|
|
348
|
+
callApiRequest(
|
|
349
|
+
client,
|
|
350
|
+
{
|
|
351
|
+
method: "POST",
|
|
352
|
+
path: "/orders",
|
|
353
|
+
body: { amount: 42 },
|
|
354
|
+
responseType: "json",
|
|
355
|
+
},
|
|
356
|
+
{ body: z.object({ amount: z.number() }) },
|
|
357
|
+
),
|
|
358
|
+
).rejects.toThrow(RestApiValidationError);
|
|
359
|
+
expect(executeQuery).not.toHaveBeenCalled();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("rejects auto, which can yield unvalidated JSON, and raw, which is streaming-only", async () => {
|
|
363
|
+
for (const responseType of ["auto", "raw"]) {
|
|
364
|
+
const { client, executeQuery } = createClient({ ok: true });
|
|
365
|
+
|
|
366
|
+
await expect(
|
|
367
|
+
callApiRequest(client, {
|
|
368
|
+
method: "GET",
|
|
369
|
+
path: "/report",
|
|
370
|
+
responseType,
|
|
371
|
+
}),
|
|
372
|
+
).rejects.toThrow(RestApiValidationError);
|
|
373
|
+
expect(executeQuery).not.toHaveBeenCalled();
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe("overload resolution", () => {
|
|
379
|
+
it("keeps public response types within the worker contract", () => {
|
|
380
|
+
expectTypeOf<(typeof REST_API_RESPONSE_TYPES)[number]>().toMatchTypeOf<
|
|
381
|
+
Exclude<`${ActionResponseType}`, "raw">
|
|
382
|
+
>();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("keeps typed results for schema callers and unknown for schema-less calls", async () => {
|
|
386
|
+
const { client } = createClient({ id: "1" });
|
|
387
|
+
|
|
388
|
+
const typed: Promise<{ id: string }> = client.apiRequest(
|
|
389
|
+
{ method: "GET", path: "/users" },
|
|
390
|
+
{ response: z.object({ id: z.string() }) },
|
|
391
|
+
);
|
|
392
|
+
expect(await typed).toEqual({ id: "1" });
|
|
393
|
+
|
|
394
|
+
const { client: rawClient } = createClient(XML_RESPONSE);
|
|
395
|
+
|
|
396
|
+
const raw: Promise<unknown> = rawClient.apiRequest({
|
|
397
|
+
method: "GET",
|
|
398
|
+
path: "/report.xml",
|
|
399
|
+
responseType: "text",
|
|
400
|
+
});
|
|
401
|
+
expect(await raw).toBe(XML_RESPONSE);
|
|
402
|
+
|
|
403
|
+
// @ts-expect-error schema-less apiRequest returns unknown, not a typed shape
|
|
404
|
+
const wrong: Promise<{ id: string }> = rawClient.apiRequest({
|
|
405
|
+
method: "GET",
|
|
406
|
+
path: "/report.xml",
|
|
407
|
+
responseType: "text",
|
|
408
|
+
});
|
|
409
|
+
await wrong;
|
|
410
|
+
|
|
411
|
+
// @ts-expect-error unvalidated JSON is unrepresentable; schema-less calls must opt into a non-JSON responseType
|
|
412
|
+
const unvalidatedJson = rawClient.apiRequest({
|
|
413
|
+
method: "GET",
|
|
414
|
+
path: "/users",
|
|
415
|
+
});
|
|
416
|
+
await expect(unvalidatedJson).rejects.toThrow(RestApiValidationError);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("types schema-less binary responses as Uint8Array", async () => {
|
|
420
|
+
const { client: binaryClient } = createClient(
|
|
421
|
+
workerBufferPayload(PDF_BYTES),
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
const binary: Promise<Uint8Array> = binaryClient.apiRequest({
|
|
425
|
+
method: "GET",
|
|
426
|
+
path: "/file.pdf",
|
|
427
|
+
responseType: "binary",
|
|
428
|
+
});
|
|
429
|
+
expectTypeOf(binary).toEqualTypeOf<Promise<Uint8Array>>();
|
|
430
|
+
expect(Array.from(await binary)).toEqual(PDF_BYTES);
|
|
431
|
+
|
|
432
|
+
// @ts-expect-error schema-less binary apiRequest returns Uint8Array
|
|
433
|
+
const wrongBinary: Promise<string> = binaryClient.apiRequest({
|
|
434
|
+
method: "GET",
|
|
435
|
+
path: "/file.pdf",
|
|
436
|
+
responseType: "binary",
|
|
437
|
+
});
|
|
438
|
+
await wrongBinary;
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("rejects binary response schemas that transform bytes to another type", () => {
|
|
442
|
+
const { client: binaryClient } = createClient(
|
|
443
|
+
workerBufferPayload(PDF_BYTES),
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
// @ts-expect-error binary response schemas must output Uint8Array
|
|
447
|
+
binaryClient.apiRequest(
|
|
448
|
+
{
|
|
449
|
+
method: "GET",
|
|
450
|
+
path: "/file.pdf",
|
|
451
|
+
responseType: "binary",
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
response: z
|
|
455
|
+
.instanceof(Uint8Array)
|
|
456
|
+
.transform((bytes) => bytes.byteLength),
|
|
457
|
+
},
|
|
458
|
+
);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("rejects response types outside the public SDK contract", async () => {
|
|
462
|
+
const { client: rawClient } = createClient(XML_RESPONSE);
|
|
463
|
+
const autoCall = rawClient.apiRequest({
|
|
464
|
+
method: "GET",
|
|
465
|
+
path: "/report.xml",
|
|
466
|
+
// @ts-expect-error "auto" is not an exposed responseType
|
|
467
|
+
responseType: "auto",
|
|
468
|
+
});
|
|
469
|
+
await expect(autoCall).rejects.toThrow(RestApiValidationError);
|
|
470
|
+
|
|
471
|
+
const rawCall = rawClient.apiRequest({
|
|
472
|
+
method: "GET",
|
|
473
|
+
path: "/report.xml",
|
|
474
|
+
// @ts-expect-error "raw" is not an exposed responseType
|
|
475
|
+
responseType: "raw",
|
|
476
|
+
});
|
|
477
|
+
await expect(rawCall).rejects.toThrow(RestApiValidationError);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
});
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"pluginId": "restapiintegration",
|
|
3
3
|
"base": "README.md",
|
|
4
|
-
"overlays": [
|
|
4
|
+
"overlays": [
|
|
5
|
+
{
|
|
6
|
+
"file": "overlays/response-types.md",
|
|
7
|
+
"sdkVersionRange": ">=0.0.3 <0.0.4"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"file": "overlays/response-types-binary.md",
|
|
11
|
+
"sdkVersionRange": ">=0.0.4"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"file": "overlays/response-types-unsupported.md",
|
|
15
|
+
"sdkVersionRange": "<0.0.3"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
5
18
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
## Non-JSON Responses
|
|
2
|
+
|
|
3
|
+
`apiRequest()` decodes responses as JSON by default. For endpoints that return non-JSON payloads, pass `responseType`:
|
|
4
|
+
|
|
5
|
+
- `"binary"` for PDFs and other byte payloads. The result is a `Uint8Array`
|
|
6
|
+
- `"text"` for XML, CSV, HTML, and other decoded strings
|
|
7
|
+
|
|
8
|
+
With `responseType: "text"` or `responseType: "binary"` the response schema may be omitted. Text returns the raw string typed `unknown`. Binary returns a `Uint8Array`:
|
|
9
|
+
|
|
10
|
+
```typescript
|
|
11
|
+
const xml = await ctx.integrations.legacyApi.apiRequest({
|
|
12
|
+
method: "GET",
|
|
13
|
+
path: "/report.xml",
|
|
14
|
+
responseType: "text",
|
|
15
|
+
});
|
|
16
|
+
// xml is the raw XML string (typed unknown)
|
|
17
|
+
|
|
18
|
+
const pdf = await ctx.integrations.legacyApi.apiRequest({
|
|
19
|
+
method: "GET",
|
|
20
|
+
path: "/file.pdf",
|
|
21
|
+
responseType: "binary",
|
|
22
|
+
});
|
|
23
|
+
// pdf is a Uint8Array
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
To validate after decoding, pass a schema that matches the decoded value:
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
const xml = await ctx.integrations.legacyApi.apiRequest(
|
|
30
|
+
{ method: "GET", path: "/report.xml", responseType: "text" },
|
|
31
|
+
{ response: z.string() },
|
|
32
|
+
);
|
|
33
|
+
// xml is typed string
|
|
34
|
+
|
|
35
|
+
const pdf = await ctx.integrations.legacyApi.apiRequest(
|
|
36
|
+
{ method: "GET", path: "/file.pdf", responseType: "binary" },
|
|
37
|
+
{ response: z.instanceof(Uint8Array) },
|
|
38
|
+
);
|
|
39
|
+
// pdf is typed Uint8Array
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Convert binary data to a JSON-safe representation before returning it from an SDK API:
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
return {
|
|
46
|
+
contentBase64: Buffer.from(pdf).toString("base64"),
|
|
47
|
+
filename: "file.pdf",
|
|
48
|
+
};
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
JSON responses (the default) always require a response schema. Omitting the schema is only allowed together with an explicit `responseType: "text"` or `responseType: "binary"`, so unvalidated JSON is not representable.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
## Non-JSON Responses
|
|
2
|
+
|
|
3
|
+
This organization's agent does not support the `responseType` option on `apiRequest()` — its worker runs an sdk-api version that decodes every response as JSON and always requires a response schema. Endpoints returning XML, CSV, or other non-JSON payloads cannot be consumed from SDK APIs until the agent is upgraded.
|
|
4
|
+
|
|
5
|
+
Do not generate code that passes a `responseType` value: it may typecheck against a newer local copy of the SDK but fails at runtime on this agent with a `RestApiValidationError` (for example, "Integration query returned ... — expected a JSON response object").
|
|
6
|
+
|
|
7
|
+
When the user needs to consume a non-JSON endpoint, this is not something you can fix in code. Tell them the required action: an administrator must upgrade this organization's Superblocks agent (On-Premise Agent / OPA) to a newer version that supports the `responseType` option. Until that upgrade happens, non-JSON endpoints cannot be consumed and there is no code-level workaround. Do not attempt to work around the limitation yourself.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
## Non-JSON Responses
|
|
2
|
+
|
|
3
|
+
`apiRequest()` decodes responses as JSON by default. For endpoints that return non-JSON payloads (XML, CSV, HTML, plain text), pass `responseType: "text"` to receive the decoded body as a string.
|
|
4
|
+
|
|
5
|
+
With `responseType: "text"` the response schema may be omitted — the raw string is returned typed `unknown`:
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
const xml = await ctx.integrations.legacyApi.apiRequest({
|
|
9
|
+
method: "GET",
|
|
10
|
+
path: "/report.xml",
|
|
11
|
+
responseType: "text",
|
|
12
|
+
});
|
|
13
|
+
// xml is the raw XML string (typed unknown)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
To get a typed string instead, validate with a schema matching the decoded value:
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
const xml = await ctx.integrations.legacyApi.apiRequest(
|
|
20
|
+
{ method: "GET", path: "/report.xml", responseType: "text" },
|
|
21
|
+
{ response: z.string() },
|
|
22
|
+
);
|
|
23
|
+
// xml is typed string
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
JSON responses (the default) always require a response schema. Omitting the schema is only allowed together with an explicit `responseType: "text"`, so unvalidated JSON is not representable.
|
|
@@ -12,6 +12,7 @@ import { describe, it, expect, vi } from "vitest";
|
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
|
|
14
14
|
import { RestApiValidationError } from "../../errors.js";
|
|
15
|
+
import { REDACTED_BINARY_RESPONSE_DATA } from "../base/decode-worker-binary-response.js";
|
|
15
16
|
import type { IntegrationConfig } from "../types.js";
|
|
16
17
|
import { SlackClientImpl } from "./client.js";
|
|
17
18
|
import type { SlackErrorResponse } from "./types.js";
|
|
@@ -508,7 +509,41 @@ describe("SlackClientImpl", () => {
|
|
|
508
509
|
});
|
|
509
510
|
});
|
|
510
511
|
|
|
511
|
-
|
|
512
|
+
describe("responseType binary", () => {
|
|
513
|
+
const pdfBytes = [0x25, 0x50, 0x44, 0x46];
|
|
514
|
+
const workerPayload = { type: "Buffer", data: pdfBytes };
|
|
515
|
+
|
|
516
|
+
it("decodes worker Buffer JSON and redacts it from validation errors", async () => {
|
|
517
|
+
const { client, executeQuery } = createClient(workerPayload);
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
await client.apiRequest(
|
|
521
|
+
{
|
|
522
|
+
method: "GET",
|
|
523
|
+
path: "/files.download",
|
|
524
|
+
responseType: "binary",
|
|
525
|
+
},
|
|
526
|
+
{ response: ChannelsSchema },
|
|
527
|
+
);
|
|
528
|
+
} catch (error) {
|
|
529
|
+
expect(error).toBeInstanceOf(RestApiValidationError);
|
|
530
|
+
if (!(error instanceof RestApiValidationError)) {
|
|
531
|
+
throw error;
|
|
532
|
+
}
|
|
533
|
+
expect(executeQuery).toHaveBeenCalledWith(
|
|
534
|
+
expect.objectContaining({ responseType: "binary" }),
|
|
535
|
+
undefined,
|
|
536
|
+
undefined,
|
|
537
|
+
);
|
|
538
|
+
expect(error.details.data).toEqual(REDACTED_BINARY_RESPONSE_DATA);
|
|
539
|
+
expect(error.details.data).not.toEqual(workerPayload);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
throw new Error(
|
|
543
|
+
"Expected Slack binary apiRequest to fail envelope validation",
|
|
544
|
+
);
|
|
545
|
+
});
|
|
546
|
+
});
|
|
512
547
|
|
|
513
548
|
describe("type narrowing", () => {
|
|
514
549
|
it("narrows to error response fields when ok is false", async () => {
|
|
@@ -56,7 +56,7 @@ type SlackSuccessSchemaHasExplicitOkKey<TSchema extends z.AnyZodObject> =
|
|
|
56
56
|
: false
|
|
57
57
|
: false;
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
type SlackSuccessSchema<TSchema extends z.AnyZodObject> =
|
|
60
60
|
SlackSuccessSchemaHasExplicitOkKey<TSchema> extends true ? never : TSchema;
|
|
61
61
|
|
|
62
62
|
/**
|
|
@@ -73,7 +73,7 @@ export interface SlackApiRequestSchema<
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/** Slack success response shape with injected discriminant. */
|
|
76
|
-
|
|
76
|
+
type SlackSuccessResponse<T extends Record<string, unknown>> = T & {
|
|
77
77
|
readonly ok: true;
|
|
78
78
|
};
|
|
79
79
|
|