Setting up a HTTP Client
Autometa comes with a built in HTTP
module which is a wrapper around
axios that works with a fluent/builder syntax, however you can use
the client of your choosing.
We'll create abstraction layers over this client to match our API structure.
Since the Dummy JSON API is a
large API, we'll break it down by controller, creating class Fixture
s for each.
We'll centralize all of our controllers through an API
class.
First we'll create a Fixture
class for the products
controller.
import { HTTP, Fixture } from "@autometa/runner";
import { Env } from "../env";
import { Products, Product } from "../models/products";
@Fixture
@Constructor(HTTP)
export class ProductController {
constructor(private readonly http: HTTP) {
this.http.url(Env.API_URL).sharedRoute("products");
}
/**
* Get all products on the service.
* [DOCS](https://dummyjson.com/docs/products)
*/
async getAll() {
return this.http.get<Products>();
}
/**
* Get a single product by ID.
* [DOCS](https://dummyjson.com/docs/product#single)
*/
async get(id: number) {
return this.http.get<Product>();
}
/**
* Add a new product.
* [DOCS](https://dummyjson.com/docs/products#add)
*/
async add(product: Product) {
return this.http.data(product).post<Product>();
}
}
Next we'll create an API class to centralize all of our controllers.
// ./src/controllers/api.ts
import { Fixture, HTTP } from "@autometa/runner";
import { ProductController } from "./controllers/products";
@Fixture
@Constructor(ProductController)
export class API {
constructor(public readonly products: ProductController) {}
}
Now we'll inject the API class into the App:
// ./app/app.ts
import { World } from "./world";
import { API } from "./api";
@AppType(World)
@Constructor(API)
export class MyApp {
constructor(public readonly api: API) {
super();
}
}
Which can be used in step definitions.
// ./app/steps/products.steps.ts
import { When } from "@autometa/runner";
When("I get all products", async ({ world, api: { products } }) => {
world.allProductsResponse = await products.getAll();
});
It's a good idea to declare your stored responses on the World
// ./app/world.ts
export class World {
declare allProductsResponse: Products;
declare productResponse: Product;
}
Depending on your tsconfig settings you may not need the declare
keyword, or you might need to
use a not null assertion
// ./app/world.ts
export class World {
allProductsResponse!: Products;
productResponse!: Product;
}
These will be automatically inferred by step definition callbacks.
Using Schemas with our Client
We can use our MyZod/Zod schemas to declare the expected shape of our responses, mapped by status code. For this API we only deal with 200 OK
// ./app/controllers/product/product.controller.ts
import { HTTP, Fixture } from "@autometa/runner";
import { Env } from "../env";
import { ProductSchema, ProductListSchema, Products, Product } from "./";
@Fixture
@Constructor(HTTP)
export class ProductController {
constructor(private readonly http: HTTP) {
this.http.url(Env.API_URL).SharedRoute("product");
}
/**
* Get all products on the service.
* [DOCS](https://dummyjson.com/docs/products)
*/
async getAll(page: number) {
return this.http
.route("all")
.param("page", page)
.schema(ProductListSchema, 200)
.get<Products>();
}
/**
* Get a single product by ID.
* [DOCS](https://dummyjson.com/docs/product#single)
*/
async get(id: number) {
return this.http.route(id).schema(ProductSchema, 200).get<Product>();
}
/**
* Add a new product.
* [DOCS](https://dummyjson.com/docs/products#add)
*/
async add(product: Product) {
return this.http.schema(ProductSchema, 200).data(product).post<Product>();
}
}
Now our responses will automatically be validated according to response code. So if our product 'price' is missing, or is a string, the test will fail immediately with a sensible message. If an unregistered response code is encountered in the response, an error will also be thrown.
In the case of errors you might want that schema to be shared, which can be accomplished with sharedSchema
// ./app/controllers/product/product.controller.ts
import { HTTP, Fixture } from "@autometa/runner";
import { Env } from "../env";
import { ErrorSchema } from "../../errors";
import { ProductSchema, ProductListSchema, Products, Product } from "./";
@Fixture
@Constructor(HTTP)
export class ProductController {
constructor(private readonly http: HTTP) {
this.http
.url(Env.API_URL)
.sharedRoute("product")
.sharedSchema(ErrorSchema, { from: 400, to: 500 });
}
// ...
async get(id: number) {
return this.http.route(id).schema(ProductSchema, 200).get<Product>();
}
// ...
}
If you wish to construct a log using the builder data before the request is sent, you can register a function
to run onSend
. When executed, the registered function will be passed an object representing the state of the request
with the following schema:
interface HTTPRequest<T> {
headers: Record<string, string> = {};
params: Record<string, string> = {};
baseUrl?: string;
route: string[] = [];
method: HTTPMethod;
data: T;
fullUrl: () => string;
}
And upon recieving a response any function registered with onRecievedResponse
which is passed a HTTPRequest<T>
:
interface HTTPResponse<T> {
status: StatusCode;
statusText: string;
data: T;
headers: Record<string, string>;
request: HTTPRequest<unknown>;
}
Now we can add logger functions to our controller (or a shared base class) to log our request and response:
// ./app/controllers/product/product.controller.ts
import { HTTP, Fixture, HTTPResponse, RequestState } from "@autometa/runner";
import { Env } from "../env";
import { ErrorSchema } from "../../errors";
import { ProductSchema, ProductListSchema, Products, Product } from "./";
@Fixture
@Constructor(HTTP)
export class ProductController {
constructor(private readonly http: HTTP) {
this.http
.url(Env.API_URL)
.sharedRoute("product")
.sharedSchema(ErrorSchema, { from: 400, to: 500 })
.sharedOnSend(this.logRequest)
.sharedOnReceive(this.logResponse);
}
// ...
async get(id: number) {
return this.http
.route(id)
.schema(ProductSchema, 200)
.get<Product>();
}
// ...
private logRequest(state: HTTPRequest) {
const headers = JSON.stringify(Object.fromEntries(state.headers));
const data = JSON.stringify(state.data);
console.log(
`Sending ${state.method} request to ${state.fullUrl}
headers: ${headers}
data: ${data}`
);
}
private logResponse(response: HTTPResponse) {
const data = JSON.stringify(response.data);
const url = response.request.url;
console.log(
`Received ${response.status} response from ${url}
statusText: ${response.statusText}
status: ${response.status}
data: ${data}`
);
}
}
Since we want our logs to be used in all endpoints, we register them with sharedOnSend
and sharedOnRecieve
.
It can be a good idea to use a shared base class for your controllers, so you don't have to register these functions for every controller.
// ./app/controllers/base.controller.ts
import { HTTP } from "@autometa/runner";
import { RequestState, HTTPResponse } from "@autometa/runner";
import { Env } from "../apps";
@Constructor(HTTP)
export abstract class BaseController {
constructor(protected readonly http: HTTP) {
this.http
.url(Env.API_URL)
.sharedOnSend(this.logRequest)
.sharedOnReceive(this.logResponse);
}
private logRequest(state: RequestState) {
const headers = JSON.stringify(Object.fromEntries(state.headers));
const data = JSON.stringify(state.data);
console.log(
`Sending ${state.method} request to ${state.fullUrl}
headers: ${headers}
data: ${data}`
);
}
private logResponse(response: HTTPResponse<unknown>) {
const data = JSON.stringify(response.data);
const url = response.request.url;
console.log(
`Received ${response.status} response from ${url}
statusText: ${response.statusText}
status: ${response.status}
data: ${data}`
);
}
}
You can register more than one function per hook. They will run in the order they were defined.
Passing additional configuration
By default the HTTP
fixture uses axios. The executing methods of HTTP (i.e. get
, post
, put
, delete
, patch
) accept an optional config
object which will be merged with the default configuration.
For example, to customize the way axios handles querystrings, you can pass a paramsSerializer
function:
// ./app/controllers/product/base.controller.ts
import { AxiosRequestConfig } from 'axios';
import qs from 'qs';
export const AxiosSerializer: AxiosRequestConfig = {
paramsSerializer: (params) => {
const str = qs.stringify(params, { arrayFormat: 'comma' });
return str;
},
};
And then pass it to the get
method:
// ./app/controllers/product/product.controller.ts
import { HTTP, Fixture } from "@autometa/runner";
@Fixture
@Constructor(HTTP)
export class ProductController {
constructor(private readonly http: HTTP) {
this.http
.url(Env.API_URL)
.sharedRoute("product")
.sharedSchema(ErrorSchema, { from: 400, to: 500 })
.sharedOnSend(this.logRequest)
.sharedOnReceive(this.logResponse);
}
// ...
async get(id: number) {
return this.http
.route(id)
.schema(ProductSchema, 200)
.get<Product>(AxiosSerializer);
}
// ...
}
Alternatively if this is a common configuration, it can be set with sharedOptions
:
// ./app/controllers/product/product.controller.ts
import { HTTP, Fixture } from "@autometa/runner";
@Fixture
@Constructor(HTTP)
export class ProductController {
constructor(private readonly http: HTTP) {
this.http
.url(Env.API_URL)
.sharedRoute("product")
.sharedSchema(ErrorSchema, { from: 400, to: 500 })
.sharedOnSend(this.logRequest)
.sharedOnReceive(this.logResponse)
.sharedOptions(AxiosSerializer);
}
// ...
async get(id: number) {
return this.http
.route(id)
.schema(ProductSchema, 200)
.get<Product>();
}
// ...
}
Using a different HTTP client
By default the HTTP
fixture uses axios, but you can use a client of your choosing
by implementing the HTTPClient
abstract class. The HTTPClient.Use()
decorator
will register the client with the HTTP
fixture.
For example here is the Axios implementation:
export class AxiosClient extends HTTPClient {
async request<TRequestType, TResponseType>(
request: HTTPRequest<TRequestType>,
options: HTTPAdditionalOptions<AxiosRequestConfig>
): Promise<HTTPResponse<TResponseType>> {
const { baseUrl, route, params, headers, method, data } = request;
const url = [baseUrl, ...route].join("/");
const axiosRequest: AxiosRequestConfig = {
url,
params,
headers,
method: method,
data,
validateStatus: function (status) {
return status >= 0 && status < 600;
},
...options
};
const response = await axios(axiosRequest);
return HTTPResponseBuilder.create()
.status(response.status as StatusCode)
.statusText(response.statusText)
.data(response.data)
.headers(response.headers as Record<string, string>)
.request(request)
.build() as HTTPResponse<TResponseType>;
}
}
The options
parameter is an arbitrary object of key:value pairs which can represent
any additional configuration you want to pass to your underlying client, or use for
custom logic.
You can make your custom client the default be decorating your
client or controller classes with HTTPClient.Use()
:
@Fixture
@HTTPClient.Use(MyCustomClient)
@Constructor(HTTP)
export class MyBaseClient {
constructor(private readonly http: HTTP) {
// ...
}
// ...
}