Skip to content

Commit 10d37a0

Browse files
authored
Merge pull request #3 from stainless-sdks/phani/sdk-flattened-multipart-fix
fix(uploads): serialize objects without files as JSON in multipart forms
2 parents 246a5b0 + 07c599a commit 10d37a0

File tree

2 files changed

+64
-4
lines changed

2 files changed

+64
-4
lines changed

src/internal/uploads.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,9 +176,27 @@ 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+
// If the object doesn't contain any uploadable values,
183+
// serialize it as JSON instead of flattening it into bracketed keys.
184+
// This handles fields with contentType: application/json in the OpenAPI spec.
185+
if (!shouldAlwaysFlatten && !hasUploadableValue(value)) {
186+
// Filter out undefined values to check if object has any actual content
187+
const entries = Object.entries(value).filter(([_, v]) => v !== undefined);
188+
if (entries.length > 0) {
189+
form.append(key, JSON.stringify(value));
190+
}
191+
// If all properties are undefined, don't add anything to the form
192+
} else {
193+
// Flatten objects that:
194+
// - Contain uploadable values (files/blobs), or
195+
// - Are explicitly marked to always flatten (like env_vars)
196+
await Promise.all(
197+
Object.entries(value).map(([name, prop]) => addFormValue(form, `${key}[${name}]`, prop)),
198+
);
199+
}
182200
} else {
183201
throw new TypeError(
184202
`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)