@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
@@ -3,44 +3,38 @@ import { z } from "zod";
3
3
 
4
4
  import type { Property } from "@superblocksteam/types/dist/src/common/v1/plugin_pb";
5
5
 
6
- import { RestApiValidationError } from "../../errors.js";
6
+ import { findBinaryDataPath, isBinaryData } from "../../errors.js";
7
+ import { describeOptionValue, validationError } from "../utils.js";
7
8
  import { REST_API_BODY_TYPES } from "./types.js";
8
- import type { BinaryBody } from "./types.js";
9
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";
10
+ type BinaryBody = ArrayBuffer | ArrayBufferView;
14
11
 
15
12
  type RequestBodyWireType =
16
13
  | "jsonBody"
17
14
  | "rawBody"
18
- | "formData"
19
15
  | "formDataBase64"
20
16
  | "binaryBody";
21
- type InjectedHeader = { readonly key: string; readonly value: string };
22
17
 
23
- export type RequestBodyWirePatch = {
18
+ /**
19
+ * Fields safe to spread directly onto the proto `RestApiRequest`. Never
20
+ * carries `contentType`: that is a header directive, not a wire field, and
21
+ * lives on `EncodedRequestBody` instead so a caller cannot spread it onto
22
+ * the wire message by mistake.
23
+ */
24
+ export type RequestBodyWireFields = {
24
25
  readonly body?: string;
25
- readonly formData?: readonly PartialMessage<Property>[];
26
+ readonly formData?: PartialMessage<Property>[];
26
27
  readonly bodyType?: RequestBodyWireType;
27
- readonly injectedHeaders: readonly InjectedHeader[];
28
28
  };
29
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
- };
30
+ /**
31
+ * The encoder's full result: wire fields plus an optional Content-Type the
32
+ * caller must push onto request headers itself.
33
+ */
34
+ export type EncodedRequestBody = {
35
+ readonly wire: RequestBodyWireFields;
36
+ readonly contentType?: string;
37
+ };
44
38
 
45
39
  const requestBodyTypeSchema = z.enum(REST_API_BODY_TYPES);
46
40
 
@@ -48,51 +42,85 @@ export function encodeRequestBody(input: {
48
42
  body: unknown;
49
43
  bodyType?: unknown;
50
44
  headers?: Record<string, string>;
51
- }): RequestBodyWirePatch {
45
+ }): EncodedRequestBody {
52
46
  const bodyTypeResult = requestBodyTypeSchema.safeParse(
53
- input.bodyType ?? "json",
47
+ input.bodyType === undefined ? "json" : input.bodyType,
54
48
  );
55
49
  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 },
50
+ throw validationError(
51
+ `Unsupported bodyType ${describeOptionValue(input.bodyType)} - expected one of: ${REST_API_BODY_TYPES.join(", ")}`,
52
+ ["bodyType"],
59
53
  );
60
54
  }
61
55
 
62
56
  const bodyType = bodyTypeResult.data;
63
57
  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
- );
58
+ if (bodyType === "json") return { wire: {} };
59
+ throw validationError(`bodyType "${bodyType}" requires a request body`, [
60
+ "body",
61
+ ]);
70
62
  }
71
63
 
72
64
  switch (bodyType) {
73
65
  case "json":
74
- return encodeJsonBody(input.body);
66
+ return { wire: encodeJsonBody(input.body) };
75
67
  case "raw":
76
- return encodeRawBody(input.body);
68
+ return { wire: encodeRawBody(input.body) };
77
69
  case "formUrlencoded":
78
70
  return encodeFormUrlencodedBody(input.body, input.headers);
79
71
  case "multipartForm":
80
- return encodeMultipartFormBody(input.body, input.headers);
72
+ return { wire: encodeMultipartFormBody(input.body, input.headers) };
81
73
  case "binary":
82
74
  return encodeBinaryBody(input.body, input.headers);
83
- default: {
84
- const _exhaustive: never = bodyType;
85
- return _exhaustive;
86
- }
87
75
  }
88
76
  }
89
77
 
90
- function encodeJsonBody(body: unknown): RequestBodyWirePatch {
91
- if (isBinaryBody(body)) {
78
+ // JSON.stringify's own error text is unbounded: a circular-structure message
79
+ // embeds the caller's full property path and constructor names, and a
80
+ // throwing toJSON()/getter contributes an arbitrary caller-authored string.
81
+ // Every other message in this module goes through describeOptionValue's
82
+ // bounded conventions; classify the failure instead of echoing its text.
83
+ function describeJsonSerializationFailure(error: unknown): string {
84
+ if (
85
+ error instanceof TypeError &&
86
+ /circular (structure|reference)/i.test(error.message)
87
+ ) {
88
+ return "the body contains a circular reference";
89
+ }
90
+ if (error instanceof TypeError && /BigInt/i.test(error.message)) {
91
+ return "the body contains a BigInt value";
92
+ }
93
+ return "a nested value's toJSON, toString, or getter threw while serializing the body";
94
+ }
95
+
96
+ function encodeJsonBody(body: unknown): RequestBodyWireFields {
97
+ if (isBinaryData(body)) {
98
+ throw validationError('Binary request bodies require bodyType: "binary"', [
99
+ "body",
100
+ ]);
101
+ }
102
+ // The walk itself only ever throws MAX_BINARY_SCAN_DEPTH's RangeError
103
+ // (see errors.ts); anything else (a throwing getter, an unusual toJSON)
104
+ // is left for JSON.stringify to judge below, which reports the same
105
+ // failure as a serialization error. A depth-capped walk fails closed
106
+ // instead: treat the body as containing binary at an unknown location
107
+ // rather than assume none, because JSON.stringify does not throw on a
108
+ // Uint8Array/Buffer/typed array -- the exact shape this check exists to
109
+ // catch -- it silently serializes them into digit-keyed garbage.
110
+ let binaryPath: (string | number)[] | undefined;
111
+ try {
112
+ binaryPath = findBinaryDataPath(body, isBinaryData);
113
+ } catch (error) {
114
+ binaryPath = error instanceof RangeError ? [] : undefined;
115
+ }
116
+ if (binaryPath !== undefined) {
117
+ const location =
118
+ binaryPath.length > 0
119
+ ? ` at ${describeOptionValue(binaryPath.join("."))}`
120
+ : "";
92
121
  throw validationError(
93
- 'Binary request bodies require bodyType: "binary"',
94
- body,
95
- ["body"],
122
+ `bodyType "json" request body contains binary data${location}; send file parts with bodyType: "multipartForm" as { value, filename }, or send bytes alone with bodyType: "binary"`,
123
+ ["body", ...binaryPath],
96
124
  );
97
125
  }
98
126
  let serialized: string | undefined;
@@ -100,87 +128,99 @@ function encodeJsonBody(body: unknown): RequestBodyWirePatch {
100
128
  serialized = JSON.stringify(body);
101
129
  } catch (error) {
102
130
  throw validationError(
103
- `JSON request body could not be serialized: ${errorMessage(error)}`,
104
- body,
131
+ `bodyType "json" request body is not JSON-serializable: ${describeJsonSerializationFailure(error)}`,
105
132
  ["body"],
106
133
  );
107
134
  }
108
135
  if (serialized === undefined) {
109
136
  throw validationError(
110
- "JSON request body must serialize to a JSON value",
111
- body,
137
+ `bodyType "json" cannot serialize a ${typeof body} request body`,
112
138
  ["body"],
113
139
  );
114
140
  }
115
- validateRequestByteLength(utf8ByteLength(serialized), ["body"]);
116
- return { body: serialized, bodyType: "jsonBody", injectedHeaders: [] };
141
+ return { body: serialized, bodyType: "jsonBody" };
117
142
  }
118
143
 
119
- function encodeRawBody(body: unknown): RequestBodyWirePatch {
144
+ function encodeRawBody(body: unknown): RequestBodyWireFields {
120
145
  if (typeof body !== "string" || body.length === 0) {
121
146
  throw validationError(
122
147
  'bodyType "raw" requires a non-empty string request body',
123
- body,
124
148
  ["body"],
125
149
  );
126
150
  }
127
- validateRequestByteLength(utf8ByteLength(body), ["body"]);
128
- return { body, bodyType: "rawBody", injectedHeaders: [] };
151
+ return { body, bodyType: "rawBody" };
152
+ }
153
+
154
+ // A field's value may be a scalar or an array; either way, undefined items
155
+ // (an explicitly-undefined optional field, or an undefined array element)
156
+ // are omitted rather than encoded, matching JSON.stringify's default-body
157
+ // semantics. Shared by encodeFormUrlencodedBody and encodeMultipartFormBody.
158
+ function definedArrayValues(value: unknown): unknown[] {
159
+ return (Array.isArray(value) ? value : [value]).filter(
160
+ (item) => item !== undefined,
161
+ );
129
162
  }
130
163
 
131
164
  function encodeFormUrlencodedBody(
132
165
  body: unknown,
133
166
  headers: Record<string, string> | undefined,
134
- ): RequestBodyWirePatch {
167
+ ): EncodedRequestBody {
135
168
  if (!isPlainRecord(body)) {
136
169
  throw validationError(
137
170
  'bodyType "formUrlencoded" requires a plain object request body',
138
- body,
139
171
  ["body"],
140
172
  );
141
173
  }
142
- const params = new URLSearchParams();
174
+ const pairs: string[] = [];
143
175
  for (const [key, value] of Object.entries(body)) {
144
176
  if (value === undefined) continue;
145
177
  if (key.length === 0) {
146
178
  throw validationError(
147
179
  "formUrlencoded body keys must be non-empty strings",
148
- body,
149
180
  ["body", key],
150
181
  );
151
182
  }
152
- appendFormUrlencodedValue(params, key, value);
183
+ const encodedKey = formUrlencode(key, key);
184
+ for (const item of definedArrayValues(value)) {
185
+ pairs.push(
186
+ `${encodedKey}=${formUrlencode(formUrlencodedPrimitiveToString(key, item), key)}`,
187
+ );
188
+ }
153
189
  }
154
- const encodedBody = params.toString();
155
- if (encodedBody.length === 0) {
190
+ if (pairs.length === 0) {
156
191
  throw validationError(
157
192
  "formUrlencoded body must encode at least one field",
158
- body,
159
193
  ["body"],
160
194
  );
161
195
  }
162
- validateRequestByteLength(utf8ByteLength(encodedBody), ["body"]);
163
196
  return {
164
- body: encodedBody,
165
- bodyType: "rawBody",
166
- injectedHeaders: contentTypeHeaderIfMissing(
197
+ wire: { body: pairs.join("&"), bodyType: "rawBody" },
198
+ contentType: contentTypeIfMissing(
167
199
  headers,
168
200
  "application/x-www-form-urlencoded",
169
201
  ),
170
202
  };
171
203
  }
172
204
 
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;
205
+ // The WASM sdk-api sandbox has no URLSearchParams global, so encode by hand.
206
+ // encodeURIComponent's unreserved set leaves `! ' ( ) ~` unescaped, but the
207
+ // WHATWG application/x-www-form-urlencoded safe set (what URLSearchParams
208
+ // produces) escapes those five; only alphanumerics, `* - . _`, and space
209
+ // (as `+`) stay literal.
210
+ function formUrlencode(value: string, key: string): string {
211
+ try {
212
+ return encodeURIComponent(value)
213
+ .replace(/%20/g, "+")
214
+ .replace(
215
+ /[!'()~]/g,
216
+ (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
217
+ );
218
+ } catch {
219
+ throw validationError(
220
+ `formUrlencoded field ${describeOptionValue(key)} contains a lone surrogate and cannot be percent-encoded`,
221
+ ["body", key],
222
+ );
182
223
  }
183
- params.append(key, formUrlencodedPrimitiveToString(key, value));
184
224
  }
185
225
 
186
226
  function formUrlencodedPrimitiveToString(key: string, value: unknown): string {
@@ -189,14 +229,12 @@ function formUrlencodedPrimitiveToString(key: string, value: unknown): string {
189
229
  if (typeof value === "number" && Number.isFinite(value)) return String(value);
190
230
  if (typeof value === "object" && value !== null) {
191
231
  throw validationError(
192
- `formUrlencoded field "${key}" is nested; flatten it using bracket notation such as "metadata[order_id]"`,
193
- value,
232
+ `formUrlencoded field ${describeOptionValue(key)} is nested; flatten it using bracket notation such as "metadata[order_id]"`,
194
233
  ["body", key],
195
234
  );
196
235
  }
197
236
  throw validationError(
198
- `formUrlencoded field "${key}" must be a string, finite number, boolean, or array of those values`,
199
- value,
237
+ `formUrlencoded field ${describeOptionValue(key)} must be a string, finite number, boolean, or array of those values`,
200
238
  ["body", key],
201
239
  );
202
240
  }
@@ -204,96 +242,74 @@ function formUrlencodedPrimitiveToString(key: string, value: unknown): string {
204
242
  function encodeBinaryBody(
205
243
  body: unknown,
206
244
  headers: Record<string, string> | undefined,
207
- ): RequestBodyWirePatch {
208
- if (!isBinaryBody(body)) {
245
+ ): EncodedRequestBody {
246
+ if (!isBinaryData(body)) {
209
247
  throw validationError(
210
248
  'bodyType "binary" requires an ArrayBuffer or ArrayBufferView request body',
211
- body,
212
249
  ["body"],
213
250
  );
214
251
  }
215
- const bytes = binaryBodyToBytes(body);
216
- validateBinaryBodyByteLength(bytes.byteLength, body, ["body"]);
252
+ if (body.byteLength === 0) {
253
+ throw validationError("binary request bodies must be non-empty", ["body"]);
254
+ }
217
255
  return {
218
- body: base64Encode(bytes),
219
- bodyType: "binaryBody",
220
- injectedHeaders: contentTypeHeaderIfMissing(
221
- headers,
222
- "application/octet-stream",
223
- ),
256
+ wire: { body: toBuffer(body).toString("base64"), bodyType: "binaryBody" },
257
+ contentType: contentTypeIfMissing(headers, "application/octet-stream"),
224
258
  };
225
259
  }
226
260
 
227
261
  function encodeMultipartFormBody(
228
262
  body: unknown,
229
263
  headers: Record<string, string> | undefined,
230
- ): RequestBodyWirePatch {
264
+ ): RequestBodyWireFields {
231
265
  if (hasHeader(headers, "content-type")) {
232
266
  throw validationError(
233
267
  "multipartForm bodies cannot provide Content-Type because the multipart boundary is generated at execution time",
234
- body,
235
268
  ["headers", "Content-Type"],
236
269
  );
237
270
  }
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) {
271
+ if (!isPlainRecord(body)) {
249
272
  throw validationError(
250
- "multipartForm body must encode at least one field",
251
- body,
273
+ 'bodyType "multipartForm" requires a plain object request body',
252
274
  ["body"],
253
275
  );
254
276
  }
255
- validateRequestByteLength(totalMultipartPartBytes(parts), ["body"]);
256
- const hasBinaryFile = parts.some(
257
- (part) => part.kind === "file" && isBinaryBody(part.value),
277
+ const formData = Object.entries(body).flatMap(([key, value]) =>
278
+ definedArrayValues(value).map((item) => parseMultipartPart(key, item)),
258
279
  );
259
- return {
260
- bodyType: hasBinaryFile ? "formDataBase64" : "formData",
261
- formData: parts.map((part) => multipartPartToWire(part, hasBinaryFile)),
262
- injectedHeaders: [],
263
- };
280
+ if (formData.length === 0) {
281
+ throw validationError("multipartForm body must encode at least one field", [
282
+ "body",
283
+ ]);
284
+ }
285
+ return { bodyType: "formDataBase64", formData };
264
286
  }
265
287
 
266
- function parseMultipartPart(key: string, value: unknown): MultipartPart {
288
+ function parseMultipartPart(
289
+ key: string,
290
+ value: unknown,
291
+ ): PartialMessage<Property> {
267
292
  if (key.length === 0)
268
- throw validationError(
269
- "multipartForm body keys must be non-empty strings",
270
- value,
271
- ["body", key],
272
- );
293
+ throw validationError("multipartForm body keys must be non-empty strings", [
294
+ "body",
295
+ key,
296
+ ]);
273
297
  if (
274
298
  typeof value === "string" ||
275
- typeof value === "number" ||
276
- typeof value === "boolean"
299
+ typeof value === "boolean" ||
300
+ (typeof value === "number" && Number.isFinite(value))
277
301
  ) {
278
- const stringValue = String(value);
279
- return {
280
- kind: "field",
281
- key,
282
- value: stringValue,
283
- byteLength: utf8ByteLength(stringValue),
284
- };
302
+ return { key, value: String(value) };
285
303
  }
286
- if (isBinaryBody(value)) {
304
+ if (isBinaryData(value)) {
287
305
  throw validationError(
288
- `multipartForm binary field "${key}" must be wrapped as { value, filename }`,
289
- value,
306
+ `multipartForm binary field ${describeOptionValue(key)} must be wrapped as { value, filename }`,
290
307
  ["body", key],
291
308
  );
292
309
  }
293
310
  if (!isPlainRecord(value)) {
294
311
  throw validationError(
295
- `multipartForm field "${key}" must be a primitive value or { value, filename } file part`,
296
- value,
312
+ `multipartForm field ${describeOptionValue(key)} must be a primitive value or { value, filename } file part`,
297
313
  ["body", key],
298
314
  );
299
315
  }
@@ -305,8 +321,7 @@ function parseMultipartPart(key: string, value: unknown): MultipartPart {
305
321
  );
306
322
  if (missingKeys.length > 0) {
307
323
  throw validationError(
308
- `multipartForm file part "${key}" is missing required ${pluralize("key", missingKeys)}: ${missingKeys.join(", ")}`,
309
- value,
324
+ `multipartForm file part ${describeOptionValue(key)} is missing required ${pluralize("key", missingKeys)}: ${missingKeys.join(", ")}`,
310
325
  ["body", key],
311
326
  );
312
327
  }
@@ -314,73 +329,29 @@ function parseMultipartPart(key: string, value: unknown): MultipartPart {
314
329
  (partKey) => partKey !== "value" && partKey !== "filename",
315
330
  );
316
331
  if (unexpectedKeys.length > 0) {
332
+ const shown = unexpectedKeys.slice(0, 5).map(describeOptionValue);
333
+ const suffix = unexpectedKeys.length > 5 ? ", ..." : "";
317
334
  throw validationError(
318
- `multipartForm file part "${key}" has unexpected ${pluralize("key", unexpectedKeys)}: ${unexpectedKeys.join(", ")}`,
319
- value,
335
+ `multipartForm file part ${describeOptionValue(key)} has unexpected ${pluralize("key", unexpectedKeys)}: ${shown.join(", ")}${suffix}`,
320
336
  ["body", key],
321
337
  );
322
338
  }
323
339
  if (typeof value.filename !== "string" || value.filename.length === 0) {
324
340
  throw validationError(
325
- `multipartForm file part "${key}" requires a non-empty filename`,
326
- value,
341
+ `multipartForm file part ${describeOptionValue(key)} requires a non-empty filename`,
327
342
  ["body", key, "filename"],
328
343
  );
329
344
  }
330
- if (typeof value.value !== "string" && !isBinaryBody(value.value)) {
345
+ if (typeof value.value !== "string" && !isBinaryData(value.value)) {
331
346
  throw validationError(
332
- `multipartForm file part "${key}" value must be a string or binary body`,
333
- value,
347
+ `multipartForm file part ${describeOptionValue(key)} value must be a string or binary body`,
334
348
  ["body", key, "value"],
335
349
  );
336
350
  }
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
351
  return {
346
- kind: "file",
347
352
  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 },
353
+ value: toBuffer(value.value).toString("base64"),
354
+ file: { filename: value.filename },
384
355
  };
385
356
  }
386
357
 
@@ -391,58 +362,17 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
391
362
  return prototype === Object.prototype || prototype === null;
392
363
  }
393
364
 
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);
365
+ function toBuffer(value: string | BinaryBody): Buffer {
366
+ if (typeof value === "string") return Buffer.from(value, "utf8");
367
+ if (value instanceof ArrayBuffer) return Buffer.from(value);
368
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
401
369
  }
402
370
 
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(
371
+ function contentTypeIfMissing(
440
372
  headers: Record<string, string> | undefined,
441
373
  value: string,
442
- ): readonly InjectedHeader[] {
443
- return hasHeader(headers, "content-type")
444
- ? []
445
- : [{ key: "Content-Type", value }];
374
+ ): string | undefined {
375
+ return hasHeader(headers, "content-type") ? undefined : value;
446
376
  }
447
377
 
448
378
  function hasHeader(
@@ -457,92 +387,6 @@ function hasHeader(
457
387
  );
458
388
  }
459
389
 
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
390
  function pluralize(word: string, values: readonly unknown[]): string {
513
391
  return values.length === 1 ? word : `${word}s`;
514
392
  }
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
- }