Skip to content
Merged
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
13 changes: 13 additions & 0 deletions templates/react-native/src/client.ts.twig
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,19 @@ export type UploadProgress = {
chunksUploaded: number;
};

/**
* A multipart file part that both React Native networking stacks understand.
*
* React Native's own fetch reads `uri` natively. Expo's fetch, the global
* fetch since Expo SDK 57, ignores `uri` and encodes the body from `bytes()`.
*/
export type FilePart = {
uri: string;
name: string;
type: string;
bytes: () => Promise<Uint8Array>;
};

class {{spec.info.title | caseUcfirst}}Exception extends Error {
code: number;
response: string;
Expand Down
25 changes: 24 additions & 1 deletion templates/react-native/src/service.ts.twig
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Client } from './client';
import type { Payload } from './client';
import type { FilePart, Payload } from './client';

export class Service {
static CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
Expand All @@ -24,4 +24,27 @@ export class Service {

return output;
}

static filePart(
uri: string,
name: string,
type: string,
read: () => Promise<string>,
): FilePart {
return {
uri,
name,
type,
bytes: async () => Service.decodeBase64(await read()),
};
}

static decodeBase64(data: string): Uint8Array {
const binary = atob(data);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
}
32 changes: 22 additions & 10 deletions templates/react-native/src/services/template.ts.twig
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,16 @@ export class {{ service.name | caseUcfirst }} extends Service {
const size = {{ parameter.name | caseCamel | escapeKeyword }}.size;

if (size <= Service.CHUNK_SIZE) {
payload['{{ parameter.name }}'] = Service.filePart(
{{ parameter.name | caseCamel | escapeKeyword }}.uri,
{{ parameter.name | caseCamel | escapeKeyword }}.name,
{{ parameter.name | caseCamel | escapeKeyword }}.type,
() =>
FileSystem.readAsStringAsync({{ parameter.name | caseCamel | escapeKeyword }}.uri, {
encoding: FileSystem.EncodingType.Base64,
}),
);

return this.client.call(
'{{ method.method.value | caseLower }}',
uri,
Expand Down Expand Up @@ -267,11 +277,12 @@ export class {{ service.name | caseUcfirst }} extends Service {
});
}

payload['{{ parameter.name }}'] = {{ '{' }}
uri: firstPath,
name: {{ parameter.name | caseCamel | escapeKeyword }}.name,
type: {{ parameter.name | caseCamel | escapeKeyword }}.type,
};
payload['{{ parameter.name }}'] = Service.filePart(
firstPath,
{{ parameter.name | caseCamel | escapeKeyword }}.name,
{{ parameter.name | caseCamel | escapeKeyword }}.type,
async () => firstChunk,
);

response = await this.client.call(
'{{ method.method.value | caseLower }}',
Expand Down Expand Up @@ -350,11 +361,12 @@ export class {{ service.name | caseUcfirst }} extends Service {
}

const chunkPayload = { ...payload };
chunkPayload['{{ parameter.name }}'] = {{ '{' }}
uri: chunkPath,
name: {{ parameter.name | caseCamel | escapeKeyword }}.name,
type: {{ parameter.name | caseCamel | escapeKeyword }}.type,
};
chunkPayload['{{ parameter.name }}'] = Service.filePart(
chunkPath,
{{ parameter.name | caseCamel | escapeKeyword }}.name,
{{ parameter.name | caseCamel | escapeKeyword }}.type,
async () => chunkData,
);

const chunkResponse = await this.client.call(
'{{ method.method.value | caseLower }}',
Expand Down
10 changes: 8 additions & 2 deletions tests/e2e/ReactNativeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ final class ReactNativeTest extends Base
'cp tests/e2e/languages/react-native/index.html tests/e2e/sdks/react-native/index.html',
'cp tests/e2e/languages/react-native/browser.js tests/e2e/sdks/react-native/browser.js',
'cp tests/e2e/languages/react-native/rollup.test.config.mjs tests/e2e/sdks/react-native/rollup.test.config.mjs',
'mkdir -p tests/e2e/sdks/react-native/shims && cp tests/e2e/languages/react-native/shims/expo-file-system.js tests/e2e/sdks/react-native/shims/expo-file-system.js',
'docker run --rm -v $(pwd):/app -w /app/tests/e2e/sdks/react-native mcr.microsoft.com/playwright:v1.59.0-jammy sh -c "npm install && npm install --no-save react-native-web react react-dom @rollup/plugin-alias @rollup/plugin-commonjs @rollup/plugin-node-resolve @rollup/plugin-replace"',
'mkdir -p tests/e2e/sdks/react-native/shims && cp tests/e2e/languages/react-native/shims/*.js tests/e2e/sdks/react-native/shims/',
'cp tests/resources/file.png tests/resources/large_file.mp4 tests/e2e/sdks/react-native/',
'docker run --rm -v $(pwd):/app -w /app/tests/e2e/sdks/react-native mcr.microsoft.com/playwright:v1.59.0-jammy sh -c "npm install && npm install --no-save esbuild expo@57 react-native-web react react-dom @rollup/plugin-alias @rollup/plugin-commonjs @rollup/plugin-node-resolve @rollup/plugin-replace"',
'docker run --rm -v $(pwd):/app -w /app/tests/e2e/sdks/react-native mcr.microsoft.com/playwright:v1.59.0-jammy sh -c "npx esbuild node_modules/expo/src/winter/FormData.ts node_modules/expo/src/winter/fetch/RequestUtils.ts --bundle --format=esm --outbase=node_modules/expo/src --outdir=shims/expo"',
Comment thread
ChiragAgg5k marked this conversation as resolved.
'docker run --rm -v $(pwd):/app -w /app/tests/e2e/sdks/react-native mcr.microsoft.com/playwright:v1.59.0-jammy sh -c "npx rollup -c rollup.test.config.mjs"',
];

Expand All @@ -48,6 +50,10 @@ final class ReactNativeTest extends Base
...Base::GENERAL_RESPONSES,
...Base::PATH_VALIDATION_RESPONSES,
...Base::DOWNLOAD_RESPONSES,
...Base::UPLOAD_RESPONSE,
...Base::LARGE_FILE_RESPONSES,
...Base::UPLOAD_RESPONSE, // Object params
...Base::LARGE_FILE_RESPONSES,
...Base::ENUM_RESPONSES,
...Base::MODEL_RESPONSES,
...Base::OPTIONAL_PARAM_RESPONSES,
Expand Down
25 changes: 25 additions & 0 deletions tests/e2e/languages/react-native/browser.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import './shims/expo-runtime';
import {
Client,
Foo,
Expand Down Expand Up @@ -142,6 +143,30 @@ import {
// Download
console.log(new TextDecoder().decode(await general.download()));

// Upload
const smallFile = {
name: 'file.png',
type: 'image/png',
size: 38756,
uri: 'http://localhost:3000/file.png',
};
const largeFile = {
name: 'large_file.mp4',
type: 'video/mp4',
size: 16310023,
uri: 'http://localhost:3000/large_file.mp4',
};
response = await general.upload('string', 123, ['string in array'], smallFile);
console.log(response.result);
response = await general.upload('string', 123, ['string in array'], largeFile);
console.log(response.result);

// Upload (Object params)
response = await general.upload({ x: 'string', y: 123, z: ['string in array'], file: smallFile });
console.log(response.result);
response = await general.upload({ x: 'string', y: 123, z: ['string in array'], file: largeFile });
console.log(response.result);

// Enum
response = await general.enum(MockType.First);
console.log(response.result);
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/languages/react-native/rollup.test.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export default {
outDir: 'dist',
rootDir: '.',
},
include: ['src/**/*.ts', 'browser.js'],
include: ['src/**/*.ts', 'browser.js', 'shims/**/*.js'],
exclude: ['node_modules/**', 'dist/**'],
}),
],
Expand Down
76 changes: 54 additions & 22 deletions tests/e2e/languages/react-native/shims/expo-file-system.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,60 @@
const notImplemented = (name) => () => {
throw new Error(
`expo-file-system shim: ${name} was called, but uploads are not exercised by this test harness.`
);
// Serves files over HTTP from the test server so the SDK's upload code can
// read them the way it reads a device file in Expo.
const written = new Map();

const bytesOf = async (uri) => {
if (written.has(uri)) {
return written.get(uri);
}
const response = await fetch(uri);
return new Uint8Array(await response.arrayBuffer());
};

const toBase64 = (bytes) => {
let binary = "";
for (let index = 0; index < bytes.length; index += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
}
return btoa(binary);
};

export const EncodingType = { UTF8: "utf8", Base64: "base64" };
export const documentDirectory = "";
export const cacheDirectory = "";

export const getInfoAsync = async (uri) => {
const bytes = await bytesOf(uri);
return { exists: true, size: bytes.length, uri };
};

export const EncodingType = { UTF8: 'utf8', Base64: 'base64' };
export const documentDirectory = '';
export const cacheDirectory = '';
export const readAsStringAsync = async (uri, options = {}) => {
const bytes = await bytesOf(uri);
const position = options.position ?? 0;
const length = options.length ?? bytes.length - position;
const slice = bytes.subarray(position, position + length);
return options.encoding === EncodingType.Base64
? toBase64(slice)
: new TextDecoder().decode(slice);
};

export const getInfoAsync = notImplemented('getInfoAsync');
export const readAsStringAsync = notImplemented('readAsStringAsync');
export const writeAsStringAsync = notImplemented('writeAsStringAsync');
export const deleteAsync = notImplemented('deleteAsync');
export const uploadAsync = notImplemented('uploadAsync');
export const FileSystemUploadType = { BINARY_CONTENT: 0, MULTIPART: 1 };
export const writeAsStringAsync = async (uri, data, options = {}) => {
const bytes =
options.encoding === EncodingType.Base64
? Uint8Array.from(atob(data), (character) => character.charCodeAt(0))
: new TextEncoder().encode(data);
written.set(uri, bytes);
};

export const deleteAsync = async (uri) => {
written.delete(uri);
};

export default {
EncodingType,
documentDirectory,
cacheDirectory,
getInfoAsync,
readAsStringAsync,
writeAsStringAsync,
deleteAsync,
uploadAsync,
FileSystemUploadType,
EncodingType,
documentDirectory,
cacheDirectory,
getInfoAsync,
readAsStringAsync,
writeAsStringAsync,
deleteAsync,
};
40 changes: 40 additions & 0 deletions tests/e2e/languages/react-native/shims/expo-runtime.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Reproduces the part of Expo's native runtime that uploads go through since
// Expo SDK 57 made expo/fetch the global fetch: React Native's FormData, Expo's
// FormData patch, and Expo's multipart body encoder. Only the transport is the
// browser's fetch, sending the bytes Expo would hand to its native request.
//
// Expo ships these as TypeScript sources only, so the test build transpiles
// them out of node_modules with esbuild before bundling.
import { installFormDataPatch } from "./expo/winter/FormData";
import {
normalizeBodyInitAsync,
normalizeHeadersInit,
overrideHeaders,
} from "./expo/winter/fetch/RequestUtils";

class ReactNativeFormData {
constructor() {
this._parts = [];
}

append(key, value) {
this._parts.push([key, value]);
}
}

globalThis.FormData = ReactNativeFormData;
installFormDataPatch(globalThis.FormData);

const browserFetch = globalThis.fetch.bind(globalThis);

globalThis.fetch = async (input, init = {}) => {
if (!(init.body instanceof globalThis.FormData)) {
return browserFetch(input, init);
}
const { body, overriddenHeaders } = await normalizeBodyInitAsync(init.body);
const headers = overrideHeaders(
normalizeHeadersInit(init.headers),
overriddenHeaders ?? [],
);
return browserFetch(input, { ...init, headers, body });
};
Loading