mirror of
https://github.com/appium/appium.git
synced 2026-05-24 12:58:49 -05:00
59e4367cc8
AWS S3 expects file field to be the last field in the POST request. Currently it is the first field and that prevents AWS from parsing the other POST fields and request gives 400 error. With the change, it makes it possible to provide formFields necessary for http upload to S3 from appium. Use case: mobile: stopMediaProjectionRecording can upload screen recording to S3 for further processing
337 lines
10 KiB
JavaScript
337 lines
10 KiB
JavaScript
import _ from 'lodash';
|
|
import fs from './fs';
|
|
import B from 'bluebird';
|
|
import {toReadableSizeString} from './util';
|
|
import log from './logger';
|
|
import Ftp from 'jsftp';
|
|
import Timer from './timing';
|
|
import axios from 'axios';
|
|
import FormData from 'form-data';
|
|
|
|
const DEFAULT_TIMEOUT_MS = 4 * 60 * 1000;
|
|
|
|
/**
|
|
* Type guard for param to {@linkcode toAxiosAuth}
|
|
* @param {any} value
|
|
* @returns {value is AuthCredentials | import('axios').AxiosBasicCredentials}
|
|
*/
|
|
function isAxiosAuth(value) {
|
|
return _.isPlainObject(value);
|
|
}
|
|
|
|
/**
|
|
* Converts {@linkcode AuthCredentials} to credentials understood by {@linkcode axios}.
|
|
* @param {AuthCredentials | import('axios').AxiosBasicCredentials} [auth]
|
|
* @returns {import('axios').AxiosBasicCredentials?}
|
|
*/
|
|
function toAxiosAuth(auth) {
|
|
if (!isAxiosAuth(auth)) {
|
|
return null;
|
|
}
|
|
|
|
const axiosAuth = {
|
|
username: 'username' in auth ? auth.username : auth.user,
|
|
password: 'password' in auth ? auth.password : auth.pass,
|
|
};
|
|
return axiosAuth.username && axiosAuth.password ? axiosAuth : null;
|
|
}
|
|
|
|
/**
|
|
* @param {NodeJS.ReadableStream} localFileStream
|
|
* @param {URL} parsedUri
|
|
* @param {HttpUploadOptions & NetOptions} [uploadOptions]
|
|
*/
|
|
async function uploadFileToHttp(
|
|
localFileStream,
|
|
parsedUri,
|
|
uploadOptions = /** @type {HttpUploadOptions & NetOptions} */ ({})
|
|
) {
|
|
const {
|
|
method = 'POST',
|
|
timeout = DEFAULT_TIMEOUT_MS,
|
|
headers,
|
|
auth,
|
|
fileFieldName = 'file',
|
|
formFields,
|
|
} = uploadOptions;
|
|
const {href} = parsedUri;
|
|
|
|
/** @type {import('axios').RawAxiosRequestConfig} */
|
|
const requestOpts = {
|
|
url: href,
|
|
method,
|
|
timeout,
|
|
maxContentLength: Infinity,
|
|
maxBodyLength: Infinity,
|
|
};
|
|
const axiosAuth = toAxiosAuth(auth);
|
|
if (axiosAuth) {
|
|
requestOpts.auth = axiosAuth;
|
|
}
|
|
if (fileFieldName) {
|
|
const form = new FormData();
|
|
if (formFields) {
|
|
let pairs = [];
|
|
if (_.isArray(formFields)) {
|
|
pairs = formFields;
|
|
} else if (_.isPlainObject(formFields)) {
|
|
pairs = _.toPairs(formFields);
|
|
}
|
|
for (const [key, value] of pairs) {
|
|
if (_.toLower(key) !== _.toLower(fileFieldName)) {
|
|
form.append(key, value);
|
|
}
|
|
}
|
|
}
|
|
form.append(fileFieldName, localFileStream); // AWS S3 POST upload requires this to be the last field
|
|
requestOpts.headers = {
|
|
...(_.isPlainObject(headers) ? headers : {}),
|
|
...form.getHeaders(),
|
|
};
|
|
requestOpts.data = form;
|
|
} else {
|
|
if (_.isPlainObject(headers)) {
|
|
requestOpts.headers = headers;
|
|
}
|
|
requestOpts.data = localFileStream;
|
|
}
|
|
log.debug(
|
|
`Performing ${method} to ${href} with options (excluding data): ` +
|
|
JSON.stringify(_.omit(requestOpts, ['data']))
|
|
);
|
|
|
|
const {status, statusText} = await axios(requestOpts);
|
|
log.info(`Server response: ${status} ${statusText}`);
|
|
}
|
|
|
|
/**
|
|
* @param {string | Buffer | NodeJS.ReadableStream} localFileStream
|
|
* @param {URL} parsedUri
|
|
* @param {NotHttpUploadOptions & NetOptions} [uploadOptions]
|
|
*/
|
|
async function uploadFileToFtp(
|
|
localFileStream,
|
|
parsedUri,
|
|
uploadOptions = /** @type {NotHttpUploadOptions & NetOptions} */ ({})
|
|
) {
|
|
const {auth} = uploadOptions;
|
|
const {hostname, port, protocol, pathname} = parsedUri;
|
|
|
|
const ftpOpts = {
|
|
host: hostname,
|
|
port: !_.isUndefined(port) ? _.parseInt(port) : 21,
|
|
};
|
|
if (auth?.user && auth?.pass) {
|
|
ftpOpts.user = auth.user;
|
|
ftpOpts.pass = auth.pass;
|
|
}
|
|
log.debug(`${protocol} upload options: ${JSON.stringify(ftpOpts)}`);
|
|
return await new B((resolve, reject) => {
|
|
new Ftp(ftpOpts).put(localFileStream, pathname, (err) => {
|
|
if (err) {
|
|
reject(err);
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns `true` if params are valid for {@linkcode uploadFileToHttp}.
|
|
* @param {any} opts
|
|
* @param {URL} url
|
|
* @returns {opts is HttpUploadOptions & NetOptions}
|
|
*/
|
|
function isHttpUploadOptions(opts, url) {
|
|
try {
|
|
const {protocol} = url;
|
|
return protocol === 'http:' || protocol === 'https:';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns `true` if params are valid for {@linkcode uploadFileToFtp}.
|
|
* @param {any} opts
|
|
* @param {URL} url
|
|
* @returns {opts is NotHttpUploadOptions & NetOptions}
|
|
*/
|
|
function isNotHttpUploadOptions(opts, url) {
|
|
try {
|
|
const {protocol} = url;
|
|
return protocol === 'ftp:';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
/**
|
|
* Uploads the given file to a remote location. HTTP(S) and FTP
|
|
* protocols are supported.
|
|
*
|
|
* @param {string} localPath - The path to a file on the local storage.
|
|
* @param {string} remoteUri - The remote URI to upload the file to.
|
|
* @param {(HttpUploadOptions|NotHttpUploadOptions) & NetOptions} [uploadOptions]
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function uploadFile(
|
|
localPath,
|
|
remoteUri,
|
|
uploadOptions = /** @type {(HttpUploadOptions|NotHttpUploadOptions) & NetOptions} */ ({})
|
|
) {
|
|
if (!(await fs.exists(localPath))) {
|
|
throw new Error(`'${localPath}' does not exists or is not accessible`);
|
|
}
|
|
|
|
const {isMetered = true} = uploadOptions;
|
|
const url = new URL(remoteUri);
|
|
const {size} = await fs.stat(localPath);
|
|
if (isMetered) {
|
|
log.info(`Uploading '${localPath}' of ${toReadableSizeString(size)} size to '${remoteUri}'`);
|
|
}
|
|
const timer = new Timer().start();
|
|
if (isHttpUploadOptions(uploadOptions, url)) {
|
|
if (!uploadOptions.fileFieldName) {
|
|
uploadOptions.headers = {
|
|
...(_.isPlainObject(uploadOptions.headers) ? uploadOptions.headers : {}),
|
|
'Content-Length': size,
|
|
};
|
|
}
|
|
await uploadFileToHttp(fs.createReadStream(localPath), url, uploadOptions);
|
|
} else if (isNotHttpUploadOptions(uploadOptions, url)) {
|
|
await uploadFileToFtp(fs.createReadStream(localPath), url, uploadOptions);
|
|
} else {
|
|
throw new Error(
|
|
`Cannot upload the file at '${localPath}' to '${remoteUri}'. ` +
|
|
`Unsupported remote protocol '${url.protocol}'. ` +
|
|
`Only http/https and ftp/ftps protocols are supported.`
|
|
);
|
|
}
|
|
if (isMetered) {
|
|
log.info(
|
|
`Uploaded '${localPath}' of ${toReadableSizeString(size)} size in ` +
|
|
`${timer.getDuration().asSeconds.toFixed(3)}s`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Downloads the given file via HTTP(S)
|
|
*
|
|
* @param {string} remoteUrl - The remote url
|
|
* @param {string} dstPath - The local path to download the file to
|
|
* @param {DownloadOptions & NetOptions} [downloadOptions]
|
|
* @throws {Error} If download operation fails
|
|
*/
|
|
async function downloadFile(
|
|
remoteUrl,
|
|
dstPath,
|
|
downloadOptions = /** @type {DownloadOptions & NetOptions} */ ({})
|
|
) {
|
|
const {isMetered = true, auth, timeout = DEFAULT_TIMEOUT_MS, headers} = downloadOptions;
|
|
|
|
/**
|
|
* @type {import('axios').RawAxiosRequestConfig}
|
|
*/
|
|
const requestOpts = {
|
|
url: remoteUrl,
|
|
responseType: 'stream',
|
|
timeout,
|
|
};
|
|
const axiosAuth = toAxiosAuth(auth);
|
|
if (axiosAuth) {
|
|
requestOpts.auth = axiosAuth;
|
|
}
|
|
if (_.isPlainObject(headers)) {
|
|
requestOpts.headers = headers;
|
|
}
|
|
|
|
const timer = new Timer().start();
|
|
let responseLength;
|
|
try {
|
|
const writer = fs.createWriteStream(dstPath);
|
|
const {data: responseStream, headers: responseHeaders} = await axios(requestOpts);
|
|
responseLength = parseInt(responseHeaders['content-length'] || '0', 10);
|
|
responseStream.pipe(writer);
|
|
|
|
await new B((resolve, reject) => {
|
|
responseStream.once('error', reject);
|
|
writer.once('finish', resolve);
|
|
writer.once('error', (e) => {
|
|
responseStream.unpipe(writer);
|
|
reject(e);
|
|
});
|
|
});
|
|
} catch (err) {
|
|
throw new Error(`Cannot download the file from ${remoteUrl}: ${err.message}`);
|
|
}
|
|
|
|
const {size} = await fs.stat(dstPath);
|
|
if (responseLength && size !== responseLength) {
|
|
await fs.rimraf(dstPath);
|
|
throw new Error(
|
|
`The size of the file downloaded from ${remoteUrl} (${size} bytes) ` +
|
|
`differs from the one in Content-Length response header (${responseLength} bytes)`
|
|
);
|
|
}
|
|
if (isMetered) {
|
|
const secondsElapsed = timer.getDuration().asSeconds;
|
|
log.debug(
|
|
`${remoteUrl} (${toReadableSizeString(size)}) ` +
|
|
`has been downloaded to '${dstPath}' in ${secondsElapsed.toFixed(3)}s`
|
|
);
|
|
if (secondsElapsed >= 2) {
|
|
const bytesPerSec = Math.floor(size / secondsElapsed);
|
|
log.debug(`Approximate download speed: ${toReadableSizeString(bytesPerSec)}/s`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export {uploadFile, downloadFile};
|
|
|
|
/**
|
|
* Common options for {@linkcode uploadFile} and {@linkcode downloadFile}.
|
|
* @typedef NetOptions
|
|
* @property {boolean} [isMetered=true] - Whether to log the actual download performance
|
|
* (e.g. timings and speed)
|
|
* @property {AuthCredentials} [auth] - Authentication credentials
|
|
*/
|
|
|
|
/**
|
|
* Specific options for {@linkcode downloadFile}.
|
|
* @typedef DownloadOptions
|
|
* @property {number} [timeout] - The actual request timeout in milliseconds; defaults to {@linkcode DEFAULT_TIMEOUT_MS}
|
|
* @property {Record<string,any>} [headers] - Request headers mapping
|
|
*/
|
|
|
|
/**
|
|
* Basic auth credentials; used by {@linkcode NetOptions}.
|
|
* @typedef AuthCredentials
|
|
* @property {string} user - Non-empty user name
|
|
* @property {string} pass - Non-empty password
|
|
*/
|
|
|
|
/**
|
|
* This type is used in {@linkcode uploadFile} if the remote location uses the `ftp` protocol, and distinguishes the type from {@linkcode HttpUploadOptions}.
|
|
* @typedef NotHttpUploadOptions
|
|
* @property {never} headers
|
|
* @property {never} method
|
|
* @property {never} timeout
|
|
* @property {never} fileFieldName
|
|
* @property {never} formFields
|
|
*/
|
|
|
|
/**
|
|
* Specific options for {@linkcode uploadFile} if the remote location uses the `http(s)` protocol
|
|
* @typedef HttpUploadOptions
|
|
* @property {import('@appium/types').HTTPHeaders} [headers] - Additional request headers mapping
|
|
* @property {import('axios').Method} [method='POST'] - The HTTP method used for file upload
|
|
* @property {number} [timeout] - The actual request timeout in milliseconds; defaults to {@linkcode DEFAULT_TIMEOUT_MS}
|
|
* @property {string} [fileFieldName='file'] - The name of the form field containing the file
|
|
* content to be uploaded. Any falsy value make the request to use non-multipart upload
|
|
* @property {Record<string, any> | [string, any][]} [formFields] - The additional form fields
|
|
* to be included into the upload request. This property is only considered if
|
|
* `fileFieldName` is set
|
|
*/
|