@superblocksteam/sdk-api 0.0.11 → 0.0.12-body-types

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.
Files changed (83) hide show
  1. package/README.md +0 -23
  2. package/dist/errors.d.ts.map +1 -1
  3. package/dist/errors.js +44 -1
  4. package/dist/errors.js.map +1 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/integrations/base/index.d.ts +2 -1
  9. package/dist/integrations/base/index.d.ts.map +1 -1
  10. package/dist/integrations/base/index.js +1 -0
  11. package/dist/integrations/base/index.js.map +1 -1
  12. package/dist/integrations/base/request-body.d.ts +21 -0
  13. package/dist/integrations/base/request-body.d.ts.map +1 -0
  14. package/dist/integrations/base/request-body.js +322 -0
  15. package/dist/integrations/base/request-body.js.map +1 -0
  16. package/dist/integrations/base/request-body.test.d.ts +2 -0
  17. package/dist/integrations/base/request-body.test.d.ts.map +1 -0
  18. package/dist/integrations/base/request-body.test.js +324 -0
  19. package/dist/integrations/base/request-body.test.js.map +1 -0
  20. package/dist/integrations/base/rest-api-client-base.d.ts.map +1 -1
  21. package/dist/integrations/base/rest-api-client-base.js +18 -4
  22. package/dist/integrations/base/rest-api-client-base.js.map +1 -1
  23. package/dist/integrations/base/types.d.ts +40 -5
  24. package/dist/integrations/base/types.d.ts.map +1 -1
  25. package/dist/integrations/base/types.js +7 -0
  26. package/dist/integrations/base/types.js.map +1 -1
  27. package/dist/integrations/documentation-resolver.test.js +20 -1
  28. package/dist/integrations/documentation-resolver.test.js.map +1 -1
  29. package/dist/integrations/dynamodb/client.d.ts +2 -6
  30. package/dist/integrations/dynamodb/client.d.ts.map +1 -1
  31. package/dist/integrations/dynamodb/client.js +10 -83
  32. package/dist/integrations/dynamodb/client.js.map +1 -1
  33. package/dist/integrations/dynamodb/index.d.ts +1 -1
  34. package/dist/integrations/dynamodb/index.d.ts.map +1 -1
  35. package/dist/integrations/dynamodb/index.js.map +1 -1
  36. package/dist/integrations/dynamodb/types.d.ts +1 -27
  37. package/dist/integrations/dynamodb/types.d.ts.map +1 -1
  38. package/dist/integrations/index.d.ts +1 -1
  39. package/dist/integrations/index.d.ts.map +1 -1
  40. package/dist/integrations/index.js.map +1 -1
  41. package/dist/integrations/restapiintegration/client.body-types.test.d.ts +2 -0
  42. package/dist/integrations/restapiintegration/client.body-types.test.d.ts.map +1 -0
  43. package/dist/integrations/restapiintegration/client.body-types.test.js +145 -0
  44. package/dist/integrations/restapiintegration/client.body-types.test.js.map +1 -0
  45. package/dist/integrations/slack/client.test.js +23 -0
  46. package/dist/integrations/slack/client.test.js.map +1 -1
  47. package/dist/runtime/context.d.ts +0 -6
  48. package/dist/runtime/context.d.ts.map +1 -1
  49. package/dist/runtime/context.js +1 -2
  50. package/dist/runtime/context.js.map +1 -1
  51. package/dist/runtime/executor.d.ts +0 -6
  52. package/dist/runtime/executor.d.ts.map +1 -1
  53. package/dist/runtime/executor.js +0 -1
  54. package/dist/runtime/executor.js.map +1 -1
  55. package/dist/types.d.ts +0 -10
  56. package/dist/types.d.ts.map +1 -1
  57. package/package.json +1 -1
  58. package/src/errors.ts +52 -1
  59. package/src/index.ts +0 -1
  60. package/src/integrations/base/index.ts +7 -0
  61. package/src/integrations/base/request-body.test.ts +402 -0
  62. package/src/integrations/base/request-body.ts +548 -0
  63. package/src/integrations/base/rest-api-client-base.ts +18 -4
  64. package/src/integrations/base/types.ts +59 -5
  65. package/src/integrations/documentation-resolver.test.ts +24 -1
  66. package/src/integrations/dynamodb/README.md +12 -33
  67. package/src/integrations/dynamodb/client.ts +16 -121
  68. package/src/integrations/dynamodb/index.ts +1 -5
  69. package/src/integrations/dynamodb/types.ts +1 -33
  70. package/src/integrations/index.ts +0 -1
  71. package/src/integrations/restapiintegration/client.body-types.test.ts +214 -0
  72. package/src/integrations/restapiintegration/docs.manifest.json +8 -0
  73. package/src/integrations/restapiintegration/overlays/request-body-types-unsupported.md +7 -0
  74. package/src/integrations/restapiintegration/overlays/request-body-types.md +106 -0
  75. package/src/integrations/slack/client.test.ts +33 -0
  76. package/src/runtime/context.ts +0 -9
  77. package/src/runtime/executor.ts +0 -8
  78. package/src/types.ts +0 -11
  79. package/dist/integrations/dynamodb/client.test.d.ts +0 -8
  80. package/dist/integrations/dynamodb/client.test.d.ts.map +0 -1
  81. package/dist/integrations/dynamodb/client.test.js +0 -198
  82. package/dist/integrations/dynamodb/client.test.js.map +0 -1
  83. package/src/integrations/dynamodb/client.test.ts +0 -254
