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

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 (152) hide show
  1. package/README.md +23 -0
  2. package/dist/errors.d.ts +36 -3
  3. package/dist/errors.d.ts.map +1 -1
  4. package/dist/errors.js +231 -38
  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/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/integrations/anthropic/types.d.ts +1 -1
  14. package/dist/integrations/base/decode-worker-binary-response.d.ts.map +1 -1
  15. package/dist/integrations/base/decode-worker-binary-response.js +2 -5
  16. package/dist/integrations/base/decode-worker-binary-response.js.map +1 -1
  17. package/dist/integrations/base/index.d.ts +1 -1
  18. package/dist/integrations/base/index.d.ts.map +1 -1
  19. package/dist/integrations/base/request-body.d.ts +18 -10
  20. package/dist/integrations/base/request-body.d.ts.map +1 -1
  21. package/dist/integrations/base/request-body.js +131 -215
  22. package/dist/integrations/base/request-body.js.map +1 -1
  23. package/dist/integrations/base/request-body.test.js +306 -164
  24. package/dist/integrations/base/request-body.test.js.map +1 -1
  25. package/dist/integrations/base/rest-api-client-base.d.ts.map +1 -1
  26. package/dist/integrations/base/rest-api-client-base.js +13 -26
  27. package/dist/integrations/base/rest-api-client-base.js.map +1 -1
  28. package/dist/integrations/base/rest-api-integration-client.d.ts.map +1 -1
  29. package/dist/integrations/base/rest-api-integration-client.js +2 -5
  30. package/dist/integrations/base/rest-api-integration-client.js.map +1 -1
  31. package/dist/integrations/base/types.d.ts +0 -8
  32. package/dist/integrations/base/types.d.ts.map +1 -1
  33. package/dist/integrations/cosmosdb/client.d.ts.map +1 -1
  34. package/dist/integrations/cosmosdb/client.js +2 -5
  35. package/dist/integrations/cosmosdb/client.js.map +1 -1
  36. package/dist/integrations/documentation-resolver.test.js +88 -2
  37. package/dist/integrations/documentation-resolver.test.js.map +1 -1
  38. package/dist/integrations/documentation.test.js +10 -0
  39. package/dist/integrations/documentation.test.js.map +1 -1
  40. package/dist/integrations/dynamodb/client.d.ts +6 -2
  41. package/dist/integrations/dynamodb/client.d.ts.map +1 -1
  42. package/dist/integrations/dynamodb/client.js +85 -15
  43. package/dist/integrations/dynamodb/client.js.map +1 -1
  44. package/dist/integrations/dynamodb/client.test.d.ts +8 -0
  45. package/dist/integrations/dynamodb/client.test.d.ts.map +1 -0
  46. package/dist/integrations/dynamodb/client.test.js +198 -0
  47. package/dist/integrations/dynamodb/client.test.js.map +1 -0
  48. package/dist/integrations/dynamodb/index.d.ts +1 -1
  49. package/dist/integrations/dynamodb/index.d.ts.map +1 -1
  50. package/dist/integrations/dynamodb/index.js.map +1 -1
  51. package/dist/integrations/dynamodb/types.d.ts +27 -1
  52. package/dist/integrations/dynamodb/types.d.ts.map +1 -1
  53. package/dist/integrations/gcs/client.d.ts.map +1 -1
  54. package/dist/integrations/gcs/client.js +2 -5
  55. package/dist/integrations/gcs/client.js.map +1 -1
  56. package/dist/integrations/gsheets/client.d.ts.map +1 -1
  57. package/dist/integrations/gsheets/client.js +2 -5
  58. package/dist/integrations/gsheets/client.js.map +1 -1
  59. package/dist/integrations/index.d.ts +1 -1
  60. package/dist/integrations/index.d.ts.map +1 -1
  61. package/dist/integrations/index.js.map +1 -1
  62. package/dist/integrations/mongodb/client.d.ts.map +1 -1
  63. package/dist/integrations/mongodb/client.js +2 -5
  64. package/dist/integrations/mongodb/client.js.map +1 -1
  65. package/dist/integrations/restapiintegration/client.body-types.test.js +107 -25
  66. package/dist/integrations/restapiintegration/client.body-types.test.js.map +1 -1
  67. package/dist/integrations/restapiintegration/client.test.js +16 -0
  68. package/dist/integrations/restapiintegration/client.test.js.map +1 -1
  69. package/dist/integrations/s3/client.d.ts.map +1 -1
  70. package/dist/integrations/s3/client.js +2 -5
  71. package/dist/integrations/s3/client.js.map +1 -1
  72. package/dist/integrations/salesforce/client.d.ts.map +1 -1
  73. package/dist/integrations/salesforce/client.js +2 -5
  74. package/dist/integrations/salesforce/client.js.map +1 -1
  75. package/dist/integrations/slack/client.d.ts.map +1 -1
  76. package/dist/integrations/slack/client.js +4 -13
  77. package/dist/integrations/slack/client.js.map +1 -1
  78. package/dist/integrations/slack/client.test.js +2 -5
  79. package/dist/integrations/slack/client.test.js.map +1 -1
  80. package/dist/integrations/utils.d.ts +8 -3
  81. package/dist/integrations/utils.d.ts.map +1 -1
  82. package/dist/integrations/utils.js +42 -1
  83. package/dist/integrations/utils.js.map +1 -1
  84. package/dist/runtime/context.d.ts +6 -0
  85. package/dist/runtime/context.d.ts.map +1 -1
  86. package/dist/runtime/context.js +2 -1
  87. package/dist/runtime/context.js.map +1 -1
  88. package/dist/runtime/executor.d.ts +6 -0
  89. package/dist/runtime/executor.d.ts.map +1 -1
  90. package/dist/runtime/executor.js +1 -0
  91. package/dist/runtime/executor.js.map +1 -1
  92. package/dist/types.d.ts +10 -0
  93. package/dist/types.d.ts.map +1 -1
  94. package/package.json +3 -3
  95. package/src/errors.test.ts +257 -0
  96. package/src/errors.ts +289 -46
  97. package/src/index.ts +1 -0
  98. package/src/integrations/anthropic/README.md +12 -59
  99. package/src/integrations/anthropic/types.ts +1 -1
  100. package/src/integrations/base/decode-worker-binary-response.ts +5 -7
  101. package/src/integrations/base/index.ts +0 -5
  102. package/src/integrations/base/request-body.test.ts +358 -195
  103. package/src/integrations/base/request-body.ts +170 -326
  104. package/src/integrations/base/rest-api-client-base.ts +22 -31
  105. package/src/integrations/base/rest-api-integration-client.ts +8 -7
  106. package/src/integrations/base/types.ts +0 -18
  107. package/src/integrations/box/README.md +4 -60
  108. package/src/integrations/cosmosdb/client.ts +8 -7
  109. package/src/integrations/documentation-resolver.test.ts +108 -2
  110. package/src/integrations/documentation.test.ts +12 -0
  111. package/src/integrations/dropbox/README.md +1 -71
  112. package/src/integrations/dropbox/docs.manifest.json +10 -1
  113. package/src/integrations/dropbox/overlays/upload-unsupported.md +3 -0
  114. package/src/integrations/dropbox/overlays/upload.md +89 -0
  115. package/src/integrations/dynamodb/README.md +33 -12
  116. package/src/integrations/dynamodb/client.test.ts +254 -0
  117. package/src/integrations/dynamodb/client.ts +129 -23
  118. package/src/integrations/dynamodb/index.ts +5 -1
  119. package/src/integrations/dynamodb/types.ts +33 -1
  120. package/src/integrations/elasticsearch/README.md +0 -80
  121. package/src/integrations/elasticsearch/docs.manifest.json +10 -1
  122. package/src/integrations/elasticsearch/overlays/bulk-unsupported.md +3 -0
  123. package/src/integrations/elasticsearch/overlays/bulk.md +80 -0
  124. package/src/integrations/gcs/client.ts +8 -7
  125. package/src/integrations/googledrive/README.md +10 -36
  126. package/src/integrations/gsheets/client.ts +8 -7
  127. package/src/integrations/index.ts +1 -0
  128. package/src/integrations/jira/README.md +0 -26
  129. package/src/integrations/jira/docs.manifest.json +10 -1
  130. package/src/integrations/jira/overlays/attachments-unsupported.md +3 -0
  131. package/src/integrations/jira/overlays/attachments.md +30 -0
  132. package/src/integrations/mongodb/client.ts +8 -7
  133. package/src/integrations/restapiintegration/client.body-types.test.ts +135 -26
  134. package/src/integrations/restapiintegration/client.test.ts +23 -0
  135. package/src/integrations/restapiintegration/docs.manifest.json +2 -2
  136. package/src/integrations/restapiintegration/overlays/request-body-types.md +26 -17
  137. package/src/integrations/s3/client.ts +8 -7
  138. package/src/integrations/salesforce/client.ts +9 -7
  139. package/src/integrations/slack/client.test.ts +2 -5
  140. package/src/integrations/slack/client.ts +13 -19
  141. package/src/integrations/stabilityai/README.md +0 -87
  142. package/src/integrations/stabilityai/docs.manifest.json +10 -1
  143. package/src/integrations/stabilityai/overlays/image-uploads-unsupported.md +3 -0
  144. package/src/integrations/stabilityai/overlays/image-uploads.md +120 -0
  145. package/src/integrations/stripe/README.md +0 -201
  146. package/src/integrations/stripe/docs.manifest.json +10 -1
  147. package/src/integrations/stripe/overlays/request-bodies-unsupported.md +3 -0
  148. package/src/integrations/stripe/overlays/request-bodies.md +204 -0
  149. package/src/integrations/utils.ts +51 -1
  150. package/src/runtime/context.ts +9 -0
  151. package/src/runtime/executor.ts +8 -0
  152. package/src/types.ts +11 -0
