Code Review Videos > Broken Link Checker > TypeScript > Starting With The Fetch Wrapper

Starting With The Fetch Wrapper

After finishing the proof of concept I had the idea in mind that I would like to start by extracting the fetch process. By the end of the exercise I had the following code (with garbage comments removed):

let previousController: AbortController | undefined = undefined;

const fetcher = async (href: string) => {
  const currentController = new AbortController();

  if (previousController) {
    previousController.abort();
  }

  previousController = currentController;

  const { url, status, statusText, ok, headers } = await fetch(href, {
    method: "head",
    redirect: "manual",
    signal: currentController.signal,
  });

  if (ok) {
    currentController.abort();
    previousController = undefined;
  }

  const location = headers.get("location");
  const redirected = null !== location;

  let nextLocation = undefined;
  try {
    if (redirected) {
      const hrefToUrl = new URL(location);
    }
  } catch (e) {}

  return { url, status, statusText, ok, headers };
};Code language: TypeScript (typescript)

It worked to the point that the proof of concept did fetch links. But blurghh, that’s horrible.

As part of the proof of concept I have learned a few things about this code:

  1. HEAD won’t behave the way I expect, I have to use GET or manual redirects don’t always work.
  2. The AbortController idea is needed, but that implementation above isn’t right.

Being comfortable with Jest, mocking, and fetch generally, I’m going to start with the idea that I will spy on calls to fetch, and iterate from there.

import { fetcher } from "./fetcher";

const fakeUrl = "http://some.fake.url";

describe("fetcher", () => {
  afterEach(() => {
    jest.resetAllMocks();
  });

  test("should call the native fetch function in the expected manner", async () => {

    const fetchSpy = jest.spyOn(global, "fetch").mockResolvedValueOnce({
      url: fakeUrl,
      status: 200,
      statusText: "something",
      ok: true,
    } as unknown as Response);

    const result = await fetcher(fakeUrl);

    expect(fetchSpy).toHaveBeenCalledTimes(1);
    expect(fetchSpy).toHaveBeenCalledWith(fakeUrl, {
      redirect: "manual",
    });

    expect(result).toEqual({
      ok: true,
      status: 200,
      statusText: "something",
      url: "http://some.fake.url",
    });
  });
});Code language: JavaScript (javascript)

Code to make this test pass:

export const fetcher = async (href: string) => {

  const { url, status, statusText, ok } = await fetch(href, {
    redirect: "manual",
  });

  return { url, status, statusText, ok };
};Code language: TypeScript (typescript)

So really, at this stage, nothing more than a restrictive wrapper around the native fetch function.

Worrying About Headers

This does miss out the headers from the response, and as we know we will want them for being able to probe into, and examine the full request flow.

There is a gotcha in here though. I can destructure the headers out of the return from fetch, but the type of the headers object is not a primitive:

  • url = string
  • status = number
  • statusText = string
  • ok = boolean

headers, however, is of type Headers. More on that on the MDN docs.

If we hadn’t spotted this yet, we would do when we came to factor this in to our unit tests:

  test("should call the native fetch function in the expected manner", async () => {
    const fetchSpy = jest.spyOn(global, "fetch").mockResolvedValueOnce({
      url: fakeUrl,
      status: 200,
      statusText: "something",
      ok: true,
      headers: ???
    } as unknown as Response);

    ...
  });
Code language: TypeScript (typescript)

We need to mock the value that headers would actually be, and that flags up that hey, we would be using this extra concept of some object that conforms to a Headers interface here.

Adding A Return Type Definition

Given that this is our wrapper around fetch, I’m going to use this opportunity to define a new TypeScript type to enforce the values that is returned by this fetcher wrapper function.

// types.d.ts

export type FetcherResponse = {
  url: string;
  status: number;
  statusText: string;
  ok: boolean;
  headers: Record<string, string>;
} Code language: JavaScript (javascript)

The Record type allows me to define a key value pairing, where I have said both the key and value will be of type string. You can get more advanced with this, but for quickly providing an inline description of a JavaScript object that will have string keys, and string values, this will suffice.

OK, so new problem.

Our FetcherResponse expects us to return headers in the format Record<string, string>.

But we directly return headers from the fetch call, which we know would be an implementation of the Headers interface.

Some kind of conversion needs to take place in our fetcher function.

An initial exploration of the headers object does make it appear that it has standard object-like behaviour.

In fact, it looks a lot like a Map:

But anyway. Until I know better, I am going to go with a conversion of the headers object to a plain old JavaScript object:

export const fetcher = async (href: string): Promise<FetcherResponse> => {
  const { url, status, statusText, ok, headers } = await fetch(href, {
    redirect: "manual",
  });

  const headersObject = Object.fromEntries(headers);

  return { url, status, statusText, ok, headers: headersObject };
};Code language: TypeScript (typescript)

Note the fix in the code above. It now correctly returns Promise<FetcherResponse>, whereas the screenshots above do not include the Promise wrapper.

Refactoring The Test Code

And so the test code becomes:

  test("should call the native fetch function in the expected manner", async () => {
    const headers = new Headers();

    const fetchSpy = jest.spyOn(global, "fetch").mockResolvedValueOnce({
      url: fakeUrl,
      status: 200,
      statusText: "something",
      ok: true,
      headers,
    } as unknown as Response);

    const result = await fetcher(fakeUrl);

    expect(fetchSpy).toHaveBeenCalledTimes(1);
    expect(fetchSpy).toHaveBeenCalledWith(fakeUrl, {
      redirect: "manual",
    });

    expect(result).toEqual({
      ok: true,
      status: 200,
      statusText: "something",
      url: "http://some.fake.url",
      headers: {},
    });
  });
Code language: TypeScript (typescript)

I’m pretty happy with that.

But equally I’d love to confirm that headers continues to behave as expected when there are some key / values returned.

With two test cases for the same test, I would probably go with this in the real world:

describe("fetcher", () => {
  afterEach(() => {
    jest.resetAllMocks();
  });

  [
    {
      description: "empty headers",
      givenHeaders: new Headers(),
      expectedHeaders: {},
    },
    {
      description: "with headers",
      givenHeaders: new Headers({ some: "data" }),
      expectedHeaders: { some: "data" },
    },
  ].forEach(({ description, givenHeaders, expectedHeaders }) => {
    test(`should call the native fetch function in the expected manner: ${description}`, async () => {
      const fetchSpy = jest.spyOn(global, "fetch").mockResolvedValueOnce({
        url: fakeUrl,
        status: 200,
        statusText: "something",
        ok: true,
        headers: givenHeaders,
      } as unknown as Response);

      const result = await fetcher(fakeUrl);

      expect(fetchSpy).toHaveBeenCalledTimes(1);
      expect(fetchSpy).toHaveBeenCalledWith(fakeUrl, {
        redirect: "manual",
      });

      expect(result).toEqual({
        ok: true,
        status: 200,
        statusText: "something",
        url: "http://some.fake.url",
        headers: expectedHeaders,
      });
    });
  });
});Code language: TypeScript (typescript)

This feels good enough for a first pass. Certainly enough to get us moving and looking at what to tackle next.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.