diff --git a/packages/rest/spec/io/axiosProxyOverridesFor.spec.ts b/packages/rest/spec/io/axiosProxyOverridesFor.spec.ts new file mode 100644 index 00000000000..4f4d14adb18 --- /dev/null +++ b/packages/rest/spec/io/axiosProxyOverridesFor.spec.ts @@ -0,0 +1,39 @@ +import { expect } from '@integration/testing-tools'; +import axios from 'axios'; +import { describe, it } from 'mocha'; + +import { axiosProxyOverridesFor } from '../../src/io/axiosProxyOverridesFor'; +import { ProxyAgent } from '../../src/io/ProxyAgent'; + +describe('axiosProxyOverridesFor', () => { + + describe('when no proxy overrides are defined', () => { + + it('overrides Axios built-in proxy mechanism to use env variables', () => { + + const overrides = axiosProxyOverridesFor(axios.create({}).defaults); + + expect(overrides.proxy).to.equal(false); + expect(overrides.httpAgent).to.be.instanceof(ProxyAgent); + expect(overrides.httpsAgent).to.be.instanceof(ProxyAgent); + }); + }); + + describe('when proxy overrides are defined', () => { + + it('creates an Axios instance that overrides Axios built-in proxy mechanism', () => { + + const overrides = axiosProxyOverridesFor(axios.create({ + proxy: { + protocol: 'http', + host: 'proxy.mycompany.com', + port: 9000, + } + }).defaults); + + expect(overrides.proxy).to.equal(false); + expect(overrides.httpAgent).to.be.instanceof(ProxyAgent); + expect(overrides.httpsAgent).to.be.instanceof(ProxyAgent); + }); + }); +}); diff --git a/packages/rest/spec/io/createUrl.spec.ts b/packages/rest/spec/io/createUrl.spec.ts new file mode 100644 index 00000000000..f116989273e --- /dev/null +++ b/packages/rest/spec/io/createUrl.spec.ts @@ -0,0 +1,128 @@ +import { describe, it } from 'mocha'; +import { given } from 'mocha-testdata'; + +import { createUrl } from '../../src/io/createUrl'; +import { expect } from '../expect'; + +describe('createUrl', () => { + + given([ + { + description: 'default port for HTTP gets ignored', + options: { + protocol: 'http', + hostname: 'example.org', + port: '80', + }, + expected: 'http://example.org/' + }, + { + description: 'default port for HTTPs gets ignored', + options: { + protocol: 'https', + hostname: 'example.org', + port: '443', + }, + expected: 'https://example.org/' + }, + { + description: 'non-default HTTP port gets added', + options: { + protocol: 'http', + hostname: 'example.org', + port: '8080', + }, + expected: 'http://example.org:8080/' + }, + { + description: 'non-default HTTPs port gets added', + options: { + protocol: 'https', + hostname: 'example.org', + port: '8443', + }, + expected: 'https://example.org:8443/' + }, + ]). + it('should create a URL when given valid configuration', ({ options, expected }) => { + + const url = createUrl(options); + + expect(url.toString()).equals(expected); + }); + + describe('when the protocol is undefined', () => { + + it('adds the port number', () => { + + const url = createUrl({ protocol: undefined, hostname: 'example.org', port: 80 }); + + expect(url.toString()).equals('example.org:80'); + }); + + it(`defaults to port 80 when port is not specified`, () => { + + const url = createUrl({ protocol: undefined, hostname: 'example.org', port: undefined }); + + expect(url.toString()).equals('example.org:80'); + }); + }); + + given([ + 'https://', + 'https:', + 'https', + 'HTTPS://', + 'HTTPS:', + 'HTTPS', + ]). + it('cleans up the protocol name', (protocol) => { + + const url = createUrl({ protocol, hostname: `example.org`, port: undefined }); + + expect(url.protocol).to.equal('https:'); + expect(url.toString()).to.equal('https://example.org/'); + }); + + describe('when dealing with credentials', () => { + + it('adds the url-encoded username to the resulting url', () => { + const url = createUrl({ + protocol: 'https', + hostname: `example.org`, + username: 'alice.jones@example.org' + }); + + expect(url.username).to.equal('alice.jones%40example.org'); + expect(url.toString()).to.equal('https://alice.jones%40example.org@example.org/'); + }); + + it('adds the url-encoded username and password to the resulting url', () => { + const url = createUrl({ + protocol: 'https', + hostname: `example.org`, + username: 'alice.jones@example.org', + password: '//P@55w0rd!' + }); + + expect(url.username).to.equal('alice.jones%40example.org'); + expect(url.password).to.equal('%2F%2FP%4055w0rd!'); + expect(url.toString()).to.equal('https://alice.jones%40example.org:%2F%2FP%4055w0rd!@example.org/'); + }); + }); + + describe('when handling errors', () => { + + it('complains when the hostname is not specified', () => { + expect(() => { + createUrl({ protocol: 'http', hostname: undefined, port: undefined }); + }).to.throw(Error, 'hostname should be a string'); + }); + + it('complains when the hostname is blank', () => { + expect(() => { + createUrl({ protocol: 'http', hostname: '', port: undefined }); + }).to.throw(Error, 'hostname should not be blank'); + }); + }); +}); diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index 92e6665d356..efeb1e33188 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -1 +1,2 @@ +export * from './io'; export * from './screenplay'; diff --git a/packages/rest/src/io/AxiosRequestConfigDefaults.ts b/packages/rest/src/io/AxiosRequestConfigDefaults.ts new file mode 100644 index 00000000000..cb62a327713 --- /dev/null +++ b/packages/rest/src/io/AxiosRequestConfigDefaults.ts @@ -0,0 +1,15 @@ +import { type CreateAxiosDefaults } from 'axios'; + +export type AxiosRequestConfigProxyDefaults = { + host: string; + port?: number; // SOCKS proxies don't require port number + auth?: { + username: string; + password: string; + }; + protocol?: string; +} + +export type AxiosRequestConfigDefaults = Omit, 'proxy'> & { + proxy?: AxiosRequestConfigProxyDefaults | false; +} diff --git a/packages/rest/src/io/ProxyAgent.ts b/packages/rest/src/io/ProxyAgent.ts new file mode 100644 index 00000000000..eaf6ffb90bc --- /dev/null +++ b/packages/rest/src/io/ProxyAgent.ts @@ -0,0 +1,129 @@ +import { ConfigurationError } from '@serenity-js/core'; +import type { AgentConnectOpts } from 'agent-base'; +import { Agent } from 'agent-base'; +import * as http from 'http'; +import { HttpProxyAgent, type HttpProxyAgentOptions } from 'http-proxy-agent'; +import * as https from 'https'; +import { HttpsProxyAgent, type HttpsProxyAgentOptions } from 'https-proxy-agent'; +import { LRUCache } from 'lru-cache'; +import { getProxyForUrl as envGetProxyForUrl } from 'proxy-from-env'; + +const protocols = [ + ...HttpProxyAgent.protocols, +] as const; + +type AgentConstructor = new (proxy: URL | string, options?: ProxyAgentOptions) => Agent; + +type ValidProtocol = (typeof protocols)[number]; + +type GetProxyForUrlCallback = (url: string) => string; + +export type ProxyAgentOptions = + HttpProxyAgentOptions<''> & + HttpsProxyAgentOptions<''> & { + /** + * Default `http.Agent` instance to use when no proxy is + * configured for a request. Defaults to a new `http.Agent()` + * instance with the proxy agent options passed in. + */ + httpAgent?: http.Agent; + /** + * Default `http.Agent` instance to use when no proxy is + * configured for a request. Defaults to a new `https.Agent()` + * instance with the proxy agent options passed in. + */ + httpsAgent?: http.Agent; + /** + * A callback for dynamic provision of proxy for url. + * Defaults to standard proxy environment variables, + * see https://www.npmjs.com/package/proxy-from-env for details + */ + getProxyForUrl?: GetProxyForUrlCallback; + }; + +/** + * A simplified version of the original + * [`ProxyAgent`](https://github.com/TooTallNate/proxy-agents/blob/5923589c2e5206504772c250ac4f20fc31122d3b/packages/proxy-agent/src/index.ts) + * with fewer dependencies. + * + * Delegates requests to the appropriate `Agent` subclass based on the "proxy" + * environment variables, or the provided `agentOptions.getProxyForUrl` callback. + * + * Uses an LRU cache to prevent unnecessary creation of proxy `http.Agent` instances. + */ +export class ProxyAgent extends Agent { + + private static proxyAgents: { [P in ValidProtocol]: [ AgentConstructor, AgentConstructor ] } = { + http: [ HttpProxyAgent, HttpsProxyAgent ], + https: [ HttpProxyAgent, HttpsProxyAgent ], + }; + + /** + * Cache for `Agent` instances. + */ + private readonly cache = new LRUCache({ + max: 20, + dispose: (value: Agent, key: string) => value.destroy(), + }); + + private readonly httpAgent: http.Agent; + private readonly httpsAgent: http.Agent; + private readonly getProxyForUrl: GetProxyForUrlCallback; + + constructor(private readonly agentOptions: ProxyAgentOptions) { + super(agentOptions); + this.httpAgent = agentOptions?.httpAgent || new http.Agent(agentOptions); + this.httpsAgent = agentOptions?.httpsAgent || new https.Agent(agentOptions as https.AgentOptions); + this.getProxyForUrl = agentOptions?.getProxyForUrl || envGetProxyForUrl; + } + + override async connect(request: http.ClientRequest, options: AgentConnectOpts): Promise { + const { secureEndpoint } = options; + const isWebSocket = request.getHeader('upgrade') === 'websocket'; + const protocol = secureEndpoint + ? (isWebSocket ? 'wss:' : 'https:') + : (isWebSocket ? 'ws:' : 'http:'); + const host = request.getHeader('host'); + const url = new URL(request.path, `${protocol}//${host}`).href; + const proxy = this.getProxyForUrl(url); + + if (! proxy) { + return secureEndpoint + ? this.httpsAgent + : this.httpAgent; + } + + // attempt to get a cached `http.Agent` instance first + const cacheKey = `${ protocol }+${ proxy }`; + let agent = this.cache.get(cacheKey); + if (! agent) { + agent = this.createAgent(new URL(proxy), secureEndpoint || isWebSocket); + + this.cache.set(cacheKey, agent); + } + + return agent; + } + + private createAgent(proxyUrl: URL, requiresTls: boolean): Agent { + + const protocol = proxyUrl.protocol.replace(':', ''); + + if (! this.isValidProtocol(protocol)) { + throw new ConfigurationError(`Unsupported protocol for proxy URL: ${ proxyUrl }`); + } + + const ctor = ProxyAgent.proxyAgents[protocol][requiresTls ? 1 : 0]; + + return new ctor(proxyUrl, this.agentOptions); + } + + private isValidProtocol(v: string): v is ValidProtocol { + return (protocols as readonly string[]).includes(v); + } + + override destroy(): void { + this.cache.clear(); + super.destroy(); + } +} diff --git a/packages/rest/src/io/axiosProxyOverridesFor.ts b/packages/rest/src/io/axiosProxyOverridesFor.ts new file mode 100644 index 00000000000..f4f99b3bd00 --- /dev/null +++ b/packages/rest/src/io/axiosProxyOverridesFor.ts @@ -0,0 +1,39 @@ +import type * as http from 'http'; +import { ensure, isDefined } from 'tiny-types'; + +import type { AxiosRequestConfigDefaults } from './AxiosRequestConfigDefaults'; +import { createUrl } from './createUrl'; +import { ProxyAgent } from './ProxyAgent'; + +/** + * @param options + */ +export function axiosProxyOverridesFor(options: AxiosRequestConfigDefaults): { + proxy: false, httpAgent: http.Agent, httpsAgent: http.Agent +} { + const envProxyOverride: string | false = options.proxy + && createUrl({ + username: options.proxy?.auth?.username, + password: options.proxy?.auth?.password, + protocol: options.proxy?.protocol, + hostname: ensure('proxy.host', options.proxy?.host, isDefined()), + port: options.proxy?.port + }).toString(); + + const agent = new ProxyAgent({ + httpAgent: options.httpAgent, + httpsAgent: options.httpsAgent, + + // if there's a specific proxy override configured, use it + // if not - detect proxy automatically based on env variables + getProxyForUrl: envProxyOverride + ? (url_: string) => envProxyOverride + : undefined, + }); + + return { + proxy: false, + httpAgent: agent, + httpsAgent: agent, + }; +} diff --git a/packages/rest/src/io/createAxios.ts b/packages/rest/src/io/createAxios.ts new file mode 100644 index 00000000000..8478e778c69 --- /dev/null +++ b/packages/rest/src/io/createAxios.ts @@ -0,0 +1,50 @@ +import { Duration } from '@serenity-js/core'; +import axios, { Axios, type AxiosInstance, type AxiosRequestConfig } from 'axios'; + +import { axiosProxyOverridesFor } from './axiosProxyOverridesFor'; +import type { AxiosRequestConfigDefaults, AxiosRequestConfigProxyDefaults } from './AxiosRequestConfigDefaults'; + +/** + * Creates an Axios instance with desired configuration and proxy support. + * + * @param axiosInstanceOrConfig + */ +export function createAxios(axiosInstanceOrConfig: AxiosInstance | AxiosRequestConfigDefaults = {}): AxiosInstance { + const axiosInstanceGiven = isAxiosInstance(axiosInstanceOrConfig); + + const axiosInstance = axiosInstanceGiven + ? axiosInstanceOrConfig + : axios.create({ + timeout: Duration.ofSeconds(10).inMilliseconds(), + ...omit(axiosInstanceOrConfig, 'proxy'), + }); + + const proxyConfig: AxiosRequestConfigProxyDefaults | false | undefined = axiosInstanceGiven + ? axiosInstanceOrConfig.defaults.proxy + : axiosInstanceOrConfig.proxy; + + const proxyOverrides = axiosProxyOverridesFor({ + ...axiosInstance.defaults, + proxy: proxyConfig || undefined, + }); + + return withOverrides(axiosInstance, proxyOverrides); +} + +function isAxiosInstance(axiosInstanceOrConfig: any): axiosInstanceOrConfig is AxiosInstance { + return axiosInstanceOrConfig + && (axiosInstanceOrConfig instanceof Axios || axiosInstanceOrConfig.defaults); +} + +function withOverrides(axiosInstance: AxiosInstance, overrides: AxiosRequestConfig): AxiosInstance { + for (const [key, override] of Object.entries(overrides)) { + axiosInstance.defaults[key] = override; + } + + return axiosInstance; +} + +function omit(record: T, key: K): Omit { + const { [key]: omitted_, ...rest } = record; + return rest; +} diff --git a/packages/rest/src/io/createUrl.ts b/packages/rest/src/io/createUrl.ts new file mode 100644 index 00000000000..912c6b4d104 --- /dev/null +++ b/packages/rest/src/io/createUrl.ts @@ -0,0 +1,39 @@ +import { ensure, isDefined, isNotBlank, isString } from 'tiny-types'; + +export interface CreateUrlOptions { + protocol?: string; + hostname: string; + port?: string | number; + username?: string; + password?: string; +} + +export function createUrl(options: CreateUrlOptions): URL { + const hostname = ensure('hostname', options?.hostname, isString(), isNotBlank()).trim(); + const port = options?.port + ? ':' + options?.port + : (options?.protocol ? undefined : ':80'); + + return new URL([ + options?.protocol && protocolFrom(options?.protocol), + (options?.username || options?.password) && credentialsFrom(options.username, options.password), + hostname, + port, + ].filter(Boolean).join('')); +} + +function protocolFrom(protocol?: string): string { + const protocolName = protocol.match(/([A-Za-z]+)[/:]*/)[1]; + + ensure('hostname', protocolName, isDefined()); + + return protocolName + '://'; +} + +function credentialsFrom(username?: string, password?: string): string { + return [ + username && encodeURIComponent(username), + password && ':' + encodeURIComponent(password), + '@' + ].filter(Boolean).join(''); +} diff --git a/packages/rest/src/io/index.ts b/packages/rest/src/io/index.ts new file mode 100644 index 00000000000..d77e6e080f2 --- /dev/null +++ b/packages/rest/src/io/index.ts @@ -0,0 +1,2 @@ +export * from './AxiosRequestConfigDefaults'; +export * from './createAxios'; diff --git a/packages/rest/src/screenplay/abilities/CallAnApi.ts b/packages/rest/src/screenplay/abilities/CallAnApi.ts index ae7a70beff3..6a288d82379 100644 --- a/packages/rest/src/screenplay/abilities/CallAnApi.ts +++ b/packages/rest/src/screenplay/abilities/CallAnApi.ts @@ -1,16 +1,14 @@ -import { Ability, ConfigurationError, Duration, LogicError, TestCompromisedError } from '@serenity-js/core'; -import axios, { - Axios, +import { Ability, ConfigurationError, LogicError, TestCompromisedError } from '@serenity-js/core'; +import { type AxiosDefaults, type AxiosError, type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, - type CreateAxiosDefaults, } from 'axios'; -import type { AxiosRequestConfigDefaults, AxiosRequestConfigProxyDefaults } from './AxiosRequestConfigDefaults'; -import { axiosProxyOverridesFor } from './proxy'; +import type { AxiosRequestConfigDefaults} from '../../io'; +import { createAxios } from '../../io'; /** * An {@apilink Ability} that wraps [axios client](https://axios-http.com/docs/api_intro) and enables @@ -150,11 +148,11 @@ import { axiosProxyOverridesFor } from './proxy'; * import { CallAnApi, GetRequest, LastResponse, Send } from '@serenity-js/rest' * import { Ensure, equals } from '@serenity-js/assertions' * - * import axios from 'axios' + * import { createAxios } from '@serenity-js/axios' * import axiosRetry from 'axios-retry' * - * const instance = axios.create({ baseURL 'https://api.example.org/' }) - * axiosRetry(axios, { retries: 3 }) + * const instance = createAxios({ baseURL 'https://api.example.org/' }) + * axiosRetry(instance, { retries: 3 }) * * await actorCalled('Apisitt') * .whoCan( @@ -177,11 +175,11 @@ import { axiosProxyOverridesFor } from './proxy'; * import { CallAnApi, GetRequest, LastResponse, Send } from '@serenity-js/rest' * import { Ensure, equals } from '@serenity-js/assertions' * - * import axios from 'axios' + * import { axiosCreate } from '@serenity-js/rest' * import axiosRetry from 'axios-retry' * - * const instance = axios.create({ baseURL 'https://api.example.org/' }) - * axiosRetry(axios, { retries: 3 }) + * const instance = axiosCreate({ baseURL 'https://api.example.org/' }) + * axiosRetry(instance, { retries: 3 }) * * await actorCalled('Apisitt') * .whoCan( @@ -326,10 +324,6 @@ export class CallAnApi extends Ability { private lastResponse: AxiosResponse; - private static readonly defaults: CreateAxiosDefaults = { - timeout: Duration.ofSeconds(10).inMilliseconds(), - }; - /** * Produces an {@apilink Ability|ability} to call a REST API at a specified `baseURL`; * @@ -363,26 +357,7 @@ export class CallAnApi extends Ability { * @param axiosInstanceOrConfig */ static using(axiosInstanceOrConfig: AxiosInstance | AxiosRequestConfigDefaults): CallAnApi { - - const axiosInstanceGiven = isAxiosInstance(axiosInstanceOrConfig); - - const axiosInstance = axiosInstanceGiven - ? axiosInstanceOrConfig - : axios.create({ - ...CallAnApi.defaults, - ...omit(axiosInstanceOrConfig, 'proxy'), - }); - - const proxyConfig: AxiosRequestConfigProxyDefaults | false | undefined = axiosInstanceGiven - ? axiosInstanceOrConfig.defaults.proxy - : axiosInstanceOrConfig.proxy; - - const proxyOverrides = axiosProxyOverridesFor({ - ...axiosInstance.defaults, - proxy: proxyConfig || undefined, - }); - - return new CallAnApi(withOverrides(axiosInstance, proxyOverrides)); + return new CallAnApi(createAxios(axiosInstanceOrConfig)); } /** @@ -488,21 +463,3 @@ export class CallAnApi extends Ability { return mappingFunction(this.lastResponse); } } - -function isAxiosInstance(axiosInstanceOrConfig: any): axiosInstanceOrConfig is AxiosInstance { - return axiosInstanceOrConfig - && (axiosInstanceOrConfig instanceof Axios || axiosInstanceOrConfig.defaults); -} - -function withOverrides(axiosInstance: AxiosInstance, overrides: AxiosRequestConfig): AxiosInstance { - for (const [key, override] of Object.entries(overrides)) { - axiosInstance.defaults[key] = override; - } - - return axiosInstance; -} - -function omit(record: T, key: K): Omit { - const { [key]: omitted_, ...rest } = record; - return rest; -} diff --git a/packages/rest/src/screenplay/abilities/index.ts b/packages/rest/src/screenplay/abilities/index.ts index 8338ed5e2a4..a9fd9b11c6d 100644 --- a/packages/rest/src/screenplay/abilities/index.ts +++ b/packages/rest/src/screenplay/abilities/index.ts @@ -1,2 +1 @@ -export * from './AxiosRequestConfigDefaults'; export * from './CallAnApi'; diff --git a/packages/rest/src/screenplay/interactions/ChangeApiConfig.ts b/packages/rest/src/screenplay/interactions/ChangeApiConfig.ts index a6c3c1a9a83..24152f7b9e8 100644 --- a/packages/rest/src/screenplay/interactions/ChangeApiConfig.ts +++ b/packages/rest/src/screenplay/interactions/ChangeApiConfig.ts @@ -13,7 +13,7 @@ import { CallAnApi } from '../abilities'; * ```ts * import { actorCalled } from '@serenity-js/core'; * import { By Navigate, PageElement, Text } from '@serenity-js/web'; - * import { CallAnApi, ChangeApiConfig, GetRequest, LastResponse, Send } from '@serenity-js/rest' + * import { axiosCreate, CallAnApi, ChangeApiConfig, GetRequest, LastResponse, Send } from '@serenity-js/rest' * import { Ensure, equals } from '@serenity-js/assertions'; * * import * as axios from 'axios'; @@ -29,7 +29,7 @@ import { CallAnApi } from '../abilities'; * BrowseTheWeb.using(protractor.browser), * * // Note: no default base URL is given when the axios instance is created - * CallAnApi.using(axios.create()), + * CallAnApi.using(axiosCreate()), * ) * .attemptsTo( * Navigate.to('/profile'),