Skip to content

Commit

Permalink
feat(connect): add support for File type in requests (#3189)
Browse files Browse the repository at this point in the history
  • Loading branch information
cromoteca authored Feb 4, 2025
1 parent a1d1c40 commit 40c4ef3
Show file tree
Hide file tree
Showing 2 changed files with 101 additions and 3 deletions.
61 changes: 58 additions & 3 deletions packages/ts/frontend/src/Connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ $wnd.Vaadin.registrations.push({
is: 'endpoint',
});

export const BODY_PART_NAME = 'hilla_body_part';

export type MaybePromise<T> = Promise<T> | T;

/**
Expand Down Expand Up @@ -187,6 +189,40 @@ function isFlowLoaded(): boolean {
return $wnd.Vaadin?.Flow?.clients?.TypeScript !== undefined;
}

/**
* Extracts file objects from the object that is used to build the request body.
*
* @param obj - The object to extract files from.
* @returns A tuple with the object without files and a map of files.
*/
function extractFiles(obj: Record<string, unknown>): [Record<string, unknown>, Map<string, File>] {
const fileMap = new Map<string, File>();

function recursiveExtract(prop: unknown, path: string): unknown {
if (prop !== null && typeof prop === 'object') {
if (prop instanceof File) {
fileMap.set(path, prop);
return null;
}
if (Array.isArray(prop)) {
return prop.map((item, index) => recursiveExtract(item, `${path}/${index}`));
}
return Object.entries(prop).reduce<Record<string, unknown>>((acc, [key, value]) => {
const newPath = `${path}/${key}`;
if (value instanceof File) {
fileMap.set(newPath, value);
} else {
acc[key] = recursiveExtract(value, newPath);
}
return acc;
}, {});
}
return prop;
}

return [recursiveExtract(obj, '') as Record<string, unknown>, fileMap];
}

/**
* A list of parameters supported by {@link ConnectClient.call | the call() method in ConnectClient}.
*/
Expand Down Expand Up @@ -304,13 +340,32 @@ export class ConnectClient {
const csrfHeaders = globalThis.document ? getCsrfTokenHeadersForEndpointRequest(globalThis.document) : {};
const headers: Record<string, string> = {
Accept: 'application/json',
'Content-Type': 'application/json',
...csrfHeaders,
};

const [paramsWithoutFiles, files] = extractFiles(params ?? {});
let body;

if (files.size > 0) {
// in this case params is not undefined, otherwise there would be no files
body = new FormData();
body.append(
BODY_PART_NAME,
JSON.stringify(paramsWithoutFiles, (_, value) => (value === undefined ? null : value)),
);

for (const [path, file] of files) {
body.append(path, file);
}
} else {
headers['Content-Type'] = 'application/json';
if (params) {
body = JSON.stringify(params, (_, value) => (value === undefined ? null : value));
}
}

const request = new Request(`${this.prefix}/${endpoint}/${method}`, {
body:
params !== undefined ? JSON.stringify(params, (_, value) => (value === undefined ? null : value)) : undefined,
body, // automatically sets Content-Type header
headers,
method: 'POST',
});
Expand Down
43 changes: 43 additions & 0 deletions packages/ts/frontend/test/Connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ForbiddenResponseError,
type MiddlewareFunction,
UnauthorizedResponseError,
BODY_PART_NAME,
} from '../src/index.js';
import type { Vaadin } from '../src/types.js';
import { subscribeStub } from './mocks/atmosphere.js';
Expand Down Expand Up @@ -494,6 +495,48 @@ describe('@vaadin/hilla-frontend', () => {
expect(await request?.json()).to.deep.equal({ fooParam: 'foo' });
});

async function checkMultipartForm(
callBody: (file: File) => Record<string, unknown>,
filePath: string,
jsonBody: string,
) {
const file = new File(['foo'], 'foo.txt', { type: 'text/plain' });
await client.call('FooEndpoint', 'fooMethod', callBody(file));

const request = fetchMock.callHistory.lastCall()?.request;
expect(request).to.exist;
expect(request?.headers.get('content-type')).to.match(/^multipart\/form-data;/u);
const formData = await request!.formData();

const uploadedFile = formData.get(filePath) as File | null;
expect(uploadedFile).to.be.instanceOf(File);
expect(uploadedFile!.name).to.equal('foo.txt');
expect(await uploadedFile!.text()).to.equal('foo');

const body = formData.get(BODY_PART_NAME);
expect(body).to.equal(jsonBody);
}

it('should use multipart if a param is of File type', async () => {
await checkMultipartForm((file) => ({ fooParam: file }), '/fooParam', '{}');
});

it('should use multipart if a param has a property if File type', async () => {
await checkMultipartForm(
(file) => ({ fooParam: { a: 'abc', b: file } }),
'/fooParam/b',
'{"fooParam":{"a":"abc"}}',
);
});

it('should use multipart if a File is found in array', async () => {
await checkMultipartForm(
(file) => ({ fooParam: ['a', file, 'c'], other: 'abc' }),
'/fooParam/1',
'{"fooParam":["a",null,"c"],"other":"abc"}',
);
});

describe('middleware invocation', () => {
it('should not invoke middleware before call', () => {
const spyMiddleware = sinon.spy(async (context: MiddlewareContext, next: MiddlewareNext) => next(context));
Expand Down

0 comments on commit 40c4ef3

Please sign in to comment.