@@ -1,17 +1,10 @@
1
- import { describe, expect, expectTypeOf, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
 
3
- import { RestApiBodyDataType } from "@superblocksteam/shared";
4
-
5
- import {
6
- REDACTED_BINARY_RESPONSE_DATA,
7
- RestApiValidationError,
8
- } from "../../errors.js";
9
- import { encodeRequestBody, MAX_BINARY_BODY_BYTES } from "./request-body.js";
10
- import { REST_API_BODY_TYPES, type RestApiBodyType } from "./types.js";
3
+ import { RestApiValidationError } from "../../errors.js";
4
+ import { encodeRequestBody } from "./request-body.js";
11
5
 
12
6
  const PNG_BYTES = [137, 80, 78, 71];
13
7
  const PNG_BASE64 = Buffer.from(PNG_BYTES).toString("base64");
14
- const AGENT_REQUEST_CAP_BYTES = 28 * 1024 * 1024;
15
8
 
16
9
  function arrayBufferFrom(bytes: readonly number[]): ArrayBuffer {
17
10
  const buffer = new ArrayBuffer(bytes.length);
@@ -19,62 +12,25 @@ function arrayBufferFrom(bytes: readonly number[]): ArrayBuffer {
19
12
  return buffer;
20
13
  }
21
14
 
22
- function getRestApiValidationError(fn: () => unknown): RestApiValidationError {
23
- try {
24
- fn();
25
- } catch (error) {
26
- if (error instanceof RestApiValidationError) return error;
27
- throw error;
28
- }
29
- throw new Error("Expected RestApiValidationError");
30
- }
31
-
32
- function expectBinaryDataRedacted(data: unknown): void {
33
- if (data !== REDACTED_BINARY_RESPONSE_DATA) {
34
- throw new Error("Expected binary validation data to be redacted");
35
- }
36
- }
37
-
38
15
  describe("encodeRequestBody", () => {
39
- it("keeps the public body type contract SDK-owned", () => {
40
- expect(REST_API_BODY_TYPES).toEqual([
41
- "json",
42
- "raw",
43
- "formUrlencoded",
44
- "multipartForm",
45
- "binary",
46
- ]);
47
- expectTypeOf<RestApiBodyType>().toEqualTypeOf<
48
- "json" | "raw" | "formUrlencoded" | "multipartForm" | "binary"
49
- >();
50
- expect(Object.values(RestApiBodyDataType).sort()).toEqual([
51
- "fileForm",
52
- "formData",
53
- "jsonBody",
54
- "rawBody",
55
- ]);
56
- expect(Object.values(RestApiBodyDataType)).not.toContain("binaryBody");
57
- expect(Object.values(RestApiBodyDataType)).not.toContain("formDataBase64");
58
- });
59
-
60
16
  it("preserves default and explicit JSON wire behavior", () => {
61
- expect(encodeRequestBody({ body: undefined })).toEqual({
62
- injectedHeaders: [],
63
- });
17
+ expect(encodeRequestBody({ body: undefined })).toEqual({ wire: {} });
64
18
  expect(encodeRequestBody({ body: undefined, bodyType: "json" })).toEqual({
65
- injectedHeaders: [],
19
+ wire: {},
66
20
  });
67
21
  expect(encodeRequestBody({ body: { amount: 42 } })).toEqual({
68
- body: JSON.stringify({ amount: 42 }),
69
- bodyType: "jsonBody",
70
- injectedHeaders: [],
22
+ wire: { body: JSON.stringify({ amount: 42 }), bodyType: "jsonBody" },
71
23
  });
72
24
  expect(
73
25
  encodeRequestBody({ body: { amount: 42 }, bodyType: "json" }),
74
26
  ).toEqual({
75
- body: JSON.stringify({ amount: 42 }),
76
- bodyType: "jsonBody",
77
- injectedHeaders: [],
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" },
78
34
  });
79
35
  });
80
36
 
@@ -84,8 +40,15 @@ describe("encodeRequestBody", () => {
84
40
  ).toThrow(/bodyType: "binary"/);
85
41
  const circular: Record<string, unknown> = {};
86
42
  circular.self = circular;
87
- for (const body of [circular, 1n, () => undefined]) {
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")]) {
88
50
  expect(() => encodeRequestBody({ body })).toThrow(RestApiValidationError);
51
+ expect(() => encodeRequestBody({ body })).toThrow(/cannot serialize a/);
89
52
  }
90
53
  for (const bodyType of [
91
54
  "jsonBody",
@@ -99,15 +62,220 @@ describe("encodeRequestBody", () => {
99
62
  new RegExp(`Unsupported bodyType ${JSON.stringify(bodyType)}`),
100
63
  );
101
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);
102
272
  });
103
273
 
104
274
  it("passes raw non-empty string bodies through unchanged", () => {
105
275
  expect(
106
276
  encodeRequestBody({ body: "<note>hello</note>", bodyType: "raw" }),
107
277
  ).toEqual({
108
- body: "<note>hello</note>",
109
- bodyType: "rawBody",
110
- injectedHeaders: [],
278
+ wire: { body: "<note>hello</note>", bodyType: "rawBody" },
111
279
  });
112
280
  expect(() => encodeRequestBody({ body: "", bodyType: "raw" })).toThrow(
113
281
  RestApiValidationError,
@@ -132,11 +300,11 @@ describe("encodeRequestBody", () => {
132
300
  bodyType: "formUrlencoded",
133
301
  }),
134
302
  ).toEqual({
135
- body: "amount=2000&currency=usd&enabled=true&metadata%5Border_id%5D=6735&tag=vip&tag=repeat",
136
- bodyType: "rawBody",
137
- injectedHeaders: [
138
- { key: "Content-Type", value: "application/x-www-form-urlencoded" },
139
- ],
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",
140
308
  });
141
309
  expect(
142
310
  encodeRequestBody({
@@ -145,12 +313,50 @@ describe("encodeRequestBody", () => {
145
313
  headers: { "content-type": "application/custom-form" },
146
314
  }),
147
315
  ).toEqual({
148
- body: "amount=2000",
149
- bodyType: "rawBody",
150
- injectedHeaders: [],
316
+ wire: { body: "amount=2000", bodyType: "rawBody" },
151
317
  });
152
318
  });
153
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
+
154
360
  it("rejects invalid form-urlencoded bodies", () => {
155
361
  const invalidBodies = [
156
362
  undefined,
@@ -159,6 +365,8 @@ describe("encodeRequestBody", () => {
159
365
  { amount: Number.NaN },
160
366
  { metadata: { orderId: "6735" } },
161
367
  { empty: [] },
368
+ { message: "truncated mid-emoji \uD83D" },
369
+ { "\uDC00": "lone low surrogate key" },
162
370
  ];
163
371
  for (const body of invalidBodies) {
164
372
  expect(() =>
@@ -171,6 +379,12 @@ describe("encodeRequestBody", () => {
171
379
  bodyType: "formUrlencoded",
172
380
  }),
173
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/);
174
388
  });
175
389
 
176
390
  it.each([
@@ -181,11 +395,8 @@ describe("encodeRequestBody", () => {
181
395
  ["Buffer", Buffer.from(PNG_BYTES)],
182
396
  ])("base64-encodes exact bytes from %s", (_name, body) => {
183
397
  expect(encodeRequestBody({ body, bodyType: "binary" })).toEqual({
184
- body: PNG_BASE64,
185
- bodyType: "binaryBody",
186
- injectedHeaders: [
187
- { key: "Content-Type", value: "application/octet-stream" },
188
- ],
398
+ wire: { body: PNG_BASE64, bodyType: "binaryBody" },
399
+ contentType: "application/octet-stream",
189
400
  });
190
401
  });
191
402
 
@@ -197,83 +408,31 @@ describe("encodeRequestBody", () => {
197
408
  headers: { "CONTENT-TYPE": "image/png" },
198
409
  }),
199
410
  ).toEqual({
200
- body: PNG_BASE64,
201
- bodyType: "binaryBody",
202
- injectedHeaders: [],
411
+ wire: { body: PNG_BASE64, bodyType: "binaryBody" },
203
412
  });
204
- for (const body of [
205
- new ArrayBuffer(0),
206
- "not bytes",
207
- new Uint8Array(MAX_BINARY_BODY_BYTES + 1),
208
- ]) {
413
+ for (const body of [new ArrayBuffer(0), "not bytes"]) {
209
414
  expect(() => encodeRequestBody({ body, bodyType: "binary" })).toThrow(
210
415
  RestApiValidationError,
211
416
  );
212
417
  }
213
418
  });
214
419
 
215
- it("keeps the binary request cap below the serialized agent request cap", () => {
216
- expect(Math.ceil(MAX_BINARY_BODY_BYTES / 3) * 4).toBeLessThan(
217
- AGENT_REQUEST_CAP_BYTES,
218
- );
219
- });
220
-
221
- it("redacts binary validation data for invalid request bodies", () => {
222
- const binaryError = getRestApiValidationError(() =>
223
- encodeRequestBody({
224
- body: new ArrayBuffer(MAX_BINARY_BODY_BYTES + 1),
225
- bodyType: "binary",
226
- }),
227
- );
228
- expectBinaryDataRedacted(binaryError.details.data);
229
-
230
- const multipartError = getRestApiValidationError(() =>
231
- encodeRequestBody({
232
- body: {
233
- file: {
234
- value: new Uint8Array(MAX_BINARY_BODY_BYTES + 1),
235
- filename: "too-large.bin",
236
- },
237
- },
238
- bodyType: "multipartForm",
239
- }),
240
- );
241
- expectBinaryDataRedacted(multipartError.details.data);
242
-
243
- const invalidPartError = getRestApiValidationError(() =>
244
- encodeRequestBody({
245
- body: {
246
- file: {
247
- contentType: "application/octet-stream",
248
- filename: "too-large.bin",
249
- value: Uint8Array.from(PNG_BYTES),
250
- },
251
- },
252
- bodyType: "multipartForm",
253
- }),
254
- );
255
- expect(invalidPartError.details.data).toEqual({
256
- contentType: "application/octet-stream",
257
- filename: "too-large.bin",
258
- value: REDACTED_BINARY_RESPONSE_DATA,
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",
259
424
  });
260
- });
261
-
262
- it("encodes multipart primitive, string-file, and mixed binary file parts", () => {
263
- expect(
264
- encodeRequestBody({
265
- body: { active: true, amount: 42, note: "hello" },
266
- bodyType: "multipartForm",
267
- }),
268
- ).toEqual({
269
- bodyType: "formData",
270
- formData: [
271
- { key: "active", value: "true" },
272
- { key: "amount", value: "42" },
273
- { key: "note", value: "hello" },
274
- ],
275
- injectedHeaders: [],
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
+ },
276
434
  });
435
+ expect(primitives.wire).not.toHaveProperty("body");
277
436
  expect(
278
437
  encodeRequestBody({
279
438
  body: {
@@ -287,12 +446,17 @@ describe("encodeRequestBody", () => {
287
446
  bodyType: "multipartForm",
288
447
  }),
289
448
  ).toEqual({
290
- bodyType: "formData",
291
- formData: [
292
- { key: "empty", value: "", file: { filename: "empty.txt" } },
293
- { key: "file", value: "hello", file: { filename: "hello.txt" } },
294
- ],
295
- injectedHeaders: [],
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
+ },
296
460
  });
297
461
  expect(
298
462
  encodeRequestBody({
@@ -304,18 +468,52 @@ describe("encodeRequestBody", () => {
304
468
  bodyType: "multipartForm",
305
469
  }),
306
470
  ).toEqual({
307
- bodyType: "formDataBase64",
308
- formData: [
309
- { key: "image", value: PNG_BASE64, file: { filename: "b.png" } },
310
- { key: "note", value: "mixed" },
311
- {
312
- key: "textFile",
313
- value: Buffer.from("cafe \u00e9", "utf8").toString("base64"),
314
- file: { filename: "note.txt" },
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],
315
496
  },
316
- ],
317
- injectedHeaders: [],
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
+ },
318
513
  });
514
+ expect(() =>
515
+ encodeRequestBody({ body: { files: [] }, bodyType: "multipartForm" }),
516
+ ).toThrow(/at least one field/);
319
517
  });
320
518
 
321
519
  it("rejects malformed multipart bodies", () => {
@@ -329,6 +527,7 @@ describe("encodeRequestBody", () => {
329
527
  const invalidBodies = [
330
528
  {},
331
529
  { "": "missing" },
530
+ { n: Number.NaN },
332
531
  { file: Uint8Array.from(PNG_BYTES) },
333
532
  {
334
533
  file: {
@@ -338,12 +537,6 @@ describe("encodeRequestBody", () => {
338
537
  },
339
538
  },
340
539
  { file: { value: "hello", filename: "" } },
341
- {
342
- file: {
343
- value: new Uint8Array(MAX_BINARY_BODY_BYTES + 1),
344
- filename: "too-large.bin",
345
- },
346
- },
347
540
  ];
348
541
  for (const body of invalidBodies) {
349
542
  expect(() =>
@@ -367,36 +560,6 @@ describe("encodeRequestBody", () => {
367
560
  },
368
561
  bodyType: "multipartForm",
369
562
  }),
370
- ).toThrow(/unexpected key: contentType/);
371
- });
372
-
373
- it("rejects multipart bodies whose total file-part bytes exceed the request budget", () => {
374
- const partBytes = Math.floor(MAX_BINARY_BODY_BYTES / 2) + 1;
375
- const first = "a".repeat(partBytes);
376
- const second = "b".repeat(partBytes);
377
-
378
- expect(() =>
379
- encodeRequestBody({
380
- body: {
381
- first: { value: first, filename: "first.txt" },
382
- second: { value: second, filename: "second.txt" },
383
- },
384
- bodyType: "multipartForm",
385
- }),
386
- ).toThrow(/20 MiB/);
387
- });
388
-
389
- it("rejects raw and multipart field bodies that exceed the request budget", () => {
390
- const tooLarge = "x".repeat(MAX_BINARY_BODY_BYTES + 1);
391
-
392
- expect(() =>
393
- encodeRequestBody({ body: tooLarge, bodyType: "raw" }),
394
- ).toThrow(/request bodies must be 20 MiB or smaller/);
395
- expect(() =>
396
- encodeRequestBody({
397
- body: { csv: tooLarge },
398
- bodyType: "multipartForm",
399
- }),
400
- ).toThrow(/request bodies must be 20 MiB or smaller/);
563
+ ).toThrow(/unexpected key: "contentType"/);
401
564
  });
402
565
  });