Challenge #11: Output Tracking

The nullability work for HttpClient is complete. One feature remains to make it fully testable: Output Tracking. This is technically unrelated to the nullability work, but it’s usually implemented at the same time.

Instructions

1. Implement the "tracks requests" test:

  1. Modify the transformAsync() helper to call httpClient.trackRequests() and return its result in the { requests } variable.
  2. Make the following request:
    await requestAsync({
    	host: HOST,
    	port: PORT,
    	method: "POST",
    	headers: { myHeader: "myValue" },
    	path: "/my/path",
    	body: "my body",
    });
  3. Assert that requests.data contains the following array of objects:
    [{
    	host: HOST,
    	port: PORT,
    	method: "POST",
    	headers: { myHeader: "myValue" },
    	path: "/my/path",
    	body: "my body",
    }]

2. Implement httpClient.trackRequests():

  1. Initialize an OutputListener in the constructor.
  2. Return an OutputTracker from httpClient.trackRequests().
  3. Track the request in httpClient.requestAsync() by emitting an event to the listener.

Remember to commit your changes when you’re done.

API Documentation

const listener = OutputListener.create();

Create an OutputListener, a utility class for implementing the “Output Tracking” pattern. Use it like this:

  1. Instantiate the OutputListener in your class’s constructor.
  2. Provide a trackXxx() method for consumers of your class to call.
  3. In your trackXxx() method, return listener.trackOutput(). That will create an OutputTracker that your class’s consumers can use to retrieve the tracked output.
  4. Track output by calling listener.emit().
  • returns listener (object) - the listener
listener.emit(data);

Inform the OutputListener of some data to be tracked. The listener will copy the data to every OutputTracker created from this listener. Does nothing if there are no OutputTrackers.

  • data (any): the data to be tracked
const outputTracker = listener.trackOutput();

Create an OutputTracker that records data that’s emitted to the listener. Each tracker is independent.

  • returns outputTracker (object) - the tracker
const output = outputTracker.data;

Returns an array with all data stored in the output tracker. Use it like this:

const requests = httpClient.trackRequests();
// run code that makes requests
const output = httpRequests.data;
  • returns output (array): the data

Relevant TypeScript Types

httpRequests.data: HttpClientOutput[]

The requests stored in the output tracker.

interface HttpClientOutput extends HttpClientRequestParameters {
	cancelled?: boolean,    // not relevant to this challenge
}

interface HttpClientRequestParameters {
	host: string,
	port: number,
	method: string,
	path: string,
	headers?: HttpHeaders,
	body?: string,
}

JavaScript Primers

No new concepts.

Hints

Implement the "tracks requests" test:

1
You’ll need your transformAsync() test helper to support tracking requests.
Call httpClient.trackRequests() in the helper.
You have to call trackRequests() before any requests are made.
trackRequests() isn’t implemented, so don’t be surprised if this causes all your tests to fail.
async function requestAsync({
	client = HttpClient.create(),
	host = HOST,
	port = PORT,
	method = "GET",
	path = "/irrelevant/path",
	headers = undefined,
	body = undefined,
} = {}) {
	const requests = client.trackRequests();
	const response = await client.requestAsync({ host, port, method, path, headers, body });
	return { response, requests };
}
2
You’re ready to write the test.
Call the requestAsync() test helper and store the requests in a variable. Don’t forget to await it.
Assert that the requests are correct.
You can get the requests with rot13Requests.data and you can assert on them with assert.deepEqual().
it("tracks requests", async () => {
	const { requests } = await requestAsync({
		host: HOST,
		port: PORT,
		method: "POST",
		headers: { myHeader: "myValue" },
		path: "/my/path",
		body: "my body",
	});

	assert.deepEqual(requests.data, [{
		host: HOST,
		port: PORT,
		method: "POST",
		headers: { myHeader: "myValue" },
		path: "/my/path",
		body: "my body",
	}]);
});
3
The test is ready to run.
It should fail with a "not implemented" exception. So will all the other tests.

That’s because httpClient.trackRequests() isn’t implemented.

Implement the production code:

4
httpClient.trackRequests() is supposed to return an OutputTracker. In order to do that, you need an OutputListener.
You can create an OutputListener with OutputListener.create().
It needs to be an instance variable, so create it in the constructor.
constructor(http) {
	this._http = http;
	this._listener = OutputListener.create();
}

In TypeScript, you’ll need to declare the type of the _listener instance variable:

