Skip to main content

HTTP Client

One of the included fixtures provided is the HTTP client. Unlike most fixtures which are which behave like singletons within the context of a Scenario, the HTTP client is "transient", and each time it is injected a new copy is created.

It is accessed as a normal fixture, by defining it as a constructor parameter for whatever fixture or App is consuming it.

@Fixture
@Constructor(HTTP)
export class MyClient {
constructor(private http: HTTP) {}
}

The client is unusual in that it behaves as a 2-part builder patten. Certain information, such as the API URL, routes or common headers can be stored statefully within the client itself

@Fixture
@Constructor(HTTP)
export class MyClient {
constructor(private http: Http) {
this.http
.url("https://api.example.com")
.sharedRoute("v2")
.sharedHeader("x-example", "true");
}
}

However when a non shared header or route is set, it creates a new client with a new request context, which will inherit values set in the original client, but modifcations happen in this new contex and do not mutate the shared client.

@Fixture
@Constructor(HTTP)
export class MyClient {
constructor(private http: Http) {
this.http
.url("https://api.example.com")
.sharedRoute("v2")
.sharedHeader("x-example", "true");
}

getProduct(id: number) {
return this.http.route("products").route(id).get();
}

getProducts(limit?: number) {
return (
this.http
.route("products") // creates a new client with the route set to /v2/products
// resolves to /v2/products?limit=10
.param("limit", limit) // creates a new client with the param set to 10
.get() // makes the request and returns the response using all data set thus far
);
}
}

Hooks

The http client has two hooks:

  • onSend which is triggered when the request has been constructed but no request has been made.
  • onRecieve which executes after the response has been received, if axios did not throw an error.

onBeforeSend receieves a copy of the current request immediately before it is sent. It can be used to log the request, or run assertions against it.

export type HTTPRequest<T> = {
url: string;
method: Method;
params?: Record<string, string>;
fullUrl: string;
headers: Record<string, string>;
data?: T;
};

fullUrl is the full resolved url of the request including parameters and routes, i.e. https://api.example.com/v2/products/1?limit=10

onRecievedResponse receives the response object, and the request state. It can be used to run assertions against a response or again for logging.

export class HTTPResponse<T> {
status: number;
statusText: string;
data: T;
headers: Record<string, string>;
request: {
url: string;
method: Method;
};
}

Schemas

Schemas are function or validator objects which are mapped to a response status code. A Schema validates the data payload of the response, and throws an error if the response does not match the required shape or other validation.

import { z } from "zod";
const ResponseSchema = z.object({
id: z.number(),
name: z.string()
});

@Fixture
export class MyClient {
constructor(private http: Http) {
this.http
.url("https://api.example.com")
.sharedRoute("v2")
.sharedOnBeforeHook(this.logRequest)
.sharedOnRecievedResponseHook(this.logResponse);
}

logRequest({ method, url }: RequestState) {
console.log(`making ${method} request to ${url}`);
}

logResponse(response: HTTPResponse<unknown>) {
console.log(`recieved ${response.status} response from ${method} ${url}`);
}
}

Schema Validation

The client accepts a schema map, which is a mapping of a schema parsing object to a HTTP status code or list of status codes. A schema is registered as an object with a parse method, which accepts the response data and returns a parsed object, or throws an error.

Example using zod:

import { z } from "zod";
const ResponseSchema = z.object({
id: z.number(),
name: z.string()
});

@Fixture
export class MyClient {
constructor(private http: Http) {
this.http
.url("https://api.example.com")
.sharedRoute("v2")
.sharedHeader("x-example", "true");
}

getProduct(id: number) {
return (
this.http
.route("products")
// single value
.schema(ResponseSchema, 200)
// list
.schema(ResponseSchema, 200, 201, 204)
// range
.schema(ResponseSchema, { from: 200, to: 204 })
.route(id)
.get()
);
}
}

Default Schemas

A number of default schemas are provided for cases where the response is not JSON, or the response is empty.

  • AnySchema
    • Accepts any response and returns it as is
  • EmptySchema
    • Accepts null, undefined or the string 'null' and returns null or undefined.
  • NullSchema
    • Accepts null or the string 'null' and returns null.
  • UndefinedSchema
    • Accepts undefined and returns undefined.
  • BooleanSchema
    • Accepts true, false or the strings 'true' or 'false' and returns a boolean of the same value.
  • NumberSchema
    • Accepts any number or string which can be parsed as a number and returns a number.
  • StringSchema
    • Accepts any string and returns it as is.
  • JSONSchema
    • Accepts any JSON string and returns the parsed JSON object, or accepts a JSON objet and returns it as is.

Decomposed Responses

Responses can be 'decomposed' from other responses. With this, the status, and header information remains the same but a new response object is produced, using a value taken from the original response as it's data.

Imagine you have steps to validate a product stored in the world:

import { Then, AssertKey } from "@autometa/runner";
// Then the product name is 'bob'
// Then the product price is 10
Then("the product {string} is {primitive}", (key, value, { world }) => {
AssertKey(world.myProduct, key);
const product = world.myProduct;
expect(product[key]).toEqual(value);
});

And later you're implementing the products endpoint which gets all products as an object with a paginated list:

{
products: Product[];
pagination: {
total: number;
limit: number;
offset: number;
}
}

It would be nice to easily reuse our existing steps to validate the product, by making a new step to add it as myProduct, which is a HTTPResponse<Produxt>

Then("I examine product {int}", (index, { world }) => {
const response = world.productsResponse;
// const data = response.data.products[index];
world.myProduct = HTTPResponse.decompose<Product>(
response,
(products) => products[index]
); // now HTTPResponse<Product>
});

Which lets you reuse your existing step.

Scenario: some scenario
...
...
Then I examine product 1
* the product name is 'bob'
* the product price is 10
And I examine product 2
* the product name is 'alice'
* the product price is 20

Additional options

Your underlying HTTPClient implementation might not handle Certain things the way you want, like how it parses param queries.

We can set additional options on the client to change this behaviour. As the default client uses Axios, we can set the paramsSerializer option using the qs query string library

// utils.ts
import qs from "qs";
export const AxiosSerializer: AxiosRequestConfig = {
paramsSerializer: (params) => {
return qs.stringify(params, { arrayFormat: "comma" });
},
};

// my-client.ts

@Fixture
export class MyClient {
constructor(private http: Http) {
this.http
.url("https://api.example.com")
.sharedRoute("v2")
.sharedHeader("x-example", "true")
.sharedOptions(AxiosSerializer);
}
}