@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.
- package/README.md +23 -0
- package/dist/errors.d.ts +36 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +231 -38
- package/dist/errors.js.map +1 -1
- package/dist/errors.test.d.ts +2 -0
- package/dist/errors.test.d.ts.map +1 -0
- package/dist/errors.test.js +189 -0
- package/dist/errors.test.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/integrations/anthropic/types.d.ts +1 -1
- package/dist/integrations/base/decode-worker-binary-response.d.ts.map +1 -1
- package/dist/integrations/base/decode-worker-binary-response.js +2 -5
- package/dist/integrations/base/decode-worker-binary-response.js.map +1 -1
- package/dist/integrations/base/index.d.ts +1 -1
- package/dist/integrations/base/index.d.ts.map +1 -1
- package/dist/integrations/base/request-body.d.ts +18 -10
- package/dist/integrations/base/request-body.d.ts.map +1 -1
- package/dist/integrations/base/request-body.js +131 -215
- package/dist/integrations/base/request-body.js.map +1 -1
- package/dist/integrations/base/request-body.test.js +306 -164
- package/dist/integrations/base/request-body.test.js.map +1 -1
- package/dist/integrations/base/rest-api-client-base.d.ts.map +1 -1
- package/dist/integrations/base/rest-api-client-base.js +13 -26
- package/dist/integrations/base/rest-api-client-base.js.map +1 -1
- package/dist/integrations/base/rest-api-integration-client.d.ts.map +1 -1
- package/dist/integrations/base/rest-api-integration-client.js +2 -5
- package/dist/integrations/base/rest-api-integration-client.js.map +1 -1
- package/dist/integrations/base/types.d.ts +0 -8
- package/dist/integrations/base/types.d.ts.map +1 -1
- package/dist/integrations/cosmosdb/client.d.ts.map +1 -1
- package/dist/integrations/cosmosdb/client.js +2 -5
- package/dist/integrations/cosmosdb/client.js.map +1 -1
- package/dist/integrations/documentation-resolver.test.js +88 -2
- package/dist/integrations/documentation-resolver.test.js.map +1 -1
- package/dist/integrations/documentation.test.js +10 -0
- package/dist/integrations/documentation.test.js.map +1 -1
- package/dist/integrations/dynamodb/client.d.ts +6 -2
- package/dist/integrations/dynamodb/client.d.ts.map +1 -1
- package/dist/integrations/dynamodb/client.js +85 -15
- package/dist/integrations/dynamodb/client.js.map +1 -1
- package/dist/integrations/dynamodb/client.test.d.ts +8 -0
- package/dist/integrations/dynamodb/client.test.d.ts.map +1 -0
- package/dist/integrations/dynamodb/client.test.js +198 -0
- package/dist/integrations/dynamodb/client.test.js.map +1 -0
- package/dist/integrations/dynamodb/index.d.ts +1 -1
- package/dist/integrations/dynamodb/index.d.ts.map +1 -1
- package/dist/integrations/dynamodb/index.js.map +1 -1
- package/dist/integrations/dynamodb/types.d.ts +27 -1
- package/dist/integrations/dynamodb/types.d.ts.map +1 -1
- package/dist/integrations/gcs/client.d.ts.map +1 -1
- package/dist/integrations/gcs/client.js +2 -5
- package/dist/integrations/gcs/client.js.map +1 -1
- package/dist/integrations/gsheets/client.d.ts.map +1 -1
- package/dist/integrations/gsheets/client.js +2 -5
- package/dist/integrations/gsheets/client.js.map +1 -1
- package/dist/integrations/index.d.ts +1 -1
- package/dist/integrations/index.d.ts.map +1 -1
- package/dist/integrations/index.js.map +1 -1
- package/dist/integrations/mongodb/client.d.ts.map +1 -1
- package/dist/integrations/mongodb/client.js +2 -5
- package/dist/integrations/mongodb/client.js.map +1 -1
- package/dist/integrations/restapiintegration/client.body-types.test.js +107 -25
- package/dist/integrations/restapiintegration/client.body-types.test.js.map +1 -1
- package/dist/integrations/restapiintegration/client.test.js +16 -0
- package/dist/integrations/restapiintegration/client.test.js.map +1 -1
- package/dist/integrations/s3/client.d.ts.map +1 -1
- package/dist/integrations/s3/client.js +2 -5
- package/dist/integrations/s3/client.js.map +1 -1
- package/dist/integrations/salesforce/client.d.ts.map +1 -1
- package/dist/integrations/salesforce/client.js +2 -5
- package/dist/integrations/salesforce/client.js.map +1 -1
- package/dist/integrations/slack/client.d.ts.map +1 -1
- package/dist/integrations/slack/client.js +4 -13
- package/dist/integrations/slack/client.js.map +1 -1
- package/dist/integrations/slack/client.test.js +2 -5
- package/dist/integrations/slack/client.test.js.map +1 -1
- package/dist/integrations/utils.d.ts +8 -3
- package/dist/integrations/utils.d.ts.map +1 -1
- package/dist/integrations/utils.js +42 -1
- package/dist/integrations/utils.js.map +1 -1
- package/dist/runtime/context.d.ts +6 -0
- package/dist/runtime/context.d.ts.map +1 -1
- package/dist/runtime/context.js +2 -1
- package/dist/runtime/context.js.map +1 -1
- package/dist/runtime/executor.d.ts +6 -0
- package/dist/runtime/executor.d.ts.map +1 -1
- package/dist/runtime/executor.js +1 -0
- package/dist/runtime/executor.js.map +1 -1
- package/dist/types.d.ts +10 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/errors.test.ts +257 -0
- package/src/errors.ts +289 -46
- package/src/index.ts +1 -0
- package/src/integrations/anthropic/README.md +12 -59
- package/src/integrations/anthropic/types.ts +1 -1
- package/src/integrations/base/decode-worker-binary-response.ts +5 -7
- package/src/integrations/base/index.ts +0 -5
- package/src/integrations/base/request-body.test.ts +358 -195
- package/src/integrations/base/request-body.ts +170 -326
- package/src/integrations/base/rest-api-client-base.ts +22 -31
- package/src/integrations/base/rest-api-integration-client.ts +8 -7
- package/src/integrations/base/types.ts +0 -18
- package/src/integrations/box/README.md +4 -60
- package/src/integrations/cosmosdb/client.ts +8 -7
- package/src/integrations/documentation-resolver.test.ts +108 -2
- package/src/integrations/documentation.test.ts +12 -0
- package/src/integrations/dropbox/README.md +1 -71
- package/src/integrations/dropbox/docs.manifest.json +10 -1
- package/src/integrations/dropbox/overlays/upload-unsupported.md +3 -0
- package/src/integrations/dropbox/overlays/upload.md +89 -0
- package/src/integrations/dynamodb/README.md +33 -12
- package/src/integrations/dynamodb/client.test.ts +254 -0
- package/src/integrations/dynamodb/client.ts +129 -23
- package/src/integrations/dynamodb/index.ts +5 -1
- package/src/integrations/dynamodb/types.ts +33 -1
- package/src/integrations/elasticsearch/README.md +0 -80
- package/src/integrations/elasticsearch/docs.manifest.json +10 -1
- package/src/integrations/elasticsearch/overlays/bulk-unsupported.md +3 -0
- package/src/integrations/elasticsearch/overlays/bulk.md +80 -0
- package/src/integrations/gcs/client.ts +8 -7
- package/src/integrations/googledrive/README.md +10 -36
- package/src/integrations/gsheets/client.ts +8 -7
- package/src/integrations/index.ts +1 -0
- package/src/integrations/jira/README.md +0 -26
- package/src/integrations/jira/docs.manifest.json +10 -1
- package/src/integrations/jira/overlays/attachments-unsupported.md +3 -0
- package/src/integrations/jira/overlays/attachments.md +30 -0
- package/src/integrations/mongodb/client.ts +8 -7
- package/src/integrations/restapiintegration/client.body-types.test.ts +135 -26
- package/src/integrations/restapiintegration/client.test.ts +23 -0
- package/src/integrations/restapiintegration/docs.manifest.json +2 -2
- package/src/integrations/restapiintegration/overlays/request-body-types.md +26 -17
- package/src/integrations/s3/client.ts +8 -7
- package/src/integrations/salesforce/client.ts +9 -7
- package/src/integrations/slack/client.test.ts +2 -5
- package/src/integrations/slack/client.ts +13 -19
- package/src/integrations/stabilityai/README.md +0 -87
- package/src/integrations/stabilityai/docs.manifest.json +10 -1
- package/src/integrations/stabilityai/overlays/image-uploads-unsupported.md +3 -0
- package/src/integrations/stabilityai/overlays/image-uploads.md +120 -0
- package/src/integrations/stripe/README.md +0 -201
- package/src/integrations/stripe/docs.manifest.json +10 -1
- package/src/integrations/stripe/overlays/request-bodies-unsupported.md +3 -0
- package/src/integrations/stripe/overlays/request-bodies.md +204 -0
- package/src/integrations/utils.ts +51 -1
- package/src/runtime/context.ts +9 -0
- package/src/runtime/executor.ts +8 -0
- package/src/types.ts +11 -0
|
@@ -1,17 +1,10 @@
|
|
|
1
|
-
import { describe, expect,
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
303
|
+
wire: {
|
|
304
|
+
body: "amount=2000¤cy=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
|
-
|
|
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("
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|
});
|