diff --git a/packages/base-driver/lib/jsonwp-proxy/proxy.js b/packages/base-driver/lib/jsonwp-proxy/proxy.js index 5f43daa40..a78b3785b 100644 --- a/packages/base-driver/lib/jsonwp-proxy/proxy.js +++ b/packages/base-driver/lib/jsonwp-proxy/proxy.js @@ -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} + */ 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, diff --git a/packages/base-driver/lib/protocol/routes.js b/packages/base-driver/lib/protocol/routes.js index 461a7eedf..7d796f16e 100644 --- a/packages/base-driver/lib/protocol/routes.js +++ b/packages/base-driver/lib/protocol/routes.js @@ -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} */ +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 diff --git a/packages/base-driver/test/unit/jsonwp-proxy/proxy.spec.js b/packages/base-driver/test/unit/jsonwp-proxy/proxy.spec.js index 496501d8b..2b1ecd75c 100644 --- a/packages/base-driver/test/unit/jsonwp-proxy/proxy.spec.js +++ b/packages/base-driver/test/unit/jsonwp-proxy/proxy.spec.js @@ -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; - }); - }); - }); }); diff --git a/packages/base-driver/test/unit/jsonwp-proxy/url.spec.js b/packages/base-driver/test/unit/jsonwp-proxy/url.spec.js index eafc0ae24..d9f9e5ea2 100644 --- a/packages/base-driver/test/unit/jsonwp-proxy/url.spec.js +++ b/packages/base-driver/test/unit/jsonwp-proxy/url.spec.js @@ -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'); }); }); });