@serenity-js/rest
Version:
Serenity/JS Screenplay Pattern library for interacting with REST and other HTTP-based services, supporting comprehensive API testing and blended testing scenarios
559 lines • 22.4 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.CallAnApi = void 0;
const node_url_1 = require("node:url");
const core_1 = require("@serenity-js/core");
const agent_base_1 = require("agent-base");
const objects_1 = require("tiny-types/lib/objects");
const io_1 = require("../../io");
/**
* An [ability](https://serenity-js.org/api/core/class/Ability/) that wraps [axios client](https://axios-http.com/docs/api_intro) and enables
* the [actor](https://serenity-js.org/api/core/class/Actor/) to [send](https://serenity-js.org/api/rest/class/Send/)
* [HTTP requests](https://serenity-js.org/api/rest/class/HTTPRequest/) to HTTP APIs.
*
* ## Configuring the ability to call an API
*
* The easiest way to configure the ability to `CallAnApi` is to provide the `baseURL` of your HTTP API,
* and rely on Serenity/JS to offer other sensible defaults:
*
* ```ts
* import { actorCalled } from '@serenity-js/core'
* import { CallAnApi, GetRequest, LastResponse, Send } from '@serenity-js/rest'
* import { Ensure, equals } from '@serenity-js/assertions'
*
* await actorCalled('Apisitt')
* .whoCan(
* CallAnApi.at('https://api.example.org/')
* )
* .attemptsTo(
* Send.a(GetRequest.to('/v1/users/2')), // GET https://api.example.org/v1/users/2
* Ensure.that(LastResponse.status(), equals(200)),
* )
* ```
*
* Please note that the following Serenity/JS test runner adapters already provide the ability to `CallAnApi` as part of their default configuration,
* so you don't need to configure it yourself:
* - [Playwright Test](https://serenity-js.org/handbook/test-runners/playwright-test/)
* - [WebdriverIO](https://serenity-js.org/handbook/test-runners/webdriverio/)
* - [Protractor](https://serenity-js.org/handbook/test-runners/protractor/)
*
* ### Resolving relative URLs
*
* Serenity/JS resolves request URLs using Node.js [WHATWG URL API](https://nodejs.org/api/url.html#new-urlinput-base).
* This means that the request URL is determined using the resource path resolved in the context of base URL, i.e. `new URL(resourcePath, [baseURL])`.
*
* Consider the following example:
*
* ```ts
* import { actorCalled } from '@serenity-js/core'
* import { CallAnApi, GetRequest, LastResponse, Send } from '@serenity-js/rest'
* import { Ensure, equals } from '@serenity-js/assertions'
*
* await actorCalled('Apisitt')
* .whoCan(
* CallAnApi.at(baseURL)
* )
* .attemptsTo(
* Send.a(GetRequest.to(resourcePath)),
* Ensure.that(LastResponse.status(), equals(200)),
* )
* ```
*
* In the above example:
* - when `resourcePath` is defined as a full URL, it overrides the base URL
* - when `resourcePath` starts with a forward slash `/`, it replaces any path defined in the base URL
* - when `resourcePath` is not a full URL and doesn't start with a forward slash, it gets appended to the base URL
*
* | baseURL | resourcePath | result |
* | ----------------------------- | -------------------------- | ------------------------------------ |
* | `https://api.example.org/` | `/v1/users/2` | `https://api.example.org/v1/users/2` |
* | `https://example.org/api/v1/` | `users/2` | `https://example.org/api/v1/users/2` |
* | `https://example.org/api/v1/` | `/secure/oauth` | `https://example.org/secure/oauth` |
* | `https://v1.example.org/api/` | `https://v2.example.org/` | `https://v2.example.org/` |
*
* ### Using Axios configuration object
*
* When you need more control over how your Axios instance is configured, provide
* an [Axios configuration object](https://axios-http.com/docs/req_config). For example:
*
* ```ts
* import { actorCalled } from '@serenity-js/core'
* import { CallAnApi, GetRequest, LastResponse, Send } from '@serenity-js/rest'
* import { Ensure, equals } from '@serenity-js/assertions'
*
* await actorCalled('Apisitt')
* .whoCan(
* CallAnApi.using({
* baseURL: 'https://api.example.org/',
* timeout: 30_000,
* // ... other configuration options
* })
* )
* .attemptsTo(
* Send.a(GetRequest.to('/users/2')),
* Ensure.that(LastResponse.status(), equals(200)),
* )
* ```
*
* ## Working with proxy servers
*
* `CallAnApi` uses an approach described in ["Node.js Axios behind corporate proxies"](https://janmolak.com/node-js-axios-behind-corporate-proxies-8b17a6f31f9d)
* to automatically detect proxy server configuration based on your environment variables.
*
* You can override this default proxy detection mechanism by providing your own proxy configuration object.
*
* ### Automatic proxy support
*
* When the URL you're sending the request to uses the HTTP protocol, Serenity/JS will automatically detect your proxy configuration based on the following environment variables:
* - `npm_config_http_proxy`
* - `http_proxy` and `HTTP_PROXY`
* - `npm_config_proxy`
* - `all_proxy`
*
* Similarly, when the request target URL uses the HTTPS protocol, the following environment variables are used instead:
* - `npm_config_https_proxy`
* - `https_proxy` and `HTTPS_PROXY`
* - `npm_config_proxy`
* - `all_proxy`
*
* Proxy configuration is ignored for both HTTP and HTTPS target URLs matching the proxy bypass rules defined in the following environment variables:
* - `npm_config_no_proxy`
* - `no_proxy` and `NO_PROXY`
*
* ### Custom proxy configuration
*
* To override the automatic proxy detection based on the environment variables, provide a proxy configuration object:
*
* ```ts
* import { actorCalled } from '@serenity-js/core'
* import { CallAnApi, GetRequest, LastResponse, Send } from '@serenity-js/rest'
* import { Ensure, equals } from '@serenity-js/assertions'
*
* await actorCalled('Apisitt')
* .whoCan(
* CallAnApi.using({
* baseURL: 'https://api.example.org/',
* proxy: {
* protocol: 'http',
* host: 'proxy.example.org',
* port: 9000,
* auth: { // optional
* username: 'proxy-username',
* password: 'proxy-password',
* },
* bypass: 'status.example.org, internal.example.org' // optional
* }
* // ... other configuration options
* })
* )
* .attemptsTo(
* Send.a(GetRequest.to('/users/2')),
* Ensure.that(LastResponse.status(), equals(200)),
* )
* ```
*
* Note that Serenity/JS uses [proxy-agents](https://github.com/TooTallNate/proxy-agents)
* and the approach described in ["Node.js Axios behind corporate proxies"](https://janmolak.com/node-js-axios-behind-corporate-proxies-8b17a6f31f9d)
* to work around [limited proxy support capabilities](https://github.com/axios/axios/issues?q=is%3Aissue+is%3Aopen+proxy) in Axios itself.
*
* ### Bypassing proxy configuration
*
* To bypass the proxy configuration for specific hostnames and IP addresses, you can either:
* - provide the `bypass` property in the proxy configuration object, or
* - use the `no_proxy` environment variable.
*
* The value of the `bypass` property or the `no_proxy` environment variable should be a comma-separated list of hostnames and IP addresses
* that should not be routed through the proxy server, for example: `.com, .serenity-js.org, .domain.com`.
*
* Note that setting the `bypass` property to `example.org` makes the requests to following URLs bypass the proxy server:
* - `api.example.org`
* - `sub.sub.example.org`
* - `example.org`
* - `my-example.org`
*
* :::info
* Serenity/JS doesn't currently support `bypass` rules expressed using CIDR notation, like `192.168.17.0/24`.
* Instead, it uses a simple comma-separated list of hostnames and IP addresses.
* If you need support for CIDR notation, please [raise an issue](https://github.com/serenity-js/serenity-js/issues).
* :::
*
* ### Using Axios instance with proxy support
*
* To have full control over the Axios instance used by the ability to `CallAnApi`, you can create it yourself
* and inject it into the ability.
* This approach allows you to still benefit from automated proxy detection in configuration, while taking advantage
* of the many [Axios plugins](https://www.npmjs.com/search?q=axios).
*
* ```ts
* import { actorCalled } from '@serenity-js/core'
* import { createAxios, CallAnApi, GetRequest, LastResponse, Send } from '@serenity-js/rest'
* import { Ensure, equals } from '@serenity-js/assertions'
*
* import axiosRetry from 'axios-retry'
*
* const instance = createAxios({ baseURL: 'https://api.example.org/' })
* axiosRetry(instance, { retries: 3 })
*
* await actorCalled('Apisitt')
* .whoCan(
* CallAnApi.using(instance)
* )
* .attemptsTo(
* Send.a(GetRequest.to('/users/2')),
* Ensure.that(LastResponse.status(), equals(200)),
* )
* ```
*
* ### Using raw Axios instance
*
* If you don't want Serenity/JS to enhance your Axios instance with proxy support, instantiate the ability to
* `CallAnApi` using its constructor directly.
* Note, however, that by using this approach you're taking the responsibility for all the aspects of configuring Axios.
*
* ```ts
* import { actorCalled } from '@serenity-js/core'
* import { CallAnApi, GetRequest, LastResponse, Send } from '@serenity-js/rest'
* import { Ensure, equals } from '@serenity-js/assertions'
*
* import { axiosCreate } from '@serenity-js/rest'
* import axiosRetry from 'axios-retry'
*
* const instance = axiosCreate({ baseURL: 'https://api.example.org/' })
* axiosRetry(instance, { retries: 3 })
*
* await actorCalled('Apisitt')
* .whoCan(
* new CallAnApi(instance) // using the constructor ensures your axios instance is not modified in any way.
* )
* .attemptsTo(
* // ...
* )
* ```
*
* ### Serenity/JS defaults
*
* When using [`CallAnApi.at`](https://serenity-js.org/api/rest/class/CallAnApi/#at) or [`CallAnApi.using`](https://serenity-js.org/api/rest/class/CallAnApi/#using) with a configuration object, Serenity/JS
* merges your [Axios request configuration](https://axios-http.com/docs/req_config) with the following defaults:
* - `timeout`: 10 seconds
*
*
* You can override them by specifying the given property in your configuration object, for example:
* ```ts
* import { actorCalled } from '@serenity-js/core'
* import { CallAnApi, GetRequest, LastResponse, Send } from '@serenity-js/rest'
* import { Ensure, equals } from '@serenity-js/assertions'
*
* await actorCalled('Apisitt')
* .whoCan(
* CallAnApi.using({
* baseURL: 'https://api.example.org/',
* timeout: 30_000
* })
* )
* .attemptsTo(
* Send.a(GetRequest.to('/users/2')),
* Ensure.that(LastResponse.status(), equals(200)),
* )
* ```
*
* ## Interacting with multiple APIs
*
* Some test scenarios might require you to interact with multiple HTTP APIs. With Serenity/JS you can do this
* using either API-specific actors, or by specifying full URLs when performing the requests.
*
* The following examples will assume that the test scenarios needs to interact with the following APIs:
* - `https://testdata.example.org/api/v1/`
* - `https://shop.example.org/api/v1/`
*
* Let's also assume that the `testdata` API allows the automation to manage the test data used by the `shop` API.
*
* ### Using API-specific actors
*
* To create API-specific actors, configure your [test runner](https://serenity-js.org/handbook/test-runners/) with a [cast](https://serenity-js.org/api/core/class/Cast/)
* that gives your actors appropriate abilities based, for example, on their name:
*
* ```ts
* import { beforeEach } from 'mocha'
* import { Actor, Cast, engage } from '@serenity-js/core'
* import { CallAnApi } from '@serenity-js/rest'
*
* export class MyActors implements Cast {
* prepare(actor: Actor): Actor {
* switch(actor.name) {
* case 'Ted':
* return actor.whoCan(CallAnApi.at('https://testdata.example.org/api/v1/'))
* case 'Shelly':
* return actor.whoCan(CallAnApi.at('https://shop.example.org/api/v1/'))
* default:
* return actor;
* }
* }
* }
*
* beforeEach(() => engage(new MyActors()))
* ```
*
* Next, retrieve the appropriate actor in your test scenario using [`actorCalled`](https://serenity-js.org/api/core/function/actorCalled/), for example:
*
* ```ts
* import { describe, it, beforeEach } from 'mocha'
* import { actorCalled, engage } from '@serenity-js/core
* import { Send, GetRequest, PostRequest, LastResponse } from '@serenity-js/rest'
* import { Ensure, equals } from '@serenity-js/assertions'
*
* describe('Multi-actor API testing', () => {
* beforeEach(() => engage(new MyActors()))
*
* it('allows each actor to interact with their API', async () => {
*
* await actorCalled('Ted').attemptsTo(
* Send.a(PostRequest.to('products').with({ name: 'Apples', price: '£2.50' })),
* Ensure.that(LastResponse.status(), equals(201)),
* )
*
* await actorCalled('Shelly').attemptsTo(
* Send.a(GetRequest.to('?product=Apples')),
* Ensure.that(LastResponse.status(), equals(200)),
* Ensure.that(LastResponse.body(), equals([
* { name: 'Apples', price: '£2.50' }
* ])),
* )
* })
* })
* ```
*
* ### Using full URLs
*
* If you prefer to have a single actor interacting with multiple APIs, you can specify the full URL for every request:
*
* ```ts
* import { describe, it, beforeEach } from 'mocha'
* import { actorCalled, Cast, engage } from '@serenity-js/core
* import { CallAnApi, Send, GetRequest, PostRequest, LastResponse } from '@serenity-js/rest'
* import { Ensure, equals } from '@serenity-js/assertions'
*
* describe('Multi-actor API testing', () => {
* beforeEach(() => engage(
* Cast.where(actor => actor.whoCan(CallAnApi.using({})))
* ))
*
* it('allows each actor to interact with their API', async () => {
*
* await actorCalled('Alice').attemptsTo(
* Send.a(PostRequest.to('https://testdata.example.org/api/v1/products')
* .with({ name: 'Apples', price: '£2.50' })),
* Ensure.that(LastResponse.status(), equals(201)),
*
* Send.a(GetRequest.to('https://shop.example.org/api/v1/?product=Apples')),
* Ensure.that(LastResponse.status(), equals(200)),
* Ensure.that(LastResponse.body(), equals([
* { name: 'Apples', price: '£2.50' }
* ])),
* )
* })
* })
* ```
*
* ## Learn more
* - [Axios: Configuring requests](https://axios-http.com/docs/req_config)
* - [MDN: HTTP methods documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods)
*
* @group Abilities
*/
class CallAnApi extends core_1.Ability {
axiosInstance;
lastResponse;
/**
* Produces an [ability](https://serenity-js.org/api/core/class/Ability/) to call a REST API at a specified `baseURL`;
*
* This is the same as invoking `CallAnApi.using({ baseURL: 'https://example.org' })`
*
* @param baseURL
*/
static at(baseURL) {
return CallAnApi.using({
baseURL: baseURL instanceof node_url_1.URL
? baseURL.toString()
: baseURL
});
}
/**
* Produces an [ability](https://serenity-js.org/api/core/class/Ability/) to call an HTTP API using the given Axios instance,
* or an Axios request configuration object.
*
* When you provide an [Axios configuration object](https://axios-http.com/docs/req_config),
* it gets shallow-merged with the following defaults:
* - request timeout of 10 seconds
* - automatic proxy support based on
* your [environment variables](https://www.npmjs.com/package/proxy-from-env#environment-variables)
*
* When you provide an Axios instance, it's enhanced with proxy support and no other modifications are made.
*
* If you don't want Serenity/JS to augment or modify your Axios instance in any way,
* please use the [`CallAnApi.constructor`](https://serenity-js.org/api/rest/class/CallAnApi/#constructor) directly.
*
* @param axiosInstanceOrConfig
*/
static using(axiosInstanceOrConfig) {
return new CallAnApi((0, io_1.createAxios)(axiosInstanceOrConfig));
}
/**
* #### Learn more
* - [AxiosInstance](https://axios-http.com/docs/instance)
*
* @param axiosInstance
* A pre-configured instance of the Axios HTTP client
*/
constructor(axiosInstance) {
super();
this.axiosInstance = axiosInstance;
}
/**
* Allows for the original Axios config to be changed after
* the [ability](https://serenity-js.org/api/core/class/Ability/) to [`CallAnApi`](https://serenity-js.org/api/rest/class/CallAnApi/)
* has been instantiated and given to the [`Actor`](https://serenity-js.org/api/core/class/Actor/).
*
* #### Learn more
* - [AxiosRequestConfig](https://axios-http.com/docs/req_config)
*
* @param fn
*/
modifyConfig(fn) {
fn(this.axiosInstance.defaults);
}
/**
* Sends an HTTP request to a specified url.
* Response will be cached and available via [`CallAnApi.mapLastResponse`](https://serenity-js.org/api/rest/class/CallAnApi/#mapLastResponse).
*
* #### Learn more
* - [AxiosRequestConfig](https://axios-http.com/docs/req_config)
* - [AxiosResponse](https://axios-http.com/docs/res_schema)
*
* @param config
* Axios request configuration, which can be used to override the defaults
* provided when the [ability](https://serenity-js.org/api/core/class/Ability/)
* to [`CallAnApi`](https://serenity-js.org/api/rest/class/CallAnApi/) was instantiated.
*/
async request(config) {
let url;
try {
url = this.resolveUrl(config);
this.lastResponse = await this.axiosInstance.request({
...config,
url,
});
return this.lastResponse;
}
catch (error) {
const description = `${config.method.toUpperCase()} ${url || config.url}`;
switch (true) {
case /timeout.*exceeded/.test(error.message):
throw new core_1.TestCompromisedError(`The request has timed out: ${description}`, error);
case /Network Error/.test(error.message):
throw new core_1.TestCompromisedError(`A network error has occurred: ${description}`, error);
case error instanceof TypeError:
throw new core_1.ConfigurationError(`Looks like there was an issue with Axios configuration`, error);
case !error.response:
throw new core_1.TestCompromisedError(`The API call has failed: ${description}`, error);
default:
this.lastResponse = error.response;
return error.response;
}
}
}
/**
* Resolves the final URL, based on the [`AxiosRequestConfig`](https://axios-http.com/docs/req_config) provided
* and any defaults that the [`AxiosInstance`](https://axios-http.com/docs/instance) has been configured with.
*
* Note that unlike Axios, this method uses the Node.js [WHATWG URL API](https://nodejs.org/api/url.html#new-urlinput-base)
* to ensure URLs are correctly resolved.
*
* @param config
*/
resolveUrl(config) {
const baseURL = this.axiosInstance.defaults.baseURL || config.baseURL;
return baseURL
? new node_url_1.URL(config.url, baseURL).toString()
: config.url;
}
/**
* Maps the last cached response to another type.
* Useful when you need to extract a portion of the [`AxiosResponse`](https://axios-http.com/docs/res_schema) object.
*
* #### Learn more
* - [AxiosResponse](https://axios-http.com/docs/res_schema)
*
* @param mappingFunction
*/
mapLastResponse(mappingFunction) {
if (!this.lastResponse) {
throw new core_1.LogicError(`Make sure to perform a HTTP API call before checking on the response`);
}
return mappingFunction(this.lastResponse);
}
toJSON() {
const simplifiedConfig = {
baseURL: this.axiosInstance.defaults.baseURL,
headers: this.axiosInstance.defaults.headers,
timeout: this.axiosInstance.defaults.timeout,
proxy: proxyConfigFrom(this.axiosInstance.defaults),
};
return {
...super.toJSON(),
options: {
...recursivelyRemove([isUndefined, isEmptyObject], simplifiedConfig),
}
};
}
}
exports.CallAnApi = CallAnApi;
function proxyConfigFrom(defaults) {
if (defaults.proxy === undefined) {
return undefined;
}
if (!(defaults.proxy === false && defaults.httpAgent instanceof agent_base_1.Agent)) {
return undefined;
}
const proxyUrl = defaults.httpAgent.getProxyForUrl(defaults.baseURL);
try {
const url = new node_url_1.URL(proxyUrl);
return {
protocol: url.protocol?.replace(/:$/, ''),
host: url.hostname,
port: url.port ? Number(url.port) : undefined,
auth: url.username
? {
username: url.username || undefined,
password: url.password || undefined,
}
: undefined,
};
}
catch {
return undefined;
}
}
function isUndefined(value) {
return value === undefined;
}
function isEmptyObject(value) {
return (0, objects_1.isObject)(value) && Object.keys(value).length === 0;
}
function recursivelyRemove(matchers, value) {
if (Array.isArray(value)) {
return value.map(item => recursivelyRemove(matchers, item));
}
if (typeof value === 'object' && value !== null) {
return Object.keys(value).reduce((acc, key) => {
if (matchers.some(matcher => matcher(value[key]))) {
return acc;
}
return {
...acc,
[key]: recursivelyRemove(matchers, value[key]),
};
}, {});
}
return value;
}
//# sourceMappingURL=CallAnApi.js.map