Skip to content

Commit 0ab607e

Browse files
committed
fix(uploads): serialize objects without files as JSON in multipart forms
Objects without uploadable values are now JSON-serialized per OpenAPI contentType spec instead of being flattened into bracketed keys. Special handling for env_vars to maintain backward compatibility - it continues using flattened format (env_vars[KEY]) as expected by APIs. - Add hasUploadableValue check to determine serialization strategy - Add tests for source field (JSON) and env_vars (flattened) behavior - Fixes 400 errors where source[type] was sent instead of source JSON Resolves issue reported to Stainless support on Oct 8, 2025
1 parent 851eb18 commit 0ab607e

File tree

2 files changed

+65
-4
lines changed

2 files changed

+65
-4
lines changed

src/internal/uploads.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,9 +176,28 @@ const addFormValue = async (form: FormData, key: string, value: unknown): Promis
176176
} else if (Array.isArray(value)) {
177177
await Promise.all(value.map((entry) => addFormValue(form, key + '[]', entry)));
178178
} else if (typeof value === 'object') {
179-
await Promise.all(
180-
Object.entries(value).map(([name, prop]) => addFormValue(form, `${key}[${name}]`, prop)),
181-
);
179+
// Special case: env_vars should always be flattened for backward compatibility
180+
// with APIs that expect env_vars[KEY] format
181+
const shouldAlwaysFlatten = key === 'env_vars';
182+
183+
// If the object doesn't contain any uploadable values,
184+
// serialize it as JSON instead of flattening it into bracketed keys.
185+
// This handles fields with contentType: application/json in the OpenAPI spec.
186+
if (!shouldAlwaysFlatten && !hasUploadableValue(value)) {
187+
// Filter out undefined values to check if object has any actual content
188+
const entries = Object.entries(value).filter(([_, v]) => v !== undefined);
189+
if (entries.length > 0) {
190+
form.append(key, JSON.stringify(value));
191+
}
192+
// If all properties are undefined, don't add anything to the form
193+
} else {
194+
// Flatten objects that:
195+
// - Contain uploadable values (files/blobs), or
196+
// - Are explicitly marked to always flatten (like env_vars)
197+
await Promise.all(
198+
Object.entries(value).map(([name, prop]) => addFormValue(form, `${key}[${name}]`, prop)),
199+
);
200+
}
182201
} else {
183202
throw new TypeError(
184203
`Invalid value given to form, expected a string, number, boolean, object, Array, File or Blob but got ${value} instead`,

tests/form.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,49 @@ describe('form data validation', () => {
6262
},
6363
fetch,
6464
);
65-
expect(Array.from(form2.entries())).toEqual([['bar[foo]', 'string']]);
65+
// Objects without uploadable values are now serialized as JSON
66+
expect(Array.from(form2.entries())).toEqual([['bar', '{"foo":"string"}']]);
67+
});
68+
69+
test('env_vars are always flattened for backward compatibility', async () => {
70+
const form = await createForm(
71+
{
72+
env_vars: {
73+
API_KEY: 'secret',
74+
DEBUG: 'true',
75+
},
76+
},
77+
fetch,
78+
);
79+
// env_vars should be flattened, not JSON-serialized
80+
expect(Array.from(form.entries())).toEqual([
81+
['env_vars[API_KEY]', 'secret'],
82+
['env_vars[DEBUG]', 'true'],
83+
]);
84+
});
85+
86+
test('source field is JSON-serialized', async () => {
87+
const form = await createForm(
88+
{
89+
source: {
90+
type: 'github',
91+
url: 'https://github.com/user/repo',
92+
ref: 'main',
93+
entrypoint: 'app.py',
94+
},
95+
},
96+
fetch,
97+
);
98+
// source should be JSON-serialized per OpenAPI spec
99+
const entries = Array.from(form.entries());
100+
expect(entries.length).toBe(1);
101+
expect(entries[0]![0]).toBe('source');
102+
expect(JSON.parse(entries[0]![1] as string)).toEqual({
103+
type: 'github',
104+
url: 'https://github.com/user/repo',
105+
ref: 'main',
106+
entrypoint: 'app.py',
107+
});
66108
});
67109

68110
test('nested undefined array item is stripped', async () => {

0 commit comments

Comments
 (0)