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:
- Modify the
transformAsync()
helper to callhttpClient.trackRequests()
and return its result in the{ requests }
variable. - Make the following request:
await requestAsync({ host: HOST, port: PORT, method: "POST", headers: { myHeader: "myValue" }, path: "/my/path", body: "my body", });
- 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()
:
- Initialize an
OutputListener
in the constructor. - Return an
OutputTracker
fromhttpClient.trackRequests()
. - 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:
- Instantiate the
OutputListener
in your class’s constructor. - Provide a
trackXxx()
method for consumers of your class to call. - In your
trackXxx()
method, returnlistener.trackOutput()
. That will create anOutputTracker
that your class’s consumers can use to retrieve the tracked output. - 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 OutputTracker
s.
- 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);
}