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
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;
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);
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>;
};
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
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}`,
};
},
// ...
};
}
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;
},
})
);
},
};
}
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()],
});
// ...
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
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;
// ...
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)],
});
// ...
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.
Top comments (0)