Using proxies for intercepting HTTP calls

by Dennis — 6 minutes

Recently a blog on dev.to I wrote in 2019 became a topic on Twitter. David shared my code snippet about a fetch call being used as a chained JavaScript object. This little gimmick code got a lot of attention on twitter so I decided to make a follow-up on this topic.

Image of the tweet from David Wells

The example shows the elegance of the JavaScript proxy. Instead of duplicating code all over the place, the api.[whatever] automatically makes the right REST request based on the object key. The tweet got some comments with other examples to enhance this proxy. For example, the proxy of Ingvar Stepanyan.

Example of Ingvar Stepanyan

Instead of fetching the given key it returns an instance of the same proxy, so you can chain a full URL.

Even more suggestions came in, like the tweet from v1vendi posting a GitHub Gist that implements a simple CRUD behaviour.

function httpRequest(url, method, data) {
  const init = { method };
  switch (method) {
    case "GET":
      if (data) url = `${url}?${new URLSearchParams(data)}`;
      break;
    case "POST":
    case "PUT":
    case "PATCH":
      init.body = JSON.stringify(data);
  }
  return fetch(url, init);
}

function generateAPI(url) {
  // a hack, so we can use field either as property or a method
  const callable = () => {};
  callable.url = url;

  return new Proxy(callable, {
    get({ url }, propKey) {
      return ["GET", "POST", "PUT", "DELETE", "PATCH"].includes(
        propKey.toUpperCase(),
      )
        ? (data) => httpRequest(url, propKey.toUpperCase(), data)
        : generateAPI(`${url}/${propKey}`);
    },
    apply({ url }, thisArg, [arg] = []) {
      return generateAPI(arg ? `${url}/${arg}` : url);
    },
  });
}

// example usage
const GameAPI = generateAPI("game_api");

GameAPI.get(); // GET /game_api
GameAPI.clans.get(); // GET /game_api/clans
GameAPI.clans(7).get(); // GET /game_api/clans/7
GameAPI.clans(7).whatever.delete(); // DELETE /game_api/clans/7/whatever
GameAPI.clans.put({ whatever: 1 });

// GET game_api/tiles/public/static/3/4/2.json?turn=37038&games=wot
GameAPI.tiles.public.static(3)(4)(`${2}.json`).get({ turn: 37, games: "wot" });

These examples show the power of JavaScript proxies. They enable you to manipulate the getters and setters of an object on a low level. This layer of abstraction can be really useful when you’re writing an extendable plugin or framework. It gives you the flexibility to extend an existing instance to add validation before calling the actual instance. Chaining a proxy to form an URL probably isn't the best use-case, but it is a nice example. To find a more realistic example I was wondering whether proxies are being used in my current codebase.

Using proxies to intercept HTTP requests

A request interceptor for an HTTP call is a function that gets called before the actual request takes place. You can for example validate a user session before sending a request. Fetch does not have a built-in method to do so, as opposed to axios interceptors. Let's implement this functionality using proxies.

We will start by making a generic HTTP client.

export interface FetchResponse<T> extends Response {
  json(): Promise<T>;
  clone(): FetchResponse<T>;
}

export type HttpConfig = Partial<Omit<RequestInit, "method">>;

export type HttpCall = <T>(
  endPoint: string,
  config?: HttpConfig,
) => Promise<FetchResponse<T>>;

export interface HttpClient {
  get: HttpCall;
  post: HttpCall;
  put: HttpCall;
  delete: HttpCall;
}

export function createHttpClient(
  baseURL: string,
  baseConfig?: Partial<Omit<RequestInit, "body">>,
): HttpClient {
  return {
    get(endPoint, config?) {
      return fetch(`${baseURL}${endPoint}`, {
        ...baseConfig,
        ...config,
        method: "GET",
      });
    },
    post(endPoint, config) {
      return fetch(`${baseURL}${endPoint}`, {
        ...baseConfig,
        ...config,
        method: "POST",
      });
    },
    put(endPoint, config) {
      return fetch(`${baseURL}${endPoint}`, {
        ...baseConfig,
        ...config,
        method: "PUT",
      });
    },
    delete(endPoint, config) {
      return fetch(`${baseURL}${endPoint}`, {
        ...baseConfig,
        ...config,
        method: "DELETE",
      });
    },
  };
}

