From dfe0272d0cdac21b41573073f661a076024bb10f Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 21 Aug 2026 16:52:02 +0200 Subject: [PATCH] fix: JSON-encode object fields in multipart request bodies Channel uploads sent `user` as the literal string "[object Object]", which the API rejects with `bad user id`, and fields with no value went out as the string "undefined". Encode form fields in ApiClient so every multipart endpoint is covered, rather than only the two global upload methods StreamClient was hand-patching. --- __tests__/file-uploads.test.ts | 69 +++++++++++++++ __tests__/multipart.test.ts | 156 +++++++++++++++++++++++++++++++++ src/ApiClient.ts | 24 +++-- src/StreamClient.ts | 18 +--- 4 files changed, 247 insertions(+), 20 deletions(-) create mode 100644 __tests__/multipart.test.ts diff --git a/__tests__/file-uploads.test.ts b/__tests__/file-uploads.test.ts index ac8b12f7..e5f64253 100644 --- a/__tests__/file-uploads.test.ts +++ b/__tests__/file-uploads.test.ts @@ -1,6 +1,8 @@ import { beforeAll, describe, it, expect } from 'vitest'; +import { randomUUID } from 'crypto'; import { createTestClient } from './create-test-client'; import { StreamClient } from '../src/StreamClient'; +import { StreamChannel } from '../src/StreamChannel'; import fs from 'fs'; import path from 'path'; import { File } from 'buffer'; @@ -69,3 +71,70 @@ describe.skip('global file uploads', () => { expect(deleteResponse).toBeDefined(); }); }); + +// Don't want to upload files and image every time we run the tests +describe.skip('channel file uploads', () => { + let client: StreamClient; + let channel: StreamChannel; + const user = { + id: 'stream-node-test-user', + role: 'admin', + }; + + beforeAll(async () => { + client = createTestClient(); + await client.upsertUsers([user]); + + channel = client.chat.channel('messaging', 'streamnodetest' + randomUUID()); + await channel.getOrCreate({ data: { created_by_id: user.id } }); + }); + + it('upload and delete file', async () => { + const filePath = path.join(__dirname, 'assets', 'test-file.pdf'); + const fileBuffer = fs.readFileSync(filePath); + + const response = await channel.uploadChannelFile({ + // @ts-expect-error API spec says file should be a string + file: new File([fileBuffer], 'test-file.pdf'), + user: { id: user.id }, + }); + + expect(response.file).toBeDefined(); + + const deleteResponse = await channel.deleteChannelFile({ + url: response.file, + }); + + expect(deleteResponse).toBeDefined(); + }); + + it('upload image', async () => { + const filePath = path.join(__dirname, 'assets', 'test-image.jpg'); + const fileBuffer = fs.readFileSync(filePath); + + const uploadSizes = [ + { + width: 100, + height: 100, + resize: 'scale', + crop: 'center', + }, + ]; + + const response = await channel.uploadChannelImage({ + // @ts-expect-error API spec says file should be a string + file: new File([fileBuffer], 'test-image.jpg'), + user: { id: user.id }, + upload_sizes: uploadSizes, + }); + + expect(response.upload_sizes?.length).toBe(1); + expect(response.upload_sizes?.[0]).toMatchObject(uploadSizes[0]); + + const deleteResponse = await channel.deleteChannelImage({ + url: response.file, + }); + + expect(deleteResponse).toBeDefined(); + }); +}); diff --git a/__tests__/multipart.test.ts b/__tests__/multipart.test.ts new file mode 100644 index 00000000..90127148 --- /dev/null +++ b/__tests__/multipart.test.ts @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ApiClient } from '../src/ApiClient'; +import { StreamChatClient } from '../src/StreamChatClient'; +import { StreamClient } from '../src/StreamClient'; + +const createApiClient = () => + new ApiClient({ + apiKey: 'test-api-key', + token: 'test-token', + baseUrl: 'https://example.com', + timeout: 3000, + }); + +const mockFetch = () => + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + +/** + * Sends a multipart request with the given body and returns the form data + * that was actually put on the wire. + */ +const formDataFor = async (body: Record) => { + const fetchSpy = mockFetch(); + + await createApiClient().sendRequest( + 'POST', + '/test', + undefined, + undefined, + body, + 'multipart/form-data', + ); + + return fetchSpy.mock.calls[0][1]!.body as FormData; +}; + +describe('multipart form data serialization', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('serializes an object field as JSON', async () => { + const formData = await formDataFor({ user: { id: 'user-id' } }); + + expect(formData.get('user')).toBe('{"id":"user-id"}'); + }); + + it('serializes an array field as JSON', async () => { + const formData = await formDataFor({ + upload_sizes: [{ width: 100, height: 100, resize: 'scale' }], + }); + + expect(formData.get('upload_sizes')).toBe( + '[{"width":100,"height":100,"resize":"scale"}]', + ); + }); + + it('keeps a file field as a file', async () => { + const file = new File(['file-contents'], 'test-file.pdf'); + const formData = await formDataFor({ file }); + + const sent = formData.get('file'); + expect(sent).toBeInstanceOf(File); + expect((sent as File).name).toBe('test-file.pdf'); + expect(await (sent as File).text()).toBe('file-contents'); + }); + + it('omits fields with no value', async () => { + const formData = await formDataFor({ user: undefined, custom: null }); + + expect([...formData.keys()]).toEqual([]); + }); + + it('keeps scalar fields as-is', async () => { + const formData = await formDataFor({ name: 'a-name', limit: 10 }); + + expect(formData.get('name')).toBe('a-name'); + expect(formData.get('limit')).toBe('10'); + }); + + it('does not double-encode an already JSON-encoded field', async () => { + const formData = await formDataFor({ + user: JSON.stringify({ id: 'user-id' }), + }); + + expect(formData.get('user')).toBe('{"id":"user-id"}'); + }); + + it('encodes the user of a global image upload', async () => { + const fetchSpy = mockFetch(); + const client = new StreamClient('test-api-key', 'test-secret'); + + await client.uploadImage({ + file: new File(['image-contents'], 'test-image.jpg'), + upload_sizes: [{ width: 100, height: 100 }], + user: { id: 'user-id' }, + }); + + const formData = fetchSpy.mock.calls[0][1]!.body as FormData; + expect(formData.get('user')).toBe('{"id":"user-id"}'); + expect(formData.get('upload_sizes')).toBe('[{"width":100,"height":100}]'); + expect(formData.get('file')).toBeInstanceOf(File); + }); + + it('omits the user of a global file upload when it has no value', async () => { + const fetchSpy = mockFetch(); + const client = new StreamClient('test-api-key', 'test-secret'); + + await client.uploadFile({ + file: new File(['file-contents'], 'test-file.pdf'), + }); + + const formData = fetchSpy.mock.calls[0][1]!.body as FormData; + expect([...formData.keys()]).toEqual(['file']); + }); + + it('encodes the user of a channel image upload', async () => { + const fetchSpy = mockFetch(); + const chat = new StreamChatClient(createApiClient()); + + await chat.uploadChannelImage({ + type: 'messaging', + id: 'channel-id', + // @ts-expect-error API spec says file should be a string + file: new File(['image-contents'], 'test-image.jpg'), + upload_sizes: [{ width: 100, height: 100 }], + user: { id: 'user-id' }, + }); + + const formData = fetchSpy.mock.calls[0][1]!.body as FormData; + expect(formData.get('user')).toBe('{"id":"user-id"}'); + expect(formData.get('upload_sizes')).toBe('[{"width":100,"height":100}]'); + expect(formData.get('file')).toBeInstanceOf(File); + }); + + it('encodes the user of a channel file upload', async () => { + const fetchSpy = mockFetch(); + const chat = new StreamChatClient(createApiClient()); + + await chat.uploadChannelFile({ + type: 'messaging', + id: 'channel-id', + // @ts-expect-error API spec says file should be a string + file: new File(['file-contents'], 'test-file.pdf'), + user: { id: 'user-id' }, + }); + + const formData = fetchSpy.mock.calls[0][1]!.body as FormData; + expect(formData.get('user')).toBe('{"id":"user-id"}'); + expect(formData.get('file')).toBeInstanceOf(File); + }); +}); diff --git a/src/ApiClient.ts b/src/ApiClient.ts index adb98be2..be83548d 100644 --- a/src/ApiClient.ts +++ b/src/ApiClient.ts @@ -51,13 +51,8 @@ export class ApiClient { const encodedBody = requestContentType === 'multipart/form-data' - ? new FormData() + ? this.multipartBodyStringify(body as Record) : JSON.stringify(body); - if (requestContentType === 'multipart/form-data') { - Object.keys(body as Record).forEach((key) => { - (encodedBody as FormData).append(key, body[key]); - }); - } try { const response = await fetch(`${this.apiConfig.baseUrl}${url}`, { @@ -125,6 +120,23 @@ export class ApiClient { } }; + protected multipartBodyStringify = (body: Record): FormData => { + const formData = new FormData(); + for (const key in body) { + const value = body[key]; + if (value === null || value === undefined) continue; + if (value instanceof Blob) { + formData.append(key, value); + } else if (isScalar(value)) { + formData.append(key, String(value)); + } else { + formData.append(key, JSON.stringify(value)); + } + } + + return formData; + }; + protected queryParamsStringify = (params: Record) => { const newParams = []; for (const k in params) { diff --git a/src/StreamClient.ts b/src/StreamClient.ts index 977f82d8..1cd8efba 100644 --- a/src/StreamClient.ts +++ b/src/StreamClient.ts @@ -105,26 +105,16 @@ export class StreamClient extends CommonApi { // @ts-expect-error API spec says file should be a string uploadFile = (request: Omit & { file: File }) => { - return super.uploadFile({ - // @ts-expect-error API spec says file should be a string - file: request.file, - // @ts-expect-error form data will only work if this is a string - user: JSON.stringify(request.user), - }); + // @ts-expect-error API spec says file should be a string + return super.uploadFile(request); }; // @ts-expect-error API spec says file should be a string uploadImage = ( request: Omit & { file: File }, ) => { - return super.uploadImage({ - // @ts-expect-error API spec says file should be a string - file: request.file, - // @ts-expect-error form data will only work if this is a string - user: JSON.stringify(request.user), - // @ts-expect-error form data will only work if this is a string - upload_sizes: JSON.stringify(request.upload_sizes), - }); + // @ts-expect-error API spec says file should be a string + return super.uploadImage(request); }; /**