@@ -0,0 +1,548 @@
1
+ import type { PartialMessage } from "@bufbuild/protobuf";
2
+ import { z } from "zod";
3
+
4
+ import type { Property } from "@superblocksteam/types/dist/src/common/v1/plugin_pb";
5
+
6
+ import { RestApiValidationError } from "../../errors.js";
7
+ import { REST_API_BODY_TYPES } from "./types.js";
8
+ import type { BinaryBody } from "./types.js";
9
+
10
+ export const MAX_BINARY_BODY_BYTES = 20 * 1024 * 1024;
11
+
12
+ const REQUEST_BODY_SIZE_ERROR_MESSAGE =
13
+ "request bodies must be 20 MiB or smaller because agent requests have a 28 MiB serialized size cap";
14
+
15
+ type RequestBodyWireType =
16
+ | "jsonBody"
17
+ | "rawBody"
18
+ | "formData"
19
+ | "formDataBase64"
20
+ | "binaryBody";
21
+ type InjectedHeader = { readonly key: string; readonly value: string };
22
+
23
+ export type RequestBodyWirePatch = {
24
+ readonly body?: string;
25
+ readonly formData?: readonly PartialMessage<Property>[];
26
+ readonly bodyType?: RequestBodyWireType;
27
+ readonly injectedHeaders: readonly InjectedHeader[];
28
+ };
29
+
30
+ type MultipartPart =
31
+ | {
32
+ readonly kind: "field";
33
+ readonly key: string;
34
+ readonly value: string;
35
+ readonly byteLength: number;
36
+ }
37
+ | {
38
+ readonly kind: "file";
39
+ readonly key: string;
40
+ readonly value: string | BinaryBody;
41
+ readonly filename: string;
42
+ readonly byteLength: number;
43
+ };
44
+
45
+ const requestBodyTypeSchema = z.enum(REST_API_BODY_TYPES);
46
+
47
+ export function encodeRequestBody(input: {
48
+ body: unknown;
49
+ bodyType?: unknown;
50
+ headers?: Record<string, string>;
51
+ }): RequestBodyWirePatch {
52
+ const bodyTypeResult = requestBodyTypeSchema.safeParse(
53
+ input.bodyType ?? "json",
54
+ );
55
+ if (!bodyTypeResult.success) {
56
+ throw new RestApiValidationError(
57
+ `Unsupported bodyType ${JSON.stringify(input.bodyType)} - expected one of: ${REST_API_BODY_TYPES.join(", ")}`,
58
+ { data: input.bodyType, zodError: bodyTypeResult.error },
59
+ );
60
+ }
61
+
62
+ const bodyType = bodyTypeResult.data;
63
+ if (input.body === undefined) {
64
+ if (bodyType === "json") return { injectedHeaders: [] };
65
+ throw validationError(
66
+ `bodyType "${bodyType}" requires a request body`,
67
+ input.body,
68
+ ["body"],
69
+ );
70
+ }
71
+
72
+ switch (bodyType) {
73
+ case "json":
74
+ return encodeJsonBody(input.body);
75
+ case "raw":
76
+ return encodeRawBody(input.body);
77
+ case "formUrlencoded":
78
+ return encodeFormUrlencodedBody(input.body, input.headers);
79
+ case "multipartForm":
80
+ return encodeMultipartFormBody(input.body, input.headers);
81
+ case "binary":
82
+ return encodeBinaryBody(input.body, input.headers);
83
+ default: {
84
+ const _exhaustive: never = bodyType;
85
+ return _exhaustive;
86
+ }
87
+ }
88
+ }
89
+
90
+ function encodeJsonBody(body: unknown): RequestBodyWirePatch {
91
+ if (isBinaryBody(body)) {
92
+ throw validationError(
93
+ 'Binary request bodies require bodyType: "binary"',
94
+ body,
95
+ ["body"],
96
+ );
97
+ }
98
+ let serialized: string | undefined;
99
+ try {
100
+ serialized = JSON.stringify(body);
101
+ } catch (error) {
102
+ throw validationError(
103
+ `JSON request body could not be serialized: ${errorMessage(error)}`,
104
+ body,
105
+ ["body"],
106
+ );
107
+ }
108
+ if (serialized === undefined) {
109
+ throw validationError(
110
+ "JSON request body must serialize to a JSON value",
111
+ body,
112
+ ["body"],
113
+ );
114
+ }
115
+ validateRequestByteLength(utf8ByteLength(serialized), ["body"]);
116
+ return { body: serialized, bodyType: "jsonBody", injectedHeaders: [] };
117
+ }
118
+
119
+ function encodeRawBody(body: unknown): RequestBodyWirePatch {
120
+ if (typeof body !== "string" || body.length === 0) {
121
+ throw validationError(
122
+ 'bodyType "raw" requires a non-empty string request body',
123
+ body,
124
+ ["body"],
125
+ );
126
+ }
127
+ validateRequestByteLength(utf8ByteLength(body), ["body"]);
128
+ return { body, bodyType: "rawBody", injectedHeaders: [] };
129
+ }
130
+
131
+ function encodeFormUrlencodedBody(
132
+ body: unknown,
133
+ headers: Record<string, string> | undefined,
134
+ ): RequestBodyWirePatch {
135
+ if (!isPlainRecord(body)) {
136
+ throw validationError(
137
+ 'bodyType "formUrlencoded" requires a plain object request body',
138
+ body,
139
+ ["body"],
140
+ );
141
+ }
142
+ const params = new URLSearchParams();
143
+ for (const [key, value] of Object.entries(body)) {
144
+ if (value === undefined) continue;
145
+ if (key.length === 0) {
146
+ throw validationError(
147
+ "formUrlencoded body keys must be non-empty strings",
148
+ body,
149
+ ["body", key],
150
+ );
151
+ }
152
+ appendFormUrlencodedValue(params, key, value);
153
+ }
154
+ const encodedBody = params.toString();
155
+ if (encodedBody.length === 0) {
156
+ throw validationError(
157
+ "formUrlencoded body must encode at least one field",
158
+ body,
159
+ ["body"],
160
+ );
161
+ }
162
+ validateRequestByteLength(utf8ByteLength(encodedBody), ["body"]);
163
+ return {
164
+ body: encodedBody,
165
+ bodyType: "rawBody",
166
+ injectedHeaders: contentTypeHeaderIfMissing(
167
+ headers,
168
+ "application/x-www-form-urlencoded",
169
+ ),
170
+ };
171
+ }
172
+
173
+ function appendFormUrlencodedValue(
174
+ params: URLSearchParams,
175
+ key: string,
176
+ value: unknown,
177
+ ): void {
178
+ if (Array.isArray(value)) {
179
+ for (const item of value)
180
+ params.append(key, formUrlencodedPrimitiveToString(key, item));
181
+ return;
182
+ }
183
+ params.append(key, formUrlencodedPrimitiveToString(key, value));
184
+ }
185
+
186
+ function formUrlencodedPrimitiveToString(key: string, value: unknown): string {
187
+ if (typeof value === "string" || typeof value === "boolean")
188
+ return String(value);
189
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
190
+ if (typeof value === "object" && value !== null) {
191
+ throw validationError(
192
+ `formUrlencoded field "${key}" is nested; flatten it using bracket notation such as "metadata[order_id]"`,
193
+ value,
194
+ ["body", key],
195
+ );
196
+ }
197
+ throw validationError(
198
+ `formUrlencoded field "${key}" must be a string, finite number, boolean, or array of those values`,
199
+ value,
200
+ ["body", key],
201
+ );
202
+ }
203
+
204
+ function encodeBinaryBody(
205
+ body: unknown,
206
+ headers: Record<string, string> | undefined,
207
+ ): RequestBodyWirePatch {
208
+ if (!isBinaryBody(body)) {
209
+ throw validationError(
210
+ 'bodyType "binary" requires an ArrayBuffer or ArrayBufferView request body',
211
+ body,
212
+ ["body"],
213
+ );
214
+ }
215
+ const bytes = binaryBodyToBytes(body);
216
+ validateBinaryBodyByteLength(bytes.byteLength, body, ["body"]);
217
+ return {
218
+ body: base64Encode(bytes),
219
+ bodyType: "binaryBody",
220
+ injectedHeaders: contentTypeHeaderIfMissing(
221
+ headers,
222
+ "application/octet-stream",
223
+ ),
224
+ };
225
+ }
226
+
227
+ function encodeMultipartFormBody(
228
+ body: unknown,
229
+ headers: Record<string, string> | undefined,
230
+ ): RequestBodyWirePatch {
231
+ if (hasHeader(headers, "content-type")) {
232
+ throw validationError(
233
+ "multipartForm bodies cannot provide Content-Type because the multipart boundary is generated at execution time",
234
+ body,
235
+ ["headers", "Content-Type"],
236
+ );
237
+ }
238
+ if (!isPlainRecord(body) || Object.keys(body).length === 0) {
239
+ throw validationError(
240
+ 'bodyType "multipartForm" requires a plain non-empty object request body',
241
+ body,
242
+ ["body"],
243
+ );
244
+ }
245
+ const parts = Object.entries(body)
246
+ .filter(([_key, value]) => value !== undefined)
247
+ .map(([key, value]) => parseMultipartPart(key, value));
248
+ if (parts.length === 0) {
249
+ throw validationError(
250
+ "multipartForm body must encode at least one field",
251
+ body,
252
+ ["body"],
253
+ );
254
+ }
255
+ validateRequestByteLength(totalMultipartPartBytes(parts), ["body"]);
256
+ const hasBinaryFile = parts.some(
257
+ (part) => part.kind === "file" && isBinaryBody(part.value),
258
+ );
259
+ return {
260
+ bodyType: hasBinaryFile ? "formDataBase64" : "formData",
261
+ formData: parts.map((part) => multipartPartToWire(part, hasBinaryFile)),
262
+ injectedHeaders: [],
263
+ };
264
+ }
265
+
266
+ function parseMultipartPart(key: string, value: unknown): MultipartPart {
267
+ if (key.length === 0)
268
+ throw validationError(
269
+ "multipartForm body keys must be non-empty strings",
270
+ value,
271
+ ["body", key],
272
+ );
273
+ if (
274
+ typeof value === "string" ||
275
+ typeof value === "number" ||
276
+ typeof value === "boolean"
277
+ ) {
278
+ const stringValue = String(value);
279
+ return {
280
+ kind: "field",
281
+ key,
282
+ value: stringValue,
283
+ byteLength: utf8ByteLength(stringValue),
284
+ };
285
+ }
286
+ if (isBinaryBody(value)) {
287
+ throw validationError(
288
+ `multipartForm binary field "${key}" must be wrapped as { value, filename }`,
289
+ value,
290
+ ["body", key],
291
+ );
292
+ }
293
+ if (!isPlainRecord(value)) {
294
+ throw validationError(
295
+ `multipartForm field "${key}" must be a primitive value or { value, filename } file part`,
296
+ value,
297
+ ["body", key],
298
+ );
299
+ }
300
+ const keys = Object.keys(value).filter(
301
+ (partKey) => value[partKey] !== undefined,
302
+ );
303
+ const missingKeys = ["value", "filename"].filter(
304
+ (requiredKey) => !keys.includes(requiredKey),
305
+ );
306
+ if (missingKeys.length > 0) {
307
+ throw validationError(
308
+ `multipartForm file part "${key}" is missing required ${pluralize("key", missingKeys)}: ${missingKeys.join(", ")}`,
309
+ value,
310
+ ["body", key],
311
+ );
312
+ }
313
+ const unexpectedKeys = keys.filter(
314
+ (partKey) => partKey !== "value" && partKey !== "filename",
315
+ );
316
+ if (unexpectedKeys.length > 0) {
317
+ throw validationError(
318
+ `multipartForm file part "${key}" has unexpected ${pluralize("key", unexpectedKeys)}: ${unexpectedKeys.join(", ")}`,
319
+ value,
320
+ ["body", key],
321
+ );
322
+ }
323
+ if (typeof value.filename !== "string" || value.filename.length === 0) {
324
+ throw validationError(
325
+ `multipartForm file part "${key}" requires a non-empty filename`,
326
+ value,
327
+ ["body", key, "filename"],
328
+ );
329
+ }
330
+ if (typeof value.value !== "string" && !isBinaryBody(value.value)) {
331
+ throw validationError(
332
+ `multipartForm file part "${key}" value must be a string or binary body`,
333
+ value,
334
+ ["body", key, "value"],
335
+ );
336
+ }
337
+ const byteLength = isBinaryBody(value.value)
338
+ ? binaryBodyToBytes(value.value).byteLength
339
+ : utf8ByteLength(value.value);
340
+ validateMultipartFilePartByteLength(byteLength, value.value, [
341
+ "body",
342
+ key,
343
+ "value",
344
+ ]);
345
+ return {
346
+ kind: "file",
347
+ key,
348
+ value: value.value,
349
+ filename: value.filename,
350
+ byteLength,
351
+ };
352
+ }
353
+
354
+ function totalMultipartPartBytes(parts: MultipartPart[]): number {
355
+ return parts.reduce((total, part) => total + part.byteLength, 0);
356
+ }
357
+
358
+ function multipartPartToWire(
359
+ part: MultipartPart,
360
+ encodeFilePartsAsBase64: boolean,
361
+ ): PartialMessage<Property> {
362
+ if (part.kind === "field") return { key: part.key, value: part.value };
363
+ if (!encodeFilePartsAsBase64) {
364
+ if (typeof part.value !== "string") {
365
+ throw validationError(
366
+ "multipartForm binary file parts require formDataBase64 encoding",
367
+ part.value,
368
+ ["body", part.key, "value"],
369
+ );
370
+ }
371
+ return {
372
+ key: part.key,
373
+ value: part.value,
374
+ file: { filename: part.filename },
375
+ };
376
+ }
377
+ const bytes = isBinaryBody(part.value)
378
+ ? binaryBodyToBytes(part.value)
379
+ : textToUtf8Bytes(part.value);
380
+ return {
381
+ key: part.key,
382
+ value: base64Encode(bytes),
383
+ file: { filename: part.filename },
384
+ };
385
+ }
386
+
387
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
388
+ if (typeof value !== "object" || value === null || Array.isArray(value))
389
+ return false;
390
+ const prototype = Object.getPrototypeOf(value);
391
+ return prototype === Object.prototype || prototype === null;
392
+ }
393
+
394
+ function isBinaryBody(value: unknown): value is BinaryBody {
395
+ return value instanceof ArrayBuffer || ArrayBuffer.isView(value);
396
+ }
397
+
398
+ function binaryBodyToBytes(value: BinaryBody): Uint8Array {
399
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
400
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
401
+ }
402
+
403
+ function validateBinaryBodyByteLength(
404
+ byteLength: number,
405
+ data: unknown,
406
+ path: (string | number)[],
407
+ ): void {
408
+ if (byteLength === 0)
409
+ throw validationError(
410
+ "binary request bodies must be non-empty",
411
+ data,
412
+ path,
413
+ );
414
+ validateRequestByteLength(byteLength, path, data);
415
+ }
416
+
417
+ function validateMultipartFilePartByteLength(
418
+ byteLength: number,
419
+ data: string | BinaryBody,
420
+ path: (string | number)[],
421
+ ): void {
422
+ if (byteLength <= MAX_BINARY_BODY_BYTES) return;
423
+ throw validationError(
424
+ REQUEST_BODY_SIZE_ERROR_MESSAGE,
425
+ isBinaryBody(data) ? data : { byteLength },
426
+ path,
427
+ );
428
+ }
429
+
430
+ function validateRequestByteLength(
431
+ byteLength: number,
432
+ path: (string | number)[],
433
+ data: unknown = { byteLength },
434
+ ): void {
435
+ if (byteLength <= MAX_BINARY_BODY_BYTES) return;
436
+ throw validationError(REQUEST_BODY_SIZE_ERROR_MESSAGE, data, path);
437
+ }
438
+
439
+ function contentTypeHeaderIfMissing(
440
+ headers: Record<string, string> | undefined,
441
+ value: string,
442
+ ): readonly InjectedHeader[] {
443
+ return hasHeader(headers, "content-type")
444
+ ? []
445
+ : [{ key: "Content-Type", value }];
446
+ }
447
+
448
+ function hasHeader(
449
+ headers: Record<string, string> | undefined,
450
+ headerName: string,
451
+ ): boolean {
452
+ return (
453
+ !!headers &&
454
+ Object.keys(headers).some(
455
+ (key) => key.toLowerCase() === headerName.toLowerCase(),
456
+ )
457
+ );
458
+ }
459
+
460
+ function base64Encode(bytes: Uint8Array): string {
461
+ if (typeof Buffer !== "undefined")
462
+ return Buffer.from(
463
+ bytes.buffer,
464
+ bytes.byteOffset,
465
+ bytes.byteLength,
466
+ ).toString("base64");
467
+ const alphabet =
468
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
469
+ let output = "";
470
+ for (let index = 0; index < bytes.length; index += 3) {
471
+ const first = bytes[index];
472
+ const second = bytes[index + 1];
473
+ const third = bytes[index + 2];
474
+ output += alphabet[first >> 2];
475
+ output += alphabet[((first & 0x03) << 4) | ((second ?? 0) >> 4)];
476
+ output +=
477
+ second === undefined
478
+ ? "="
479
+ : alphabet[((second & 0x0f) << 2) | ((third ?? 0) >> 6)];
480
+ output += third === undefined ? "=" : alphabet[third & 0x3f];
481
+ }
482
+ return output;
483
+ }
484
+
485
+ function textToUtf8Bytes(value: string): Uint8Array {
486
+ if (typeof TextEncoder !== "undefined")
487
+ return new TextEncoder().encode(value);
488
+ const bytes: number[] = [];
489
+ for (let index = 0; index < value.length; index += 1) {
490
+ const codePoint = value.codePointAt(index);
491
+ if (codePoint === undefined) continue;
492
+ if (codePoint > 0xffff) index += 1;
493
+ appendCodePointAsUtf8(bytes, codePoint);
494
+ }
495
+ return Uint8Array.from(bytes);
496
+ }
497
+
498
+ function utf8ByteLength(value: string): number {
499
+ let byteLength = 0;
500
+ for (let index = 0; index < value.length; index += 1) {
501
+ const codePoint = value.codePointAt(index);
502
+ if (codePoint === undefined) continue;
503
+ if (codePoint > 0xffff) index += 1;
504
+ if (codePoint <= 0x7f) byteLength += 1;
505
+ else if (codePoint <= 0x7ff) byteLength += 2;
506
+ else if (codePoint <= 0xffff) byteLength += 3;
507
+ else byteLength += 4;
508
+ }
509
+ return byteLength;
510
+ }
511
+
512
+ function pluralize(word: string, values: readonly unknown[]): string {
513
+ return values.length === 1 ? word : `${word}s`;
514
+ }
515
+
516
+ function appendCodePointAsUtf8(bytes: number[], codePoint: number): void {
517
+ if (codePoint <= 0x7f) bytes.push(codePoint);
518
+ else if (codePoint <= 0x7ff)
519
+ bytes.push(0xc0 | (codePoint >> 6), 0x80 | (codePoint & 0x3f));
520
+ else if (codePoint <= 0xffff)
521
+ bytes.push(
522
+ 0xe0 | (codePoint >> 12),
523
+ 0x80 | ((codePoint >> 6) & 0x3f),
524
+ 0x80 | (codePoint & 0x3f),
525
+ );
526
+ else
527
+ bytes.push(
528
+ 0xf0 | (codePoint >> 18),
529
+ 0x80 | ((codePoint >> 12) & 0x3f),
530
+ 0x80 | ((codePoint >> 6) & 0x3f),
531
+ 0x80 | (codePoint & 0x3f),
532
+ );
533
+ }
534
+
535
+ function validationError(
536
+ message: string,
537
+ data: unknown,
538
+ path: (string | number)[],
539
+ ): RestApiValidationError {
540
+ return new RestApiValidationError(message, {
541
+ data,
542
+ zodError: new z.ZodError([{ code: z.ZodIssueCode.custom, message, path }]),
543
+ });
544
+ }
545
+
546
+ function errorMessage(error: unknown): string {
547
+ return error instanceof Error ? error.message : String(error);
548
+ }
@@ -8,6 +8,7 @@ import { RestApiValidationError } from "../../errors.js";
8
8
  import type { QueryExecutor, TraceMetadata } from "../registry.js";
