@superblocksteam/sdk-api 2.0.165-next.0 → 2.0.166-next.0

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 (125) hide show
  1. package/README.md +1 -2
  2. package/dist/errors.d.ts +36 -3
  3. package/dist/errors.d.ts.map +1 -1
  4. package/dist/errors.js +244 -8
  5. package/dist/errors.js.map +1 -1
  6. package/dist/errors.test.d.ts +2 -0
  7. package/dist/errors.test.d.ts.map +1 -0
  8. package/dist/errors.test.js +189 -0
  9. package/dist/errors.test.js.map +1 -0
  10. package/dist/integrations/base/decode-worker-binary-response.d.ts.map +1 -1
  11. package/dist/integrations/base/decode-worker-binary-response.js +2 -5
  12. package/dist/integrations/base/decode-worker-binary-response.js.map +1 -1
  13. package/dist/integrations/base/index.d.ts +2 -1
  14. package/dist/integrations/base/index.d.ts.map +1 -1
  15. package/dist/integrations/base/index.js +1 -0
  16. package/dist/integrations/base/index.js.map +1 -1
  17. package/dist/integrations/base/request-body.d.ts +29 -0
  18. package/dist/integrations/base/request-body.d.ts.map +1 -0
  19. package/dist/integrations/base/request-body.js +238 -0
  20. package/dist/integrations/base/request-body.js.map +1 -0
  21. package/dist/integrations/base/request-body.test.d.ts +2 -0
  22. package/dist/integrations/base/request-body.test.d.ts.map +1 -0
  23. package/dist/integrations/base/request-body.test.js +466 -0
  24. package/dist/integrations/base/request-body.test.js.map +1 -0
  25. package/dist/integrations/base/rest-api-client-base.d.ts.map +1 -1
  26. package/dist/integrations/base/rest-api-client-base.js +15 -14
  27. package/dist/integrations/base/rest-api-client-base.js.map +1 -1
  28. package/dist/integrations/base/rest-api-integration-client.d.ts +2 -2
  29. package/dist/integrations/base/rest-api-integration-client.d.ts.map +1 -1
  30. package/dist/integrations/base/rest-api-integration-client.js +5 -6
  31. package/dist/integrations/base/rest-api-integration-client.js.map +1 -1
  32. package/dist/integrations/base/types.d.ts +55 -11
  33. package/dist/integrations/base/types.d.ts.map +1 -1
  34. package/dist/integrations/base/types.js +7 -0
  35. package/dist/integrations/base/types.js.map +1 -1
  36. package/dist/integrations/cosmosdb/client.d.ts.map +1 -1
  37. package/dist/integrations/cosmosdb/client.js +2 -5
  38. package/dist/integrations/cosmosdb/client.js.map +1 -1
  39. package/dist/integrations/documentation-resolver.test.js +106 -1
  40. package/dist/integrations/documentation-resolver.test.js.map +1 -1
  41. package/dist/integrations/dynamodb/client.d.ts.map +1 -1
  42. package/dist/integrations/dynamodb/client.js +2 -5
  43. package/dist/integrations/dynamodb/client.js.map +1 -1
  44. package/dist/integrations/gcs/client.d.ts.map +1 -1
  45. package/dist/integrations/gcs/client.js +2 -5
  46. package/dist/integrations/gcs/client.js.map +1 -1
  47. package/dist/integrations/gsheets/client.d.ts.map +1 -1
  48. package/dist/integrations/gsheets/client.js +2 -5
  49. package/dist/integrations/gsheets/client.js.map +1 -1
  50. package/dist/integrations/mongodb/client.d.ts.map +1 -1
  51. package/dist/integrations/mongodb/client.js +2 -5
  52. package/dist/integrations/mongodb/client.js.map +1 -1
  53. package/dist/integrations/restapiintegration/client.body-types.test.d.ts +2 -0
  54. package/dist/integrations/restapiintegration/client.body-types.test.d.ts.map +1 -0
  55. package/dist/integrations/restapiintegration/client.body-types.test.js +227 -0
  56. package/dist/integrations/restapiintegration/client.body-types.test.js.map +1 -0
  57. package/dist/integrations/restapiintegration/client.test.js +56 -30
  58. package/dist/integrations/restapiintegration/client.test.js.map +1 -1
  59. package/dist/integrations/s3/client.d.ts.map +1 -1
  60. package/dist/integrations/s3/client.js +2 -5
  61. package/dist/integrations/s3/client.js.map +1 -1
  62. package/dist/integrations/salesforce/client.d.ts.map +1 -1
  63. package/dist/integrations/salesforce/client.js +2 -5
  64. package/dist/integrations/salesforce/client.js.map +1 -1
  65. package/dist/integrations/slack/client.d.ts.map +1 -1
  66. package/dist/integrations/slack/client.js +4 -13
  67. package/dist/integrations/slack/client.js.map +1 -1
  68. package/dist/integrations/slack/client.test.js +20 -0
  69. package/dist/integrations/slack/client.test.js.map +1 -1
  70. package/dist/integrations/utils.d.ts +8 -3
  71. package/dist/integrations/utils.d.ts.map +1 -1
  72. package/dist/integrations/utils.js +42 -1
  73. package/dist/integrations/utils.js.map +1 -1
  74. package/package.json +2 -2
  75. package/src/errors.test.ts +257 -0
  76. package/src/errors.ts +304 -10
  77. package/src/integrations/base/decode-worker-binary-response.ts +5 -7
  78. package/src/integrations/base/index.ts +3 -0
  79. package/src/integrations/base/request-body.test.ts +565 -0
  80. package/src/integrations/base/request-body.ts +392 -0
  81. package/src/integrations/base/rest-api-client-base.ts +24 -19
  82. package/src/integrations/base/rest-api-integration-client.ts +14 -10
  83. package/src/integrations/base/types.ts +66 -11
  84. package/src/integrations/box/README.md +4 -60
  85. package/src/integrations/cosmosdb/client.ts +8 -7
  86. package/src/integrations/documentation-resolver.test.ts +130 -1
  87. package/src/integrations/dropbox/README.md +1 -71
  88. package/src/integrations/dropbox/docs.manifest.json +10 -1
  89. package/src/integrations/dropbox/overlays/upload-unsupported.md +3 -0
  90. package/src/integrations/dropbox/overlays/upload.md +89 -0
  91. package/src/integrations/dynamodb/client.ts +8 -7
  92. package/src/integrations/elasticsearch/README.md +0 -80
  93. package/src/integrations/elasticsearch/docs.manifest.json +10 -1
  94. package/src/integrations/elasticsearch/overlays/bulk-unsupported.md +3 -0
  95. package/src/integrations/elasticsearch/overlays/bulk.md +80 -0
  96. package/src/integrations/gcs/client.ts +8 -7
  97. package/src/integrations/googledrive/README.md +10 -36
  98. package/src/integrations/gsheets/client.ts +8 -7
  99. package/src/integrations/jira/README.md +0 -26
  100. package/src/integrations/jira/docs.manifest.json +10 -1
  101. package/src/integrations/jira/overlays/attachments-unsupported.md +3 -0
  102. package/src/integrations/jira/overlays/attachments.md +30 -0
  103. package/src/integrations/lakebase/README.md +1 -3
  104. package/src/integrations/mongodb/client.ts +8 -7
  105. package/src/integrations/postgres/README.md +1 -3
  106. package/src/integrations/restapiintegration/client.body-types.test.ts +323 -0
  107. package/src/integrations/restapiintegration/client.test.ts +68 -32
  108. package/src/integrations/restapiintegration/docs.manifest.json +8 -0
  109. package/src/integrations/restapiintegration/overlays/request-body-types-unsupported.md +7 -0
  110. package/src/integrations/restapiintegration/overlays/request-body-types.md +115 -0
  111. package/src/integrations/restapiintegration/overlays/response-types-binary.md +4 -8
  112. package/src/integrations/s3/client.ts +8 -7
  113. package/src/integrations/salesforce/client.ts +9 -7
  114. package/src/integrations/slack/client.test.ts +30 -0
  115. package/src/integrations/slack/client.ts +13 -19
  116. package/src/integrations/snowflakepostgres/README.md +1 -3
  117. package/src/integrations/stabilityai/README.md +0 -87
  118. package/src/integrations/stabilityai/docs.manifest.json +10 -1
  119. package/src/integrations/stabilityai/overlays/image-uploads-unsupported.md +3 -0
  120. package/src/integrations/stabilityai/overlays/image-uploads.md +120 -0
  121. package/src/integrations/stripe/README.md +0 -201
  122. package/src/integrations/stripe/docs.manifest.json +10 -1
  123. package/src/integrations/stripe/overlays/request-bodies-unsupported.md +3 -0
  124. package/src/integrations/stripe/overlays/request-bodies.md +204 -0
  125. package/src/integrations/utils.ts +51 -1