The createHttpClient function adds simple type support and makes a nice CRUD abstraction. We’ll use the Star Wars API to fetch some people.

// swApi.ts
import { createHttpClient } from "~/clients/httpClient";
import type { AllPeopleResponse } from "./types/People";

const httpClient = createHttpClient("https://swapi.dev/api");

export function getAllPeople() {
  return httpClient
    .get<AllPeopleResponse>("/people")
    .then((response) => response.json())
    .then((people) => people.results);
}

export function getPeopleById(id: string) {
  return httpClient
    .get<AllPeopleResponse>(`/people/${id}`)
    .then((response) => response.json());
}

Using the HTTP client, we can retrieve the people data:

const allPeople = await getAllPeople();
const singlePeople = await getPeopleById("1");

To mimic our session validation, we will make an empty function called validateSession that returns a Promise resolving a Boolean, indicating whether your session is valid.

In order to correctly use our API, we have to add this validation check for every function in our Star Wars API.

// swApi.ts
import { createHttpClient } from "~/clients/httpClient";
import type { AllPeopleResponse } from "./types/People";

const httpClient = createHttpClient("https://swapi.dev/api");

export function getAllPeople() {
  const isValidSession = await validateSession();

  if (!isValidSession) {
    throw new Error("User has no session");
  }

  return httpClient
    .get<AllPeopleResponse>("/people")
    .then((response) => response.json())
    .then((people) => people.results);
}

export function getPeopleById(id: string) {
  const isValidSession = await validateSession();

  if (!isValidSession) {
    throw new Error("User has no session");
  }

  return httpClient
    .get<AllPeopleResponse>(`/people/${id}`)
    .then((response) => response.json());
}

As you can see in this example we have more logic spread out in our API layer. This will increase the risk of bugs. Instead, we can use a proxy to intercept the call without modifying the original client.

// withSessionValidation.ts
import { validateSession } from "./validateSession";
import type { HttpClient, HttpCall } from "~/clients/httpClient";

export function withSessionValidation(client: HttpClient): HttpClient {
  const handler: ProxyHandler<HttpClient> = {
    get: (target, prop: keyof HttpClient, receiver): HttpCall => {
      const calledFunction: HttpCall = Reflect.get(target, prop, receiver);

      return async (...args) => {
        const isValidSession = await validateSession();

        if (!isValidSession) {
          throw new Error("User has no session");
        }

        return calledFunction(...args);
      };
    },
  };

  return new Proxy(client, handler);
}

// swApi.ts
import { createHttpClient } from "~/clients/httpClient";
import { withSessionValidation } from "./withSessionValidation";
import type { AllPeopleResponse } from "./types/People";

const httpClient = withSessionValidation(
  createHttpClient("https://swapi.dev/api"),
);

export function getAllPeople() {
  return httpClient
    .get<AllPeopleResponse>("/people")
    .then((response) => response.json())
    .then((people) => people.results);
}

export function getPeopleById(id: string) {
  return httpClient
    .get<AllPeopleResponse>(`/people/${id}`)
    .then((response) => response.json());
}

The proxy intercepts the call and validates the session. If the session validation fails the server will never be called.

This proxy instance of an HTTP client enables you to add custom validation rules before actually calling the server. With this abstraction layer you don’t need to adjust your HTTP client and for that reason you can achieve cleaner code. However be aware of using it in overly complex ways. Most solutions using a proxy can also be achieved another way. Proxies enable you to solve complex problems in an elegant way.

meerdivotion

Cases

Blogs

Event