fix(base-driver): Optimize the logic of getUrlForProxy (#21018)

This commit is contained in:
Mykola Mokhnach
2025-02-20 19:59:15 +01:00
committed by GitHub
parent dc24864cbb
commit 8a664a4eef
4 changed files with 144 additions and 149 deletions

View File

@@ -9,16 +9,19 @@ import {
errorFromW3CJsonCode,
getResponseForW3CError,
} from '../protocol/errors';
import {routeToCommandName} from '../protocol';
import {isSessionCommand, routeToCommandName} from '../protocol';
import {MAX_LOG_BODY_LENGTH, DEFAULT_BASE_PATH, PROTOCOLS} from '../constants';
import ProtocolConverter from './protocol-converter';
import {formatResponseValue, formatStatus} from '../protocol/helpers';
import http from 'http';
import https from 'https';
import { match as pathToRegexMatch } from 'path-to-regexp';
import nodeUrl from 'node:url';
const DEFAULT_LOG = logger.getLogger('WD Proxy');
const DEFAULT_REQUEST_TIMEOUT = 240000;
const COMMAND_WITH_SESSION_ID_MATCHER = pathToRegexMatch('/session/:sessionId{/*command}');
const {MJSONWP, W3C} = PROTOCOLS;
@@ -111,26 +114,6 @@ class JWProxy {
this._activeRequests = [];
}
/**
* Return true if the given endpoint started with '/session' and
* it could have session id after the path.
* e.g.
* - should return true
* - /session/82a9b7da-faaf-4a1d-8ef3-5e4fb5812200
* - /session/82a9b7da-faaf-4a1d-8ef3-5e4fb5812200/url
* - should return false
* - /session
* - /sessions
* - /session/
* - /status
* - /appium/sessions
* @param {string} endpoint
* @returns {boolean}
*/
endpointRequiresSessionId(endpoint) {
return !!(pathToRegexMatch('/session/:sessionId{/*command}')(endpoint));
}
set downstreamProtocol(value) {
this._downstreamProtocol = value;
this.protocolConverter.downstreamProtocol = value;
@@ -140,67 +123,61 @@ class JWProxy {
return this._downstreamProtocol;
}
getUrlForProxy(url) {
if (url === '') {
url = '/';
/**
*
* @param {string} url
* @param {string} [method]
* @returns {string}
*/
getUrlForProxy(url, method) {
const parsedUrl = nodeUrl.parse(url || '/');
if (
!parsedUrl.href || !parsedUrl.pathname
|| (parsedUrl.protocol && !['http:', 'https:'].includes(parsedUrl.protocol))
) {
throw new Error(`Did not know how to proxy the url '${url}'`);
}
const proxyBase = `${this.scheme}://${this.server}:${this.port}${this.base}`;
const endpointRe = '(/(session|status|appium))';
let remainingUrl = '';
if (/^http/.test(url)) {
const first = new RegExp(`(https?://.+)${endpointRe}`).exec(url);
if (!first) {
throw new Error('Got a complete url but could not extract JWP endpoint');
}
remainingUrl = url.replace(first[1], '');
} else if (new RegExp('^/').test(url)) {
remainingUrl = url;
} else {
throw new Error(`Did not know what to do with url '${url}'`);
const pathname = this.reqBasePath && parsedUrl.pathname.startsWith(this.reqBasePath)
? parsedUrl.pathname.replace(this.reqBasePath, '')
: parsedUrl.pathname;
const match = COMMAND_WITH_SESSION_ID_MATCHER(pathname);
const normalizedPathname = _.trimEnd(
match && _.isArray(match.params?.command)
? `/${match.params.command.join('/')}`
: pathname,
'/'
);
const commandName = normalizedPathname
? routeToCommandName(
normalizedPathname,
/** @type {import('@appium/types').HTTPMethod | undefined} */ (method)
)
: '';
const requiresSessionId = !commandName || (commandName && isSessionCommand(commandName));
const proxyPrefix = `${this.scheme}://${this.server}:${this.port}${this.base}`;
let proxySuffix = normalizedPathname ? `/${_.trimStart(normalizedPathname, '/')}` : '';
if (parsedUrl.search) {
proxySuffix += parsedUrl.search;
}
const stripPrefixRe = new RegExp('^.*?(/(session|status|appium).*)$');
if (stripPrefixRe.test(remainingUrl)) {
remainingUrl = /** @type {RegExpExecArray} */ (stripPrefixRe.exec(remainingUrl))[1];
if (!requiresSessionId) {
return `${proxyPrefix}${proxySuffix}`;
}
if (!new RegExp(endpointRe).test(remainingUrl)) {
remainingUrl = `/session/${this.sessionId}${remainingUrl}`;
if (!this.sessionId) {
throw new ReferenceError(`Session ID is not set, but saw a URL that requires it (${url})`);
}
const requiresSessionId = this.endpointRequiresSessionId(remainingUrl);
if (requiresSessionId && this.sessionId === null) {
throw new Error('Trying to proxy a session command without session id');
}
const sessionBaseRe = new RegExp('^/session/([^/]+)');
if (sessionBaseRe.test(remainingUrl)) {
if (this.sessionId === null) {
throw new ReferenceError(
`Session ID is not set, but saw a URL path referencing a session (${remainingUrl}). This may be a bug in your client.`
);
}
// we have something like /session/:id/foobar, so we need to replace
// the session id
const match = sessionBaseRe.exec(remainingUrl);
// TODO: if `requiresSessionId` is `false` and `sessionId` is `null`, this is a bug.
// are we sure `sessionId` is not `null`?
remainingUrl = remainingUrl.replace(
/** @type {RegExpExecArray} */ (match)[1],
/** @type {string} */ (this.sessionId)
);
} else if (requiresSessionId) {
throw new Error(`Could not find :session section for url: ${remainingUrl}`);
}
remainingUrl = remainingUrl.replace(/\/$/, ''); // can't have trailing slashes
return proxyBase + remainingUrl;
return `${proxyPrefix}/session/${this.sessionId}${proxySuffix}`;
}
/**
*
* @param {string} url
* @param {string} method
* @param {any} body
* @returns {Promise<any>}
*/
async proxy(url, method, body = null) {
method = method.toUpperCase();
const newUrl = this.getUrlForProxy(url);
const newUrl = this.getUrlForProxy(url, method);
const truncateBody = (content) =>
_.truncate(_.isString(content) ? content : JSON.stringify(content), {
length: MAX_LOG_BODY_LENGTH,

View File

@@ -4,6 +4,12 @@ import _ from 'lodash';
import {util} from '@appium/support';
import {PROTOCOLS, DEFAULT_BASE_PATH} from '../constants';
import {match} from 'path-to-regexp';
import { LRUCache } from 'lru-cache';
/** @type {LRUCache<string, string>} */
const COMMAND_NAMES_CACHE = new LRUCache({
max: 1024,
});
const SET_ALERT_TEXT_PAYLOAD_PARAMS = {
validate: (jsonObj) =>
@@ -941,7 +947,7 @@ export const ALL_COMMANDS = _.flatMap(_.values(METHOD_MAP).map(_.values))
/**
*
* @param {string} endpoint
* @param {import('@appium/types').HTTPMethod} method
* @param {import('@appium/types').HTTPMethod} [method]
* @param {string} [basePath=DEFAULT_BASE_PATH]
* @returns {string|undefined}
*/
@@ -959,22 +965,46 @@ export function routeToCommandName(endpoint, method, basePath = DEFAULT_BASE_PAT
throw new Error(`'${endpoint}' cannot be translated to a command name: ${err.message}`);
}
const normalizedMethod = _.toUpper(method);
const cacheKey = toCommandNameCacheKey(normalizedPathname, normalizedMethod);
if (COMMAND_NAMES_CACHE.has(cacheKey)) {
return COMMAND_NAMES_CACHE.get(cacheKey) || undefined;
}
/** @type {string[]} */
const possiblePathnames = [];
if (!normalizedPathname.startsWith('/session/')) {
possiblePathnames.push(`/session/any-session-id${normalizedPathname}`);
}
possiblePathnames.push(normalizedPathname);
const normalizedMethod = _.toUpper(method);
for (const [routePath, routeSpec] of _.toPairs(METHOD_MAP)) {
const routeMatcher = match(routePath);
if (possiblePathnames.some((pp) => routeMatcher(pp))) {
const commandName = routeSpec?.[normalizedMethod]?.command;
const commandForAnyMethod = () => _.first(
(_.keys(routeSpec) ?? []).map((key) => routeSpec[key]?.command)
);
const commandName = normalizedMethod
? routeSpec?.[normalizedMethod]?.command
: commandForAnyMethod();
if (commandName) {
COMMAND_NAMES_CACHE.set(cacheKey, commandName);
return commandName;
}
}
}
// storing an empty string means we did not find any match for this set of arguments
// and we want to cache this result
COMMAND_NAMES_CACHE.set(cacheKey, '');
}
/**
*
* @param {string} endpoint
* @param {string} [method]
* @returns {string}
*/
function toCommandNameCacheKey(endpoint, method) {
return `${endpoint}:${method ?? ''}`;
}
// driver commands that do not require a session to already exist

View File

@@ -61,58 +61,72 @@ describe('proxy', function () {
});
describe('getUrlForProxy', function () {
it('should modify session id, host, and port', function () {
let j = mockProxy({sessionId: '123'});
j.getUrlForProxy('http://host.com:1234/session/456/element/200/value').should.eql(
`http://${TEST_HOST}:${port}/session/123/element/200/value`
);
mockProxy({sessionId: '123'})
.getUrlForProxy('http://host.com:1234/session/456/element/200/value', 'POST').should.eql(
`http://${TEST_HOST}:${port}/session/123/element/200/value`
);
});
it('should prepend scheme, host and port if not provided', function () {
let j = mockProxy({sessionId: '123'});
j.getUrlForProxy('/session/456/element/200/value').should.eql(
j.getUrlForProxy('/session/456/element/200/value', 'POST').should.eql(
`http://${TEST_HOST}:${port}/session/123/element/200/value`
);
j.getUrlForProxy('/session/456/appium/settings', 'POST').should.eql(
`http://${TEST_HOST}:${port}/session/123/appium/settings`
);
});
it('should prepend scheme, host, port and session if not provided', function () {
mockProxy({sessionId: '123'})
.getUrlForProxy('/element/200/value', 'POST').should.eql(
`http://${TEST_HOST}:${port}/session/123/element/200/value`
);
});
it('should keep query parameters', function () {
mockProxy({sessionId: '123'})
.getUrlForProxy('/element/200/value?foo=1&bar=2', 'POST').should.eql(
`http://${TEST_HOST}:${port}/session/123/element/200/value?foo=1&bar=2`
);
});
it('should respect nonstandard incoming request base path', function () {
let j = mockProxy({sessionId: '123', reqBasePath: ''});
j.getUrlForProxy('/session/456/element/200/value').should.eql(
`http://${TEST_HOST}:${port}/session/123/element/200/value`
);
mockProxy({sessionId: '123', reqBasePath: ''})
.getUrlForProxy('/session/456/element/200/value', 'POST').should.eql(
`http://${TEST_HOST}:${port}/session/123/element/200/value`
);
j = mockProxy({sessionId: '123', reqBasePath: '/my/base/path'});
j.getUrlForProxy('/my/base/path/session/456/element/200/value').should.eql(
`http://${TEST_HOST}:${port}/session/123/element/200/value`
);
mockProxy({sessionId: '123', reqBasePath: '/my/base/path'})
.getUrlForProxy('/my/base/path/session/456/element/200/value', 'POST').should.eql(
`http://${TEST_HOST}:${port}/session/123/element/200/value`
);
mockProxy({reqBasePath: '/my/base/path'})
.getUrlForProxy('/my/base/path/session', 'POST').should.eql(
`http://${TEST_HOST}:${port}/session`
);
});
it('should work with urls which do not have session ids', function () {
let j = mockProxy({sessionId: '123'});
j.getUrlForProxy('http://host.com:1234/session').should.eql(
j.getUrlForProxy('http://host.com:1234/session', 'POST').should.eql(
`http://${TEST_HOST}:${port}/session`
);
let newUrl = j.getUrlForProxy('/session');
newUrl.should.eql(`http://${TEST_HOST}:${port}/session`);
j.getUrlForProxy('/session', 'POST')
.should.eql(`http://${TEST_HOST}:${port}/session`);
j.getUrlForProxy('/appium/sessions', 'GET')
.should.eql(`http://${TEST_HOST}:${port}/appium/sessions`);
});
it('should throw an error if url requires a sessionId but its null', function () {
let j = mockProxy();
let e;
try {
j.getUrlForProxy('/session/456/element/200/value');
j.getUrlForProxy('/session/456/element/200/value', 'POST');
} catch (err) {
e = err;
}
should.exist(e);
e.message.should.contain('without session id');
e.message.should.contain('not set');
});
it('should not throw an error if url does not require a session id and its null', function () {
let j = mockProxy();
let newUrl = j.getUrlForProxy('/status');
should.exist(newUrl);
});
it('should not throw an error if url does not require a session id with appium vendor prefix and its null', function () {
let j = mockProxy();
let newUrl = j.getUrlForProxy('/appium/something');
let newUrl = mockProxy().getUrlForProxy('/status', 'GET');
should.exist(newUrl);
});
});
@@ -215,30 +229,4 @@ describe('proxy', function () {
res.sentBody.should.eql({value: {message: 'chrome not reachable'}});
});
});
describe('endpointRequiresSessionId', function () {
const j = mockProxy({sessionId: '123'});
[
'/session/82a9b7da-faaf-4a1d-8ef3-5e4fb5812200',
'/session/82a9b7da-faaf-4a1d-8ef3-5e4fb5812200/',
'/session/82a9b7da-faaf-4a1d-8ef3-5e4fb5812200/url',
'/session/82a9b7da-faaf-4a1d-8ef3-5e4fb5812200/element/3d001db2-7987-42a7-975d-8d5d5304083f',
].forEach(function (endpoint) {
it(`should be true with ${endpoint}`, function () {
j.endpointRequiresSessionId(endpoint).should.be.true;
});
});
[
'/session',
'/session/',
'/sessions',
'/appium/sessions',
'/status',
].forEach(function (endpoint) {
it(`should be false with ${endpoint}`, function () {
j.endpointRequiresSessionId(endpoint).should.be.false;
});
});
});
});

View File

@@ -37,7 +37,7 @@ describe('JWProxy', function () {
it('should translate host and port', function () {
let incomingUrl = PROXY_STATUS_URL;
let j = createJWProxy();
let proxyUrl = j.getUrlForProxy(incomingUrl);
let proxyUrl = j.getUrlForProxy(incomingUrl, 'GET');
proxyUrl.should.equal(testStatusURL);
});
it('should translate the scheme', function () {
@@ -49,21 +49,21 @@ describe('JWProxy', function () {
it('should translate the base', function () {
let incomingUrl = PROXY_STATUS_URL;
let j = createJWProxy({base: ''});
let proxyUrl = j.getUrlForProxy(incomingUrl);
let proxyUrl = j.getUrlForProxy(incomingUrl, 'GET');
proxyUrl.should.equal(testStatusURL);
});
it('should translate the session id', function () {
let incomingUrl = createProxyURL('foobar', 'element');
let j = createJWProxy({sessionId: 'barbaz'});
let proxyUrl = j.getUrlForProxy(incomingUrl);
let proxyUrl = j.getUrlForProxy(incomingUrl, 'POST');
proxyUrl.should.equal(createTestURL('barbaz', 'element'));
});
it('should error when translating session commands without session id', function () {
let incomingUrl = createProxyURL('foobar', 'element');
let j = createJWProxy();
(() => {
j.getUrlForProxy(incomingUrl);
}).should.throw('session id');
j.getUrlForProxy(incomingUrl, 'POST');
}).should.throw('not set');
});
});
@@ -71,7 +71,7 @@ describe('JWProxy', function () {
it('should proxy /status', function () {
let incomingUrl = '/status';
let j = createJWProxy();
let proxyUrl = j.getUrlForProxy(incomingUrl);
let proxyUrl = j.getUrlForProxy(incomingUrl, 'GET');
proxyUrl.should.equal(testStatusURL);
});
it('should proxy /session', function () {
@@ -83,64 +83,64 @@ describe('JWProxy', function () {
it('should proxy /sessions', function () {
let incomingUrl = '/sessions';
let j = createJWProxy();
let proxyUrl = j.getUrlForProxy(incomingUrl);
let proxyUrl = j.getUrlForProxy(incomingUrl, 'GET');
proxyUrl.should.equal(createTestURL('', 'sessions'));
});
it('should proxy session commands based off /session', function () {
let incomingUrl = '/session/foobar/element';
let j = createJWProxy({sessionId: 'barbaz'});
let proxyUrl = j.getUrlForProxy(incomingUrl);
let proxyUrl = j.getUrlForProxy(incomingUrl, 'POST');
proxyUrl.should.equal(createTestURL('barbaz', 'element'));
});
it('should error session commands based off /session without session id', function () {
let incomingUrl = '/session/foobar/element';
let j = createJWProxy();
(() => {
j.getUrlForProxy(incomingUrl);
}).should.throw('session id');
j.getUrlForProxy(incomingUrl, 'POST');
}).should.throw('not set');
});
it('should proxy session commands based off ', function () {
let incomingUrl = '/session/3d001db2-7987-42a7-975d-8d5d5304083f/timeouts/implicit_wait';
let j = createJWProxy({sessionId: '123'});
let proxyUrl = j.getUrlForProxy(incomingUrl);
let proxyUrl = j.getUrlForProxy(incomingUrl, 'POST');
proxyUrl.should.equal(createTestURL('123', 'timeouts/implicit_wait'));
});
it('should proxy session commands based off /session as ""', function () {
let incomingUrl = '';
let j = createJWProxy();
(() => {
j.getUrlForProxy(incomingUrl);
}).should.throw('session id');
j.getUrlForProxy(incomingUrl, 'GET');
}).should.throw('not set');
j = createJWProxy({sessionId: '123'});
let proxyUrl = j.getUrlForProxy(incomingUrl);
let proxyUrl = j.getUrlForProxy(incomingUrl, 'GET');
proxyUrl.should.equal(createTestSessionURL('123'));
});
it('should proxy session commands without /session', function () {
let incomingUrl = '/element';
let j = createJWProxy({sessionId: 'barbaz'});
let proxyUrl = j.getUrlForProxy(incomingUrl);
let proxyUrl = j.getUrlForProxy(incomingUrl, 'POST');
proxyUrl.should.equal(createTestURL('barbaz', 'element'));
});
it(`should proxy session commands when '/session' is in the url`, function () {
let incomingUrl =
'/session/82a9b7da-faaf-4a1d-8ef3-5e4fb5812200/cookie/session-something-or-other';
let j = createJWProxy({sessionId: 'barbaz'});
let proxyUrl = j.getUrlForProxy(incomingUrl);
let proxyUrl = j.getUrlForProxy(incomingUrl, 'POST');
proxyUrl.should.equal(createTestURL('barbaz', 'cookie/session-something-or-other'));
});
it(`should proxy session commands when '/session' is in the url and not base on the original url`, function () {
let incomingUrl =
'/session/82a9b7da-faaf-4a1d-8ef3-5e4fb5812200/cookie/session-something-or-other';
let j = createJWProxy({sessionId: 'barbaz'});
let proxyUrl = j.getUrlForProxy(incomingUrl);
let proxyUrl = j.getUrlForProxy(incomingUrl, 'POST');
proxyUrl.should.equal(createTestURL('barbaz', 'cookie/session-something-or-other'));
});
it('should error session commands without /session without session id', function () {
let incomingUrl = '/element';
let j = createJWProxy();
(() => {
j.getUrlForProxy(incomingUrl);
}).should.throw('session id');
j.getUrlForProxy(incomingUrl, 'POST');
}).should.throw('not set');
});
});
});