9
9
  import type { IntegrationConfig, IntegrationClientImpl } from "../types.js";
10
10
  import { decodeWorkerBinaryResponse } from "./decode-worker-binary-response.js";
11
+ import { encodeRequestBody } from "./request-body.js";
11
12
  import { REST_API_RESPONSE_TYPES } from "./types.js";
12
13
  import type { ApiRequestOptions } from "./types.js";
13
14
 
@@ -92,12 +93,25 @@ export abstract class RestApiClientBase implements IntegrationClientImpl {
92
93
  responseType,
93
94
  };
94
95
 
95
- if (options.body !== undefined) {
96
- request.body = JSON.stringify(options.body);
97
- request.bodyType = "jsonBody";
96
+ const bodyPatch = encodeRequestBody({
97
+ body: options.body,
98
+ bodyType: options.bodyType,
99
+ headers: options.headers,
100
+ });
101
+ if (bodyPatch.body !== undefined) {
102
+ request.body = bodyPatch.body;
103
+ }
104
+ if (bodyPatch.formData !== undefined) {
105
+ request.formData = [...bodyPatch.formData];
106
+ }
107
+ if (bodyPatch.bodyType !== undefined) {
108
+ request.bodyType = bodyPatch.bodyType;
109
+ }
110
+ for (const { key, value } of bodyPatch.injectedHeaders) {
111
+ headers.push(this.createParam(key, value));
98
112
  }
99
113
 
100
- const result = await this.executeQuery({ ...request }, undefined, metadata);
114
+ const result = await this.executeQuery(request, undefined, metadata);
101
115
 
102
116
  if (result === null || result === undefined) {
103
117
  throw new RestApiValidationError(
@@ -8,6 +8,34 @@ export const REST_API_RESPONSE_TYPES = ["binary", "json", "text"] as const;
8
8
 
9
9
  export type RestApiResponseType = (typeof REST_API_RESPONSE_TYPES)[number];
10
10
 
11
+ export const REST_API_BODY_TYPES = [
12
+ "json",
13
+ "raw",
14
+ "formUrlencoded",
15
+ "multipartForm",
16
+ "binary",
17
+ ] as const;
18
+
19
+ export type RestApiBodyType = (typeof REST_API_BODY_TYPES)[number];
20
+
21
+ export type BinaryBody = ArrayBuffer | ArrayBufferView;
22
+
23
+ export type MultipartFormValue =
24
+ | string
25
+ | number
26
+ | boolean
27
+ | { value: string | BinaryBody; filename: string };
28
+
29
+ export type MultipartFormBody = Record<string, MultipartFormValue>;
30
+
31
+ export type FormUrlencodedValue =
32
+ | string
33
+ | number
34
+ | boolean
35
+ | readonly (string | number | boolean)[];
36
+
37
+ export type FormUrlencodedBody = Record<string, FormUrlencodedValue>;
38
+
11
39
  export interface ApiRequestOptions<TBody = unknown> {
12
40
  /**
13
41
  * HTTP method for the request.
@@ -25,6 +53,18 @@ export interface ApiRequestOptions<TBody = unknown> {
25
53
  */
26
54
  body?: TBody;
27
55
 
56
+ /**
57
+ * How the request body should be encoded. Defaults to `"json"`.
58
+ *
59
+ * Use `"raw"` for caller-encoded string bodies, `"formUrlencoded"` for
60
+ * `application/x-www-form-urlencoded` payloads, `"multipartForm"` for
61
+ * multipart form fields and file parts, and `"binary"` for byte payloads.
62
+ * Worker wire values such as `"jsonBody"`, `"rawBody"`, `"formData"`,
63
+ * `"fileForm"`, `"binaryBody"`, and `"formDataBase64"` are not public SDK
64
+ * `bodyType` values.
65
+ */
66
+ bodyType?: RestApiBodyType;
67
+
28
68
  /**
29
69
  * Optional query parameters
30
70
  */
@@ -64,16 +104,20 @@ export interface ApiRequestSchema<TBody = unknown, TResponse = unknown> {
64
104
  /**
65
105
  * Interface for integration clients that support generic API requests.
66
106
  *
67
- * Providing a response schema gives type-safe, validated results. Omitting
68
- * it returns the decoded response: `Uint8Array` for `responseType: "binary"`,
69
- * or `unknown` for `responseType: "text"`. JSON still requires a schema.
70
- * Object schemas only make sense for JSON-shaped results.
107
+ * Providing a response schema gives type-safe, validated results. Omitting it
108
+ * returns the decoded response: `Uint8Array` for `responseType: "binary"`, or
109
+ * `unknown` for `responseType: "text"`. JSON still requires a schema. Object
110
+ * schemas only make sense for JSON-shaped results.
111
+ *
112
+ * Request bodies default to JSON. Set `bodyType` to one of `"json"`, `"raw"`,
113
+ * `"formUrlencoded"`, `"multipartForm"`, or `"binary"` when the endpoint
114
+ * expects a different request encoding.
71
115
  */
72
116
  export interface SupportsApiRequest {
73
117
  /**
74
118
  * Execute a generic API request with type-safe validation.
75
119
  *
76
- * @param options - Request configuration including method, path, params, and body
120
+ * @param options - Request configuration including method, path, params, body, and bodyType
77
121
  * @param schema - Zod schemas for request body and response validation
78
122
  * @param metadata - Optional trace metadata for observability (label, description)
79
123
  * @returns Validated response data
@@ -101,6 +145,16 @@ export interface SupportsApiRequest {
101
145
  * { label: 'slack.postMessage', description: 'Post a message to #alerts' }
102
146
  * );
103
147
  * // result is typed as { ts: string; channel: string }
148
+ *
149
+ * await ctx.integrations.stripe.apiRequest(
150
+ * {
151
+ * method: 'POST',
152
+ * path: '/v1/charges',
153
+ * bodyType: 'formUrlencoded',
154
+ * body: { amount: 2000, currency: 'usd' },
155
+ * },
156
+ * { response: StripeChargeSchema }
157
+ * );
104
158
  * ```
105
159
  */
106
160
  apiRequest<TBody, TResponse>(