DEV Community

Cover image for REST API Consumption with feTS: A Practical Guide to Type-Safe HTTP Clients
Francisco Mendes
Francisco Mendes

Posted on

REST API Consumption with feTS: A Practical Guide to Type-Safe HTTP Clients

Introduction

These days I feel like we're lucky to have the chance to choose the tools we want to work with. I really like GraphQL and in my opinion it is an important piece, even in cases where the backend programming language is different from that of the frontend. And regardless of the context/language, there is always tooling for schema-based codegen.

In cases where you have a monorepo that uses TypeScript, tRPC is undoubtedly a great tool to be used in this context and one of the great advantages is that it is simple and intuitive and I feel that we can be much more productive.

However, we are not always that lucky, because the backend may use a different language and prefer to use REST. In these cases, many people either create an API client with the definition of data types, or use validation schemas to guarantee type-safety at runtime and infer types from them.

Today I'm going to share a solution that in my opinion is very interesting, it's called feTS, it's also an end-to-end solution but today I'm going to teach you how to use it just as an http api client.

Getting Started

We start by running the following command:

npm install fets
Enter fullscreen mode Exit fullscreen mode

In the next step we will need a definition of an OpenAPI. In today's article I will use the {JSON} Placeholder found in this Gist.

After copying the contents of the yaml file, let's open SwaggerEditor and convert the yaml definition to json and in our project we can create the file openapi.ts and assign the json of the OpenAPI specification to a variable called openapi as follows:

export const openapi = {
  // OpenAPI specification...
} as const;
Enter fullscreen mode Exit fullscreen mode

In the main.ts (or other) file we can start by importing the necessary feTS primitives to create our type-safe client together with the openapi we just created. After instantiating the client we can define the API endpoint that we will consume and we will have the client ready and completely type-safe.

import { createClient, type NormalizeOAS } from "fets";

import { openapi } from "./openapi";

const client = createClient<NormalizeOAS<typeof openapi>>({
  endpoint: "https://jsonplaceholder.typicode.com/",
});

const response = await client["/posts/{id}"].get({ params: { id: 3 } });
const data = await response.json();

console.log(data);
Enter fullscreen mode Exit fullscreen mode

Obviously I wouldn't leave the article here because another topic I want to cover is the creation of plugins. Let's assume that our API needs to send the token in the headers so that we can be authorized to consume resources protected by the REST API.

To do this, we will create a plugin called useAuthPlugin which will have the following interfaces:

export type Tokens = {
  accessToken: string;
  refreshToken: string;
};

export type AuthPluginOpts = {
  refreshToken: () => Promise<Tokens>;
  actions: {
    getTokens: () => Tokens;
    setTokens: (tokens: Tokens) => void;
    clearTokens: () => void;
  };
  trace?: (exception: Error | null) => Promise<void>;
};
Enter fullscreen mode Exit fullscreen mode

In the previous block we defined the data structure of the tokens and defined the arguments with the functions that we will inject into the plugin. These functions will be related to local session management and the invocation of token renewal with a reactive strategy.

First, we will install the following dependencies:

npm install fetch-retry
Enter fullscreen mode Exit fullscreen mode

Next, we will move on to defining the behaviors of some of the hooks that are available from the ClientPlugin API. Starting with the onRequestInit hook that executes before the request is made.

// ...
export function useAuthPlugin(opts: AuthPluginOpts): ClientPlugin {
  return {
    onRequestInit({ requestInit }) {
      requestInit.headers = {
        ...requestInit.headers,
        Authorization: `Bearer ${opts.actions.getTokens().accessToken}`,
      };
    },
    // ...
  };
}
Enter fullscreen mode Exit fullscreen mode

In the code block above what was done was to inject the access token into the Authorization header before executing the http request.

Next, we will take advantage of the onFetch hook, which will allow us to customize some behaviors of the Fetch API, such as adding retry strategies if a request does not go as desired.

To do this, we will take advantage of the fetch-retry package that we installed a while ago and we will define an exponential retry to take some back-pressure from the backend and we will define our retry strategy with the retryOn method.

Ideally, if an error occurs, we should trace it, so this would be our first condition. Next we will define the number of times we can retry an http request and in this case I defined that the maximum would be twice.

Coming to the most important point of the plugin, if the response status code is 401 we will make a request to renew the tokens and if they are not returned we will invoke the function to clean up the local session and we will not retry the request. Otherwise, we store the tokens locally and try the http request again and this time the new access token will be injected into the Authorization header.

// ...
export function useAuthPlugin(opts: AuthPluginOpts): ClientPlugin {
  return {
    // ...
    onFetch({ fetchFn, setFetchFn }) {
      setFetchFn(
        fetchRetry(fetchFn as any, {
          retryDelay: (attempt) => Math.pow(2, attempt) * 1200,
          retryOn: async (attempt, exception, response) => {
            if (exception !== null) {
              await opts.trace?.(exception);
            }

            if (attempt > 2) return false;

            if (response?.status === 401) {
              const tokens = await opts.refreshToken();

              if (!tokens?.accessToken) {
                opts.actions.clearTokens();
                return false;
              }

              opts.actions.setTokens(tokens);
              return true;
            }

            return false;
          },
        })
      );
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Returning to the file where the http client was created, we import the newly created plugin and pass it to the plugins array.

// ...
import { useAuthPlugin, type AuthPluginOpts } from "./plugins";

const client = createClient<NormalizeOAS<typeof openapi>>({
  endpoint: "https://jsonplaceholder.typicode.com/",
  plugins: [useAuthPlugin()],
});

// ...
Enter fullscreen mode Exit fullscreen mode

We are only missing one thing, which is the definition of the object that contains the functions that are invoked within the plugin that are specified in the AuthPluginOpts interface.

First, let's install the following dependency:

npm install moize ms
Enter fullscreen mode Exit fullscreen mode

And let's move on to the object definition starting with the function, refreshToken. In this function we will take advantage of the moize library to memoize the promise for about seven seconds, to ensure that we do not have "race conditions" in cases where several http requests renew the tokens. It could be like this:

import moize from "moize";
import ms from "ms";

// ...

const opts = {
  refreshToken: moize.promise(
    async () => {
      const { accessToken, refreshToken } = await someRefreshTokenApiCall();
      return { accessToken, refreshToken };
    },
    { maxAge: ms("7 seconds") }
  ),
  // ...
} satisfies AuthPluginOpts;

// ...
Enter fullscreen mode Exit fullscreen mode

Last but not least, we can define the actions that have the function of obtaining, saving and removing the local session and passing it in the plugin arguments.

// ...

const opts = {
  // ...
  actions: {
    getTokens() {
      return {
        accessToken: localStorage.getItem("accessToken") || "",
        refreshToken: localStorage.getItem("refreshToken") || "",
      };
    },
    setTokens(tokens) {
      localStorage.setItem("accessToken", tokens.accessToken);
      localStorage.setItem("refreshToken", tokens.refreshToken);
    },
    clearTokens() {
      localStorage.removeItem("accessToken");
      localStorage.removeItem("refreshToken");
    },
  },
} satisfies AuthPluginOpts;

const client = createClient<NormalizeOAS<typeof openapi>>({
  // ...
  plugins: [useAuthPlugin(opts)],
});

// ...
Enter fullscreen mode Exit fullscreen mode

Conclusion

I hope you found this article helpful, whether you're using the information in an existing project or just giving it a try for fun.

Please let me know if you notice any mistakes in the article by leaving a comment. And, if you'd like to see the source code for this article, you can find it on the github repository linked below.

Github Repo

Top comments (0)