export class HttpClient {

	_listener: OutputListener<HttpClientOutput>;

	// ...

	constructor(private readonly _http: NodeHttp) {
		this._listener = OutputListener.create();
	}

}
5
Now you can implement httpClient.trackRequests().
You need to return an OutputTracker.
You can create an OutputTracker by calling this._listener.trackOutput().
trackRequests() {
	return this._listener.trackOutput();
}
6
You’re ready to run the test again.
It should fail, saying that no requests were tracked (it got an empty array).

That’s because your production code isn’t telling the listener when requests occur.

7
Emit the tracking data when a request is made in your production code.
The request is made in httpClient.#sendRequest().
You can emit the event with this._listener.emit().
#sendRequest(host, port, method, path, headers, body) {
	const clientRequest = this._http.request({
		host: host,
		port: port,
		method: method,
		path: path,
		headers: headers,
	});
	clientRequest.end(body);

	this._listener.emit({ host, port, method, path, headers, body });

	return clientRequest;
}

Complete Solution

Test code:
it("tracks requests", async () => {
	const { requests } = await requestAsync({
		host: HOST,
		port: PORT,
		method: "POST",
		headers: { myHeader: "myValue" },
		path: "/my/path",
		body: "my body",
	});

	assert.deepEqual(requests.data, [{
		host: HOST,
		port: PORT,
		method: "POST",
		headers: { myHeader: "myValue" },
		path: "/my/path",
		body: "my body",
	}]);
});

// ...

async function requestAsync({
	client = HttpClient.create(),
	host = HOST,
	port = PORT,
	method = "GET",
	path = "/irrelevant/path",
	headers = undefined,
	body = undefined,
} = {}) {
	const requests = client.trackRequests();
	const response = await client.requestAsync({ host, port, method, path, headers, body });
	return { response, requests };
}
Production code (JavaScript):
export class HttpClient {

	static create() {
		return new HttpClient(http);
	}

	static createNull(responses) {
		return new HttpClient(new StubbedHttp(responses));
	}

	constructor(http) {
		this._http = http;
		this._listener = OutputListener.create();
	}

	trackRequests() {
		return this._listener.trackOutput();
	}

	async requestAsync({ host, port, method, path, headers = {}, body = "" }) {
		if (method.toLowerCase() === "get" && body !== "") {
			throw new Error("Don't include body with GET requests; Node won't send it");
		}

		const clientRequest = this.#sendRequest(host, port, method, path, headers, body);
		return await this.#handleResponseAsync(clientRequest);
	}

	#sendRequest(host, port, method, path, headers, body) {
		const clientRequest = this._http.request({
			host: host,
			port: port,
			method: method,
			path: path,
			headers: headers,
		});
		clientRequest.end(body);

		this._listener.emit({ host, port, method, path, headers, body });

		return clientRequest;
	}

	async #handleResponseAsync(clientRequest) {
		return await new Promise((resolve, reject) => {
			clientRequest.on("error", (err) => reject(err));

			clientRequest.on("response", (clientResponse) => {
				let body = "";
				clientResponse.on("data", (chunk) => {
					body += chunk;
				});

				clientResponse.on("end", () => {
					resolve({
						status: clientResponse.statusCode,
						headers: clientResponse.headers,
						body,
					});
				});
			});
		});
	}

}


class StubbedHttp {

	constructor(responses = {}) {
		this._responses = responses;
	}

	request({ path }) {
		return new StubbedRequest(this._responses[path]);
	}

}

class StubbedRequest extends EventEmitter {

	constructor(responses) {
		super();
		this._responses = responses;
	}

	end() {
		const response = nextResponse(this._responses);
		setImmediate(() => {
			this.emit("response", new StubbedResponse(response));
		});
	}

}

function nextResponse(responses) {
	if (Array.isArray(responses)) {
		if (responses.length === 0) throw new Error("No more responses configured in Nulled HTTP client");
		return responses.shift();
	}
	else {
		return responses;
	}
}

class StubbedResponse extends EventEmitter {

	constructor({
		status = 501,
		headers = {},
		body = "",
	} = DEFAULT_NULLED_RESPONSE) {
		super();

		this.statusCode = status;
		this.headers = normalizeHeaders(headers);

		setImmediate(() => {
			this.emit("data", body);
			this.emit("end");
		});
	}

}

function normalizeHeaders(headers) {
	const originalEntries = Object.entries(headers);
	const transformedEntries = originalEntries.map(([ key, value ]) => [ key.toLowerCase(), value ]);
	return Object.fromEntries(transformedEntries);
}
Production code (TypeScript):
interface NodeHttp {
	request: ({ host, port, method, path, headers }: {
		host: string,
		port: number,
		method: string,
		path: string,
		headers: HttpHeaders,
	}) => NodeHttpRequest,
}

interface NodeHttpRequest extends EventEmitter {
	end: (body?: string) => void,
}

interface NodeHttpResponse extends EventEmitter {
	statusCode: number,
	headers: HttpHeaders,
}

export class HttpClient {

	_listener: OutputListener<HttpClientOutput>;

	static create(): HttpClient {
		return new HttpClient(http);
	}

	static createNull(responses?: NulledHttpClientResponses): HttpClient {
		return new HttpClient(new StubbedHttp(responses));
	}

	constructor(private readonly _http: NodeHttp) {
		this._listener = OutputListener.create();
	}

	trackRequests(): OutputTracker<HttpClientRequestParameters> {
		return this._listener.trackOutput();
	}

	async requestAsync({
		host,
		port,
		method,
		path,
		headers = {},
		body = ""
	}: HttpClientRequestParameters): Promise<HttpClientResponse> {
		if (method.toLowerCase() === "get" && body !== "") {
			throw new Error("Don't include body with GET requests; Node won't send it");
		}

		const clientRequest = this.#sendRequest(host, port, method, path, headers, body);
		return await this.#handleResponseAsync(clientRequest);
	}

	#sendRequest(
		host: string,
		port: number,
		method: string,
		path: string,
		headers: HttpHeaders,
		body: string
	): NodeHttpRequest {
		const clientRequest = this._http.request({
			host: host,
			port: port,
			method: method,
			path: path,
			headers: headers,
		});
		clientRequest.end(body);

		this._listener.emit({ host, port, method, path, headers, body });

		return clientRequest;
	}

	async #handleResponseAsync(clientRequest: NodeHttpRequest): Promise<HttpClientResponse> {
		return await new Promise((resolve, reject) => {
			clientRequest.on("error", (err: Error) => reject(err));

			clientRequest.on("response", (clientResponse: NodeHttpResponse) => {
				let body = "";
				clientResponse.on("data", (chunk: Buffer) => {
					body += chunk;
				});

				clientResponse.on("end", () => {
					resolve({
						status: clientResponse.statusCode as number,
						headers: clientResponse.headers as HttpHeaders,
						body,
					});
				});
			});
		});
	}

	/* Placeholder to satisfy TypeScript compiler */
	request(options: any): { responsePromise: Promise<HttpClientResponse>, cancelFn: (message: string) => boolean } {
		throw new Error("not implemented");
	}
}


class StubbedHttp {

	constructor(private readonly _responses: NulledHttpClientResponses = {}) {
	}

	request({ path }: { path: string }) {
		return new StubbedRequest(this._responses[path]);
	}

}

class StubbedRequest extends EventEmitter {

	constructor(private readonly _responses?: NulledHttpClientResponse | NulledHttpClientResponse[]) {
		super();
	}

	end() {
		const response = nextResponse(this._responses);
		setImmediate(() => {
			this.emit("response", new StubbedResponse(response));
		});
	}

}

function nextResponse(responses?: NulledHttpClientResponse | NulledHttpClientResponse[]) {
	if (Array.isArray(responses)) {
		if (responses.length === 0) throw new Error("No more responses configured in Nulled HTTP client");
		return responses.shift();
	}
	else {
		return responses;
	}
}

class StubbedResponse extends EventEmitter implements NodeHttpResponse {

	statusCode: number;
	headers: HttpHeaders;

	constructor({
			status = 501,
			headers = {},
			body = "",
	}: NulledHttpClientResponse = DEFAULT_NULLED_RESPONSE) {
		super();

		this.statusCode = status;
		this.headers = normalizeHeaders(headers);

		setImmediate(() => {
			this.emit("data", body);
			this.emit("end");
		});
	}

}

function normalizeHeaders(headers: HttpHeaders) {
	const originalEntries = Object.entries(headers);
	const transformedEntries = originalEntries.map(([ key, value ]) => [ key.toLowerCase(), value ]);
	return Object.fromEntries(transformedEntries);
}

Bonus challenges

Return to module overview