diff --git a/templates/react-native/src/client.ts.twig b/templates/react-native/src/client.ts.twig index fc5307ed63..bb9b7fcfaa 100644 --- a/templates/react-native/src/client.ts.twig +++ b/templates/react-native/src/client.ts.twig @@ -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; +}; + class {{spec.info.title | caseUcfirst}}Exception extends Error { code: number; response: string; diff --git a/templates/react-native/src/service.ts.twig b/templates/react-native/src/service.ts.twig index 5fa7a7a134..1f34f85899 100644 --- a/templates/react-native/src/service.ts.twig +++ b/templates/react-native/src/service.ts.twig @@ -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 @@ -24,4 +24,27 @@ export class Service { return output; } + + static filePart( + uri: string, + name: string, + type: string, + read: () => Promise, + ): 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; + } } diff --git a/templates/react-native/src/services/template.ts.twig b/templates/react-native/src/services/template.ts.twig index 2e112b0dd6..3907ec271b 100644 --- a/templates/react-native/src/services/template.ts.twig +++ b/templates/react-native/src/services/template.ts.twig @@ -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, @@ -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 }}', @@ -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 }}', diff --git a/tests/e2e/ReactNativeTest.php b/tests/e2e/ReactNativeTest.php index de55ec0e6b..aba724e1f2 100644 --- a/tests/e2e/ReactNativeTest.php +++ b/tests/e2e/ReactNativeTest.php @@ -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"', '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"', ]; @@ -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, diff --git a/tests/e2e/languages/react-native/browser.js b/tests/e2e/languages/react-native/browser.js index 22162aa277..f8f9808e24 100644 --- a/tests/e2e/languages/react-native/browser.js +++ b/tests/e2e/languages/react-native/browser.js @@ -1,3 +1,4 @@ +import './shims/expo-runtime'; import { Client, Foo, @@ -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); diff --git a/tests/e2e/languages/react-native/rollup.test.config.mjs b/tests/e2e/languages/react-native/rollup.test.config.mjs index cc2e90bad7..62d4e843ef 100644 --- a/tests/e2e/languages/react-native/rollup.test.config.mjs +++ b/tests/e2e/languages/react-native/rollup.test.config.mjs @@ -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/**'], }), ], diff --git a/tests/e2e/languages/react-native/shims/expo-file-system.js b/tests/e2e/languages/react-native/shims/expo-file-system.js index 412db8d0a2..e1f39f9ca0 100644 --- a/tests/e2e/languages/react-native/shims/expo-file-system.js +++ b/tests/e2e/languages/react-native/shims/expo-file-system.js @@ -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, }; diff --git a/tests/e2e/languages/react-native/shims/expo-runtime.js b/tests/e2e/languages/react-native/shims/expo-runtime.js new file mode 100644 index 0000000000..480c5a4a6c --- /dev/null +++ b/tests/e2e/languages/react-native/shims/expo-runtime.js @@ -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 }); +};