@@ -0,0 +1,565 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { RestApiValidationError } from "../../errors.js";
4
+ import { encodeRequestBody } from "./request-body.js";
5
+
6
+ const PNG_BYTES = [137, 80, 78, 71];
7
+ const PNG_BASE64 = Buffer.from(PNG_BYTES).toString("base64");
8
+
9
+ function arrayBufferFrom(bytes: readonly number[]): ArrayBuffer {
10
+ const buffer = new ArrayBuffer(bytes.length);
11
+ new Uint8Array(buffer).set(bytes);
12
+ return buffer;
13
+ }
14
+
15
+ describe("encodeRequestBody", () => {
16
+ it("preserves default and explicit JSON wire behavior", () => {
17
+ expect(encodeRequestBody({ body: undefined })).toEqual({ wire: {} });
18
+ expect(encodeRequestBody({ body: undefined, bodyType: "json" })).toEqual({
19
+ wire: {},
20
+ });
21
+ expect(encodeRequestBody({ body: { amount: 42 } })).toEqual({
22
+ wire: { body: JSON.stringify({ amount: 42 }), bodyType: "jsonBody" },
23
+ });
24
+ expect(
25
+ encodeRequestBody({ body: { amount: 42 }, bodyType: "json" }),
26
+ ).toEqual({
27
+ wire: { body: JSON.stringify({ amount: 42 }), bodyType: "jsonBody" },
28
+ });
29
+ expect(encodeRequestBody({ body: "plain text" })).toEqual({
30
+ wire: { body: '"plain text"', bodyType: "jsonBody" },
31
+ });
32
+ expect(encodeRequestBody({ body: null })).toEqual({
33
+ wire: { body: "null", bodyType: "jsonBody" },
34
+ });
35
+ });
36
+
37
+ it("rejects invalid JSON and bodyType inputs", () => {
38
+ expect(() =>
39
+ encodeRequestBody({ body: Uint8Array.from(PNG_BYTES) }),
40
+ ).toThrow(/bodyType: "binary"/);
41
+ const circular: Record<string, unknown> = {};
42
+ circular.self = circular;
43
+ expect(() => encodeRequestBody({ body: circular })).toThrow(
44
+ /the body contains a circular reference/,
45
+ );
46
+ expect(() => encodeRequestBody({ body: 1n })).toThrow(
47
+ /not JSON-serializable/,
48
+ );
49
+ for (const body of [() => undefined, Symbol("token")]) {
50
+ expect(() => encodeRequestBody({ body })).toThrow(RestApiValidationError);
51
+ expect(() => encodeRequestBody({ body })).toThrow(/cannot serialize a/);
52
+ }
53
+ for (const bodyType of [
54
+ "jsonBody",
55
+ "rawBody",
56
+ "formData",
57
+ "fileForm",
58
+ "binaryBody",
59
+ "formDataBase64",
60
+ ]) {
61
+ expect(() => encodeRequestBody({ body: {}, bodyType })).toThrow(
62
+ new RegExp(`Unsupported bodyType ${JSON.stringify(bodyType)}`),
63
+ );
64
+ }
65
+ // Only an omitted bodyType defaults to "json"; an explicit null is an
66
+ // invalid value like any other, not a synonym for omission.
67
+ expect(() => encodeRequestBody({ body: {}, bodyType: null })).toThrow(
68
+ /Unsupported bodyType null/,
69
+ );
70
+ });
71
+
72
+ it("classifies a circular body under both V8's and QuickJS's JSON.stringify wording", () => {
73
+ // Node/V8 throws "Converting circular structure to JSON"; the QuickJS
74
+ // sandbox sdk-api actually runs in throws "circular reference"
75
+ // (workers/javascript/packages/quickjs-emscripten vendored quickjs.c).
76
+ // Both must classify as the same bounded message.
77
+ for (const message of [
78
+ "Converting circular structure to JSON",
79
+ "circular reference",
80
+ ]) {
81
+ const stringifySpy = vi
82
+ .spyOn(JSON, "stringify")
83
+ .mockImplementationOnce(() => {
84
+ throw new TypeError(message);
85
+ });
86
+ expect(() => encodeRequestBody({ body: {} })).toThrow(
87
+ /the body contains a circular reference/,
88
+ );
89
+ stringifySpy.mockRestore();
90
+ }
91
+ });
92
+
93
+ it("rejects binary nested inside a JSON body (APPS-2739)", () => {
94
+ expect(() =>
95
+ encodeRequestBody({
96
+ body: { attributes: "{}", file: Uint8Array.from(PNG_BYTES) },
97
+ }),
98
+ ).toThrow(
99
+ /contains binary data at "file"; send file parts with bodyType: "multipartForm"/,
100
+ );
101
+ expect(() =>
102
+ encodeRequestBody({
103
+ body: {
104
+ attachments: [{ name: "a" }, { bytes: arrayBufferFrom(PNG_BYTES) }],
105
+ },
106
+ }),
107
+ ).toThrow(/contains binary data at "attachments\.1\.bytes"/);
108
+ });
109
+
110
+ it("rejects binary nested inside a Map or Set value in a JSON body", () => {
111
+ expect(() =>
112
+ encodeRequestBody({
113
+ body: { file: new Map([["bytes", Uint8Array.from(PNG_BYTES)]]) },
114
+ }),
115
+ ).toThrow(/contains binary data/);
116
+ expect(() =>
117
+ encodeRequestBody({
118
+ body: { file: new Set([Uint8Array.from(PNG_BYTES)]) },
119
+ }),
120
+ ).toThrow(/contains binary data/);
121
+ });
122
+
123
+ it("fails closed instead of silently serializing binary past the scan depth cap", () => {
124
+ let body: unknown = Uint8Array.from(PNG_BYTES);
125
+ for (let depth = 0; depth < 105; depth++) {
126
+ body = { child: body };
127
+ }
128
+ expect(() => encodeRequestBody({ body })).toThrow(/contains binary data/);
129
+ });
130
+
131
+ it("bounds the message when a nested getter throws during JSON serialization", () => {
132
+ const throwingGetter = {
133
+ get secret(): string {
134
+ throw new Error("getter boom");
135
+ },
136
+ };
137
+ expect(() => encodeRequestBody({ body: throwingGetter })).toThrow(
138
+ /not JSON-serializable: a nested value's toJSON, toString, or getter threw/,
139
+ );
140
+ let message = "";
141
+ try {
142
+ encodeRequestBody({ body: throwingGetter });
143
+ } catch (error) {
144
+ message = error instanceof Error ? error.message : "";
145
+ }
146
+ expect(message).not.toContain("getter boom");
147
+ });
148
+
149
+ it("keeps caller keys in validation messages bounded and escaped", () => {
150
+ const longKey = "k".repeat(500);
151
+ const surrogateKey = "bad\uD800key";
152
+ const cases: {
153
+ body: Record<string, unknown>;
154
+ bodyType: string;
155
+ expected: RegExp;
156
+ }[] = [
157
+ {
158
+ body: { [longKey]: { nested: true } },
159
+ bodyType: "formUrlencoded",
160
+ expected: /is nested/,
161
+ },
162
+ {
163
+ body: { [surrogateKey]: "v" },
164
+ bodyType: "formUrlencoded",
165
+ expected: /lone surrogate/,
166
+ },
167
+ {
168
+ body: { [longKey]: Uint8Array.from(PNG_BYTES) },
169
+ bodyType: "multipartForm",
170
+ expected: /wrapped as/,
171
+ },
172
+ {
173
+ body: { [surrogateKey]: { value: "x", filename: "f", [longKey]: 1 } },
174
+ bodyType: "multipartForm",
175
+ expected: /unexpected key/,
176
+ },
177
+ ];
178
+ for (const { body, bodyType, expected } of cases) {
179
+ let message = "";
180
+ try {
181
+ encodeRequestBody({ body, bodyType });
182
+ } catch (error) {
183
+ expect(error).toBeInstanceOf(RestApiValidationError);
184
+ message = error instanceof Error ? error.message : "";
185
+ }
186
+ expect(message).toMatch(expected);
187
+ expect(message).not.toContain("k".repeat(65));
188
+ expect(message).not.toContain("\uD800");
189
+ expect(message.length).toBeLessThan(400);
190
+ }
191
+ });
192
+
193
+ it("keeps unsupported bodyType messages bounded and free of caller data", () => {
194
+ expect(() =>
195
+ encodeRequestBody({ body: {}, bodyType: "x".repeat(500) }),
196
+ ).toThrow(`"${"x".repeat(64)}..."`);
197
+ for (const bodyType of [
198
+ new Uint8Array(50_000),
199
+ { secret: "s".repeat(1000) },
200
+ ]) {
201
+ let caught: unknown;
202
+ try {
203
+ encodeRequestBody({ body: {}, bodyType });
204
+ } catch (error) {
205
+ caught = error;
206
+ }
207
+ if (!(caught instanceof RestApiValidationError)) throw caught;
208
+ const message = caught.message;
209
+ expect(message).toMatch(
210
+ /^Unsupported bodyType (binary data \(50000 bytes\)|object with keys \[secret\])/,
211
+ );
212
+ expect(message).not.toContain("s".repeat(10));
213
+ expect(message.length).toBeLessThan(200);
214
+ // The mismatch branch must not bypass the module's bounded-error
215
+ // convention: details.data stays undefined and details.zodError
216
+ // carries only the already-bounded message, never the raw caller value.
217
+ expect(caught.details.data).toBeUndefined();
218
+ const detailMessage = caught.details.zodError.issues[0].message;
219
+ expect(detailMessage).toBe(message);
220
+ expect(JSON.stringify(caught.details)).not.toContain("s".repeat(10));
221
+ }
222
+ });
223
+
224
+ it("bounds caller keys embedded in the zod issue path, not just the message", () => {
225
+ const longKey = "k".repeat(500);
226
+ let caught: unknown;
227
+ try {
228
+ encodeRequestBody({
229
+ body: { [longKey]: { nested: true } },
230
+ bodyType: "formUrlencoded",
231
+ });
232
+ } catch (error) {
233
+ caught = error;
234
+ }
235
+ if (!(caught instanceof RestApiValidationError)) throw caught;
236
+ const path = caught.details.zodError.issues[0].path;
237
+ expect(JSON.stringify(path)).not.toContain("k".repeat(65));
238
+ expect(path).toEqual(["body", `${"k".repeat(64)}...`]);
239
+ });
240
+
241
+ it("bounds long keys inside describeOptionValue's object description", () => {
242
+ const longKey = "k".repeat(10_000);
243
+ let message = "";
244
+ try {
245
+ encodeRequestBody({ body: {}, bodyType: { [longKey]: 1 } });
246
+ } catch (error) {
247
+ expect(error).toBeInstanceOf(RestApiValidationError);
248
+ message = error instanceof Error ? error.message : "";
249
+ }
250
+ expect(message).toMatch(/^Unsupported bodyType object with keys/);
251
+ expect(message).not.toContain("k".repeat(65));
252
+ expect(message.length).toBeLessThan(200);
253
+ });
254
+
255
+ it("bounds the unexpected-keys list in multipart file-part errors", () => {
256
+ const extraKeys = Object.fromEntries(
257
+ Array.from({ length: 50 }, (_, i) => [`extra${i}`, i]),
258
+ );
259
+ let message = "";
260
+ try {
261
+ encodeRequestBody({
262
+ body: { file: { value: "hello", filename: "f.txt", ...extraKeys } },
263
+ bodyType: "multipartForm",
264
+ });
265
+ } catch (error) {
266
+ expect(error).toBeInstanceOf(RestApiValidationError);
267
+ message = error instanceof Error ? error.message : "";
268
+ }
269
+ expect(message).toMatch(/unexpected keys/);
270
+ expect(message).toContain(", ...");
271
+ expect(message.length).toBeLessThan(400);
272
+ });
273
+
274
+ it("passes raw non-empty string bodies through unchanged", () => {
275
+ expect(
276
+ encodeRequestBody({ body: "<note>hello</note>", bodyType: "raw" }),
277
+ ).toEqual({
278
+ wire: { body: "<note>hello</note>", bodyType: "rawBody" },
279
+ });
280
+ expect(() => encodeRequestBody({ body: "", bodyType: "raw" })).toThrow(
281
+ RestApiValidationError,
282
+ );
283
+ expect(() =>
284
+ encodeRequestBody({ body: { value: "hello" }, bodyType: "raw" }),
285
+ ).toThrow(RestApiValidationError);
286
+ });
287
+
288
+ it("encodes form-urlencoded bodies and injects Content-Type only when absent", () => {
289
+ expect(
290
+ encodeRequestBody({
291
+ body: {
292
+ amount: 2000,
293
+ currency: "usd",
294
+ empty: [],
295
+ enabled: true,
296
+ optional: undefined,
297
+ "metadata[order_id]": "6735",
298
+ tag: ["vip", "repeat"],
299
+ },
300
+ bodyType: "formUrlencoded",
301
+ }),
302
+ ).toEqual({
303
+ wire: {
304
+ body: "amount=2000&currency=usd&enabled=true&metadata%5Border_id%5D=6735&tag=vip&tag=repeat",
305
+ bodyType: "rawBody",
306
+ },
307
+ contentType: "application/x-www-form-urlencoded",
308
+ });
309
+ expect(
310
+ encodeRequestBody({
311
+ body: { amount: 2000 },
312
+ bodyType: "formUrlencoded",
313
+ headers: { "content-type": "application/custom-form" },
314
+ }),
315
+ ).toEqual({
316
+ wire: { body: "amount=2000", bodyType: "rawBody" },
317
+ });
318
+ });
319
+
320
+ it("drops undefined items from form-urlencoded array values, matching multipart semantics", () => {
321
+ expect(
322
+ encodeRequestBody({
323
+ body: { tags: ["a", undefined, "b"] },
324
+ bodyType: "formUrlencoded",
325
+ }).wire.body,
326
+ ).toBe("tags=a&tags=b");
327
+ // An array of only undefined items leaves no pairs, same as an empty array.
328
+ expect(() =>
329
+ encodeRequestBody({
330
+ body: { tags: [undefined, undefined] },
331
+ bodyType: "formUrlencoded",
332
+ }),
333
+ ).toThrow(/must encode at least one field/);
334
+ });
335
+
336
+ it("percent-encodes form-urlencoded keys and values with + for spaces", () => {
337
+ expect(
338
+ encodeRequestBody({
339
+ body: {
340
+ "odd key": "a+b=c&d",
341
+ scope: "read:orders write:orders",
342
+ unicode: "caf\u00e9 \u{1F600}",
343
+ },
344
+ bodyType: "formUrlencoded",
345
+ }).wire.body,
346
+ ).toBe(
347
+ "odd+key=a%2Bb%3Dc%26d&scope=read%3Aorders+write%3Aorders&unicode=caf%C3%A9+%F0%9F%98%80",
348
+ );
349
+ });
350
+
351
+ it("percent-encodes characters encodeURIComponent leaves literal but URLSearchParams escapes", () => {
352
+ expect(
353
+ encodeRequestBody({
354
+ body: { "a!b'c(d)e~f*g": "h!i'j(k)l~m*n" },
355
+ bodyType: "formUrlencoded",
356
+ }).wire.body,
357
+ ).toBe("a%21b%27c%28d%29e%7Ef*g=h%21i%27j%28k%29l%7Em*n");
358
+ });
359
+
360
+ it("rejects invalid form-urlencoded bodies", () => {
361
+ const invalidBodies = [
362
+ undefined,
363
+ [],
364
+ { "": "missing" },
365
+ { amount: Number.NaN },
366
+ { metadata: { orderId: "6735" } },
367
+ { empty: [] },
368
+ { message: "truncated mid-emoji \uD83D" },
369
+ { "\uDC00": "lone low surrogate key" },
370
+ ];
371
+ for (const body of invalidBodies) {
372
+ expect(() =>
373
+ encodeRequestBody({ body, bodyType: "formUrlencoded" }),
374
+ ).toThrow(RestApiValidationError);
375
+ }
376
+ expect(() =>
377
+ encodeRequestBody({
378
+ body: { metadata: { orderId: "6735" } },
379
+ bodyType: "formUrlencoded",
380
+ }),
381
+ ).toThrow(/metadata\[order_id\]/);
382
+ expect(() =>
383
+ encodeRequestBody({
384
+ body: { message: "truncated mid-emoji \uD83D" },
385
+ bodyType: "formUrlencoded",
386
+ }),
387
+ ).toThrow(/field "message" contains a lone surrogate/);
388
+ });
389
+
390
+ it.each([
391
+ ["Uint8Array", Uint8Array.from(PNG_BYTES)],
392
+ ["ArrayBuffer", arrayBufferFrom(PNG_BYTES)],
393
+ ["DataView", new DataView(arrayBufferFrom([0, ...PNG_BYTES, 1]), 1, 4)],
394
+ ["subarray view", Uint8Array.from([0, ...PNG_BYTES, 1]).subarray(1, 5)],
395
+ ["Buffer", Buffer.from(PNG_BYTES)],
396
+ ])("base64-encodes exact bytes from %s", (_name, body) => {
397
+ expect(encodeRequestBody({ body, bodyType: "binary" })).toEqual({
398
+ wire: { body: PNG_BASE64, bodyType: "binaryBody" },
399
+ contentType: "application/octet-stream",
400
+ });
401
+ });
402
+
403
+ it("rejects invalid binary bodies and preserves caller Content-Type", () => {
404
+ expect(
405
+ encodeRequestBody({
406
+ body: Uint8Array.from(PNG_BYTES),
407
+ bodyType: "binary",
408
+ headers: { "CONTENT-TYPE": "image/png" },
409
+ }),
410
+ ).toEqual({
411
+ wire: { body: PNG_BASE64, bodyType: "binaryBody" },
412
+ });
413
+ for (const body of [new ArrayBuffer(0), "not bytes"]) {
414
+ expect(() => encodeRequestBody({ body, bodyType: "binary" })).toThrow(
415
+ RestApiValidationError,
416
+ );
417
+ }
418
+ });
419
+
420
+ it("encodes multipart parts as formDataBase64 with base64 file parts", () => {
421
+ const primitives = encodeRequestBody({
422
+ body: { active: true, amount: 42, note: "hello" },
423
+ bodyType: "multipartForm",
424
+ });
425
+ expect(primitives).toEqual({
426
+ wire: {
427
+ bodyType: "formDataBase64",
428
+ formData: [
429
+ { key: "active", value: "true" },
430
+ { key: "amount", value: "42" },
431
+ { key: "note", value: "hello" },
432
+ ],
433
+ },
434
+ });
435
+ expect(primitives.wire).not.toHaveProperty("body");
436
+ expect(
437
+ encodeRequestBody({
438
+ body: {
439
+ empty: { value: "", filename: "empty.txt" },
440
+ file: {
441
+ value: "hello",
442
+ filename: "hello.txt",
443
+ contentType: undefined,
444
+ },
445
+ },
446
+ bodyType: "multipartForm",
447
+ }),
448
+ ).toEqual({
449
+ wire: {
450
+ bodyType: "formDataBase64",
451
+ formData: [
452
+ { key: "empty", value: "", file: { filename: "empty.txt" } },
453
+ {
454
+ key: "file",
455
+ value: Buffer.from("hello", "utf8").toString("base64"),
456
+ file: { filename: "hello.txt" },
457
+ },
458
+ ],
459
+ },
460
+ });
461
+ expect(
462
+ encodeRequestBody({
463
+ body: {
464
+ image: { value: Uint8Array.from(PNG_BYTES), filename: "b.png" },
465
+ note: "mixed",
466
+ textFile: { value: "cafe \u00e9", filename: "note.txt" },
467
+ },
468
+ bodyType: "multipartForm",
469
+ }),
470
+ ).toEqual({
471
+ wire: {
472
+ bodyType: "formDataBase64",
473
+ formData: [
474
+ { key: "image", value: PNG_BASE64, file: { filename: "b.png" } },
475
+ { key: "note", value: "mixed" },
476
+ {
477
+ key: "textFile",
478
+ value: Buffer.from("cafe \u00e9", "utf8").toString("base64"),
479
+ file: { filename: "note.txt" },
480
+ },
481
+ ],
482
+ },
483
+ });
484
+ });
485
+
486
+ it("repeats the key for multipart array values", () => {
487
+ expect(
488
+ encodeRequestBody({
489
+ body: {
490
+ files: [
491
+ { value: Uint8Array.from(PNG_BYTES), filename: "a.png" },
492
+ undefined,
493
+ { value: "hello", filename: "b.txt" },
494
+ ],
495
+ tags: ["x", 2],
496
+ },
497
+ bodyType: "multipartForm",
498
+ }),
499
+ ).toEqual({
500
+ wire: {
501
+ bodyType: "formDataBase64",
502
+ formData: [
503
+ { key: "files", value: PNG_BASE64, file: { filename: "a.png" } },
504
+ {
505
+ key: "files",
506
+ value: Buffer.from("hello", "utf8").toString("base64"),
507
+ file: { filename: "b.txt" },
508
+ },
509
+ { key: "tags", value: "x" },
510
+ { key: "tags", value: "2" },
511
+ ],
512
+ },
513
+ });
514
+ expect(() =>
515
+ encodeRequestBody({ body: { files: [] }, bodyType: "multipartForm" }),
516
+ ).toThrow(/at least one field/);
517
+ });
518
+
519
+ it("rejects malformed multipart bodies", () => {
520
+ expect(() =>
521
+ encodeRequestBody({
522
+ body: { file: "hello" },
523
+ bodyType: "multipartForm",
524
+ headers: { "Content-Type": "multipart/form-data" },
525
+ }),
526
+ ).toThrow(/boundary/);
527
+ const invalidBodies = [
528
+ {},
529
+ { "": "missing" },
530
+ { n: Number.NaN },
531
+ { file: Uint8Array.from(PNG_BYTES) },
532
+ {
533
+ file: {
534
+ contentType: "image/png",
535
+ filename: "b.png",
536
+ value: Uint8Array.from(PNG_BYTES),
537
+ },
538
+ },
539
+ { file: { value: "hello", filename: "" } },
540
+ ];
541
+ for (const body of invalidBodies) {
542
+ expect(() =>
543
+ encodeRequestBody({ body, bodyType: "multipartForm" }),
544
+ ).toThrow(RestApiValidationError);
545
+ }
546
+ expect(() =>
547
+ encodeRequestBody({
548
+ body: { file: { value: "contents" } },
549
+ bodyType: "multipartForm",
550
+ }),
551
+ ).toThrow(/missing required key: filename/);
552
+ expect(() =>
553
+ encodeRequestBody({
554
+ body: {
555
+ file: {
556
+ value: "contents",
557
+ filename: "contents.txt",
558
+ contentType: "text/plain",
559
+ },
560
+ },
561
+ bodyType: "multipartForm",
562
+ }),
563
+ ).toThrow(/unexpected key: "contentType"/);
564
+ });
565
+ });