UNPKG

@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
"use strict"; 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