Skip to main content

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 Fixtures 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();
});
tip

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) {
// ...
}
// ...
}