@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
224 lines (207 loc) • 8.38 kB
text/typescript
import { URL } from 'node:url';
import type { Answerable, AnswersQuestions, CollectsArtifacts, UsesAbilities } from '@serenity-js/core';
import { d, Interaction, LogicError } from '@serenity-js/core';
import { CallAnApi } from '../abilities';
/**
* Changes configuration of the [ability](https://serenity-js.org/api/core/class/Ability/) to [`CallAnApi`](https://serenity-js.org/api/rest/class/CallAnApi/)
* that the [actor](https://serenity-js.org/api/core/class/Actor/) executing this [interaction](https://serenity-js.org/api/core/class/Interaction/) has been configured with.
*
* ## Changing API URL for all subsequent requests
*
* ```ts
* import { actorCalled } from '@serenity-js/core';
* import { By Navigate, PageElement, Text } from '@serenity-js/web';
* import { axiosCreate, CallAnApi, ChangeApiConfig, GetRequest, LastResponse, Send } from '@serenity-js/rest'
* import { Ensure, equals } from '@serenity-js/assertions';
*
* import * as axios from 'axios';
*
* // Let's imagine that the website under test displays
* // a dynamically generated API URL that we would like to use
* const ApiDetailsWidget = {
* url: () => PageElement.located(By.id('api-url')).describedAs('API URL'),
* }
*
* await actorCalled('Apisitt')
* .whoCan(
* BrowseTheWeb.using(protractor.browser),
*
* // Note: no default base URL is given when the axios instance is created
* CallAnApi.using(axiosCreate()),
* )
* .attemptsTo(
* Navigate.to('/profile'),
*
* // We change the API URL based on the text displayed in the widget
* // (although we could change it to some arbitrary string too).
* ChangeApiConfig.setUrlTo(Text.of(ApiDetailsWidget.url())),
*
* // Any subsequent request will be sent to the newly set URL
* Send.a(GetRequest.to('/projects')),
* Ensure.that(LastResponse.status(), equals(200)),
* )
* ```
*
* ## Changing API port for all subsequent requests
*
* ```ts
* import { actorCalled } from '@serenity-js/core'
* import { LocalServer, ManageALocalServer, StartLocalServer } from '@serenity-js/local-server'
* import { CallAnApi, ChangeApiConfig, GetRequest, LastResponse, Send } from '@serenity-js/rest'
* import { Ensure, equals } from '@serenity-js/assertions'
*
* await actorCalled('Apisitt')
* .whoCan(
* ManageALocalServer.runningAHttpListener(someServer),
* CallAnApi.at('http://localhost'),
* )
* .attemptsTo(
* StartALocalServer.onRandomPort(),
* ChangeApiConfig.setPortTo(LocalServer.port()),
* Send.a(GetRequest.to('/api')),
* Ensure.that(LastResponse.status(), equals(200)),
* )
* ```
*
* ## Setting a header for all subsequent requests
*
* ```ts
* import { actorCalled, Question } from '@serenity-js/core'
* import { CallAnApi, ChangeApiConfig, GetRequest, LastResponse, Send } from '@serenity-js/rest'
* import { Ensure, equals } from '@serenity-js/assertions'
*
* // A sample Question reading a Node process environment variable
* const EnvVar = (var_name: string) =>
* Question.about(`${ name } environment variable`, actor => process.env[var_name]);
*
* await actorCalled('Apisitt')
* .whoCan(
* CallAnApi.at('http://localhost'),
* )
* .attemptsTo(
* ChangeApiConfig.setHeader('Authorization', EnvVar('TOKEN')),
* Send.a(GetRequest.to('/api')),
* Ensure.that(LastResponse.status(), equals(200)),
* )
* ```
*
* ## Handling sensitive information
*
* By design, any data handled by an actor appears in Serenity/JS reports.
* To prevent the exposure of any sensitive information, such as passwords or tokens, you should use [`Masked`](https://serenity-js.org/api/core/class/Masked/).
*
* ```ts
* import { actorCalled, Masked } from '@serenity-js/core'
* import { CallAnApi, ChangeApiConfig, GetRequest, LastResponse, Send } from '@serenity-js/rest'
* import { Ensure, equals } from '@serenity-js/assertions'
*
* await actorCalled('Apisitt')
* .whoCan(
* CallAnApi.at('http://localhost'),
* )
* .attemptsTo(
* ChangeApiConfig.setHeader('Authorization', Masked.valueOf('secret token')),
* Send.a(GetRequest.to('/api')),
* Ensure.that(LastResponse.status(), equals(200)),
* )
* ```
*
* @group Activities
*/
export class ChangeApiConfig {
/**
* Instructs the [actor](https://serenity-js.org/api/core/class/Actor/) to change the base URL
* of their [ability](https://serenity-js.org/api/core/class/Ability/) to [`CallAnApi`](https://serenity-js.org/api/rest/class/CallAnApi/)
*
* @param newApiUrl
*/
static setUrlTo(newApiUrl: Answerable<string>): Interaction {
return new ChangeApiConfigSetUrl(newApiUrl);
}
/**
* Instructs the [actor](https://serenity-js.org/api/core/class/Actor/) to change the port configured in the base URL
* of their [ability](https://serenity-js.org/api/core/class/Ability/) to [`CallAnApi`](https://serenity-js.org/api/rest/class/CallAnApi/)
*
* @param newApiPort
*/
static setPortTo(newApiPort: Answerable<number>): Interaction {
return new ChangeApiConfigSetPort(newApiPort)
}
/**
* Instructs the [actor](https://serenity-js.org/api/core/class/Actor/) to change the configuration of the [`AxiosInstance`](https://axios-http.com/docs/instance)
* used by their [ability](https://serenity-js.org/api/core/class/Ability/) to [`CallAnApi`](https://serenity-js.org/api/rest/class/CallAnApi/)
* and set an HTTP request header for any subsequent [HTTP requests](https://serenity-js.org/api/rest/class/HTTPRequest/)
* issued via [`Send`](https://serenity-js.org/api/rest/class/Send/).
*
* @param name
* @param value
*/
static setHeader(name: Answerable<string>, value: Answerable<string>): Interaction {
return new ChangeApiConfigSetHeader(name, value);
}
}
/**
* @package
*/
class ChangeApiConfigSetUrl extends Interaction {
constructor(private readonly newApiUrl: Answerable<string>) {
super(d`#actor changes API url configuration to ${ newApiUrl }`);
}
performAs(actor: UsesAbilities & CollectsArtifacts & AnswersQuestions): Promise<void> {
return actor.answer(this.newApiUrl)
.then(newApiUrl => CallAnApi.as(actor).modifyConfig(config => config.baseURL = newApiUrl));
}
}
/**
* @package
*/
class ChangeApiConfigSetPort extends Interaction {
constructor(private readonly newPort: Answerable<number | string>) {
super(`#actor changes API port configuration to ${ newPort }`);
}
performAs(actor: UsesAbilities & CollectsArtifacts & AnswersQuestions): Promise<void> {
return actor.answer(this.newPort)
.then(newPort => CallAnApi.as(actor).modifyConfig(config => {
if (! config.baseURL) {
throw new LogicError(`Can't change the port of a baseURL that has not been set`)
}
try {
const newUrl = new URL(config.baseURL);
newUrl.port = `${ newPort }`;
config.baseURL = newUrl.toString();
}
catch (error) {
throw new LogicError(`Could not change the API port`, error);
}
}));
}
}
/**
* @package
*
* @see https://github.com/axios/axios#custom-instance-defaults
*/
class ChangeApiConfigSetHeader extends Interaction {
constructor(
private readonly name: Answerable<string>,
private readonly value: Answerable<string>
) {
super(`#actor changes API URL and sets header "${ name }" to "${ value }"`);
}
performAs(actor: UsesAbilities & CollectsArtifacts & AnswersQuestions): Promise<void> {
return Promise.all([
actor.answer(this.name),
actor.answer(this.value),
]).
then(([ name, value ]) => {
if (! name) {
throw new LogicError(`Looks like the name of the header is missing, "${ name }" given`);
}
// A header with an empty value might still be valid so we don't validate the value
// see: https://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html#sec2.1
return CallAnApi.as(actor).modifyConfig(config => {
config.headers.common[name] = value;
});
});
}
}