Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 125 additions & 22 deletions package/src/utils/__tests__/installNativeMultipartAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { AxiosProgressEvent, AxiosRequestConfig } from 'axios';

import { getTestClient } from '../../mock-builders/mock';
import { NativeHandlers, NativeMultipartUploadProgressConfig } from '../../native';
import {
installNativeMultipartAdapter,
wrapAxiosAdapterWithNativeMultipart,
} from '../installNativeMultipartAdapter';
isNativeMultipartUploadAvailable,
NativeHandlers,
NativeMultipartUploadProgressConfig,
} from '../../native';
import { installNativeMultipartAdapter } from '../installNativeMultipartAdapter';

type NativeMultipartTestAxiosConfig = AxiosRequestConfig & {
uploadProgress?: (event: AxiosProgressEvent) => void;
Expand Down Expand Up @@ -362,18 +363,9 @@ describe('installNativeMultipartAdapter', () => {
it('installs only once per client', async () => {
const client = getTestClient();
preserveRequestData(client);
const defaultAdapter = jest.fn().mockResolvedValue({
config: {},
data: 'default',
headers: {},
status: 200,
statusText: 'OK',
});

client.axiosInstance.defaults.adapter = defaultAdapter;
const use = jest.spyOn(client.axiosInstance.interceptors.request, 'use');

installNativeMultipartAdapter(client);
const installedAdapter = client.axiosInstance.defaults.adapter;
installNativeMultipartAdapter(client);

const formData = {
Expand All @@ -391,12 +383,20 @@ describe('installNativeMultipartAdapter', () => {

await client.axiosInstance.post('/uploads/image', formData);

expect(client.axiosInstance.defaults.adapter).toBe(installedAdapter);
expect(defaultAdapter).not.toHaveBeenCalled();
expect(NativeHandlers.multipartUpload).toHaveBeenCalled();
expect(use).toHaveBeenCalledTimes(1);
expect(NativeHandlers.multipartUpload).toHaveBeenCalledTimes(1);
});

it('leaves the default adapter for the client’s own axios to resolve', () => {
const client = getTestClient();
const defaultAdapter = client.axiosInstance.defaults.adapter;

installNativeMultipartAdapter(client);

expect(client.axiosInstance.defaults.adapter).toBe(defaultAdapter);
});

it('composes explicitly with a custom adapter', async () => {
it('keeps a custom default adapter for non-multipart requests', async () => {
const client = getTestClient();
preserveRequestData(client);
const customAdapter = jest.fn().mockResolvedValue({
Expand All @@ -407,10 +407,8 @@ describe('installNativeMultipartAdapter', () => {
statusText: 'OK',
});

client.axiosInstance.defaults.adapter = wrapAxiosAdapterWithNativeMultipart(
client,
customAdapter,
);
client.axiosInstance.defaults.adapter = customAdapter;
installNativeMultipartAdapter(client);

const multipartFormData = {
_parts: [
Expand All @@ -429,9 +427,114 @@ describe('installNativeMultipartAdapter', () => {

expect(NativeHandlers.multipartUpload).toHaveBeenCalled();
expect(customAdapter).not.toHaveBeenCalled();
expect(client.axiosInstance.defaults.adapter).toBe(customAdapter);

await client.axiosInstance.post('/messages', { text: 'hello' });

expect(customAdapter).toHaveBeenCalled();
});

it('rejects when an interceptor replaces the file body after it was routed natively', async () => {
const client = getTestClient();
preserveRequestData(client);
Comment thread
szuperaz marked this conversation as resolved.

// Registered before the install, so axios runs it after the install's interceptor.
client.axiosInstance.interceptors.request.use((config) => {
config.data = { text: 'no longer a file' };
return config;
});
installNativeMultipartAdapter(client);

const formData = {
_parts: [
[
'file',
{
name: 'test.jpg',
type: 'image/jpeg',
uri: 'file:///tmp/test.jpg',
},
],
],
};

await expect(client.axiosInstance.post('/uploads/image', formData)).rejects.toThrow(
/request body changed/,
);
expect(NativeHandlers.multipartUpload).not.toHaveBeenCalled();
});

it('routes a FormData upload natively through axios’ default request transform', async () => {
// Every other test swaps `transformRequest` for a no-op. This one keeps axios' default, which
// runs between the install's interceptor (sees the body before it) and the native adapter (sees
// it after), so the body has to pass through it untouched. axios captures the global FormData
// when it loads, so it is loaded fresh with React Native's FormData installed globally — as it
// is in an app — instead of Node's.
const originalFormData = global.FormData;
const RNFormData = jest.requireActual('react-native/Libraries/Network/FormData').default;
global.FormData = RNFormData;

try {
let isolated!: {
getTestClient: typeof getTestClient;
installNativeMultipartAdapter: typeof installNativeMultipartAdapter;
NativeHandlers: typeof NativeHandlers;
};
jest.isolateModules(() => {
isolated = {
getTestClient: require('../../mock-builders/mock').getTestClient,
installNativeMultipartAdapter: require('../installNativeMultipartAdapter')
.installNativeMultipartAdapter,
NativeHandlers: require('../../native').NativeHandlers,
};
});
isolated.NativeHandlers.multipartUpload = NativeHandlers.multipartUpload;

const client = isolated.getTestClient();
const defaultAdapter = jest.fn();
client.axiosInstance.defaults.adapter = defaultAdapter;
isolated.installNativeMultipartAdapter(client);

const formData = new RNFormData();
formData.append('file', {
name: 'test.jpg',
type: 'image/jpeg',
uri: 'file:///tmp/test.jpg',
});
formData.append('user', JSON.stringify({ id: 'john' }));

const response = await client.axiosInstance.postForm('/uploads/image', formData);

expect(defaultAdapter).not.toHaveBeenCalled();
expect(NativeHandlers.multipartUpload).toHaveBeenCalledWith(
expect.objectContaining({
parts: [
{
fieldName: 'file',
fileName: 'test.jpg',
kind: 'file',
mimeType: 'image/jpeg',
uri: 'file:///tmp/test.jpg',
},
{ fieldName: 'user', kind: 'text', value: JSON.stringify({ id: 'john' }) },
],
}),
);
expect(response.status).toBe(200);
} finally {
global.FormData = originalFormData;
}
});

it('does not install when native multipart upload is unavailable', () => {
// Nothing registers a native uploader in the test setup.
NativeHandlers.multipartUpload = originalMultipartUpload;
expect(isNativeMultipartUploadAvailable()).toBe(false);
const client = getTestClient();
const use = jest.spyOn(client.axiosInstance.interceptors.request, 'use');

installNativeMultipartAdapter(client);

expect(use).not.toHaveBeenCalled();
});
});
85 changes: 46 additions & 39 deletions package/src/utils/installNativeMultipartAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import axios from 'axios';
import type { AxiosAdapter, AxiosProgressEvent, InternalAxiosRequestConfig } from 'axios';
import type { StreamChat } from 'stream-chat';

Expand All @@ -24,11 +23,9 @@ type NativeMultipartAxiosRequestConfig = InternalAxiosRequestConfig & {
uploadProgress?: (event: AxiosProgressEvent) => void;
};

type ResolvableAxiosAdapter = Parameters<typeof axios.getAdapter>[0];

const DEFAULT_COMPLETION_PROGRESS_CAP = 90;

const installedAdapters = new WeakSet<StreamChat>();
const installedClients = new WeakSet<StreamChat>();

const getFormDataEntries = (data: unknown): [string, FormDataPartValue][] | null => {
if (!data || typeof data !== 'object') {
Expand Down Expand Up @@ -80,11 +77,8 @@ const getNativeProgressOptions = (
return Object.keys(nativeProgressOptions).length ? nativeProgressOptions : undefined;
};

const createNativeMultipartRequest = (
client: StreamChat,
config: NativeMultipartAxiosRequestConfig,
): NativeMultipartUploadRequest | null => {
const entries = getFormDataEntries(config.data);
const getNativeMultipartParts = (data: unknown): NativeMultipartUploadRequest['parts'] | null => {
const entries = getFormDataEntries(data);

if (!entries) {
return null;
Expand Down Expand Up @@ -120,6 +114,19 @@ const createNativeMultipartRequest = (
return null;
}

return parts;
};

const createNativeMultipartRequest = (
client: StreamChat,
config: NativeMultipartAxiosRequestConfig,
): NativeMultipartUploadRequest | null => {
const parts = getNativeMultipartParts(config.data);

if (!parts) {
return null;
}

return {
headers: normalizeHeaders(config.headers),
method: (config.method || 'POST').toUpperCase(),
Expand Down Expand Up @@ -250,53 +257,53 @@ const nativeMultipartAxiosAdapter = async (
};
};

const resolveAxiosAdapter = (adapter: ResolvableAxiosAdapter): AxiosAdapter =>
axios.getAdapter(adapter);

const createNativeMultipartAwareAdapter = (
client: StreamChat,
fallbackAdapter: ResolvableAxiosAdapter,
): AxiosAdapter => {
const resolvedFallbackAdapter = resolveAxiosAdapter(fallbackAdapter);

return (config) => {
const createNativeMultipartAdapter =
(client: StreamChat): AxiosAdapter =>
(config) => {
// Built from the config the adapter receives, so it reflects everything that ran after routing:
// request interceptors registered before this one (axios runs them after it) and
// `transformRequest`.
const nativeMultipartRequest = createNativeMultipartRequest(
client,
config as NativeMultipartAxiosRequestConfig,
);

if (!nativeMultipartRequest) {
return resolvedFallbackAdapter(config);
return Promise.reject(
Comment thread
szuperaz marked this conversation as resolved.
new Error(
'Native multipart upload: the request body changed after it was routed to the native uploader.',
),
);
}

return nativeMultipartAxiosAdapter(nativeMultipartRequest, config);
};
};

export const wrapAxiosAdapterWithNativeMultipart = (
client: StreamChat,
fallbackAdapter: ResolvableAxiosAdapter,
): AxiosAdapter => {
if (!isNativeMultipartUploadAvailable()) {
return resolveAxiosAdapter(fallbackAdapter);
}

return createNativeMultipartAwareAdapter(client, fallbackAdapter);
};

/**
* Routes file uploads through the native uploader by setting `adapter` on those requests only.
*
* The client's default adapter is deliberately left alone. Replacing it would mean resolving the
* fallback (`['xhr', 'http', 'fetch']`) with `axios.getAdapter`, i.e. importing axios here — and
* wherever that import resolves to a different copy than `stream-chat`'s, every request would fail
* with that copy's `AxiosError`, which `stream-chat` does not recognise. Untouched, every other
* request is resolved by `stream-chat`'s own axios.
*/
export const installNativeMultipartAdapter = (client: StreamChat) => {
if (!isNativeMultipartUploadAvailable()) {
return;
}

if (installedAdapters.has(client)) {
if (installedClients.has(client)) {
return;
}

const previousAdapter = client.axiosInstance.defaults.adapter;
client.axiosInstance.defaults.adapter = wrapAxiosAdapterWithNativeMultipart(
client,
previousAdapter,
);
installedAdapters.add(client);
const nativeMultipartAdapter = createNativeMultipartAdapter(client);
client.axiosInstance.interceptors.request.use((config) => {
if (getNativeMultipartParts(config.data)) {
config.adapter = nativeMultipartAdapter;
}

return config;
});
installedClients.add(client);
};
Loading