DEV Community

Cover image for How to write the right API client in TypeScript
Matvey Romanov
Matvey Romanov

Posted on

How to write the right API client in TypeScript

In this article, I will talk in detail about the implementation of the client API in TypeScript for working with both third-party APIs and my own. The client can work with public and protected endpoints and is not tied to a specific framework, which makes it suitable for use in React, Vue, Svelte and other frameworks.

Creating an application is more complicated than a ToDo list, most often we need to interact with some data stored on the server. These can be weather forecasts processed by a third-party API, as well as our customers' data, be it their login and password or a shopping list in the store. Working with a SPA (Single Page Application) application, we need to receive, modify and send this very data from the client side. Therefore, you need to have some kind of layer responsible for interacting with the server. In this article, we will consider using the API client with the React library, although it can be safely used on the same Vue, Svelte, and so on.

Why not register all queries in the components where they are used?

It's simple: if you change the API interface you are working with, you will have to go through all the code and find all the points of change that it affected. You can try to put this logic into React hooks, since we are talking about it now, but this solution will not be able to be used in other projects with other frameworks.

TypeScript implementation

To begin with, we will put the domains where the API is located in a kind of config that works with the .env file:

REACT_APP_API_BASE_URL="http://localhost:8083"
Enter fullscreen mode Exit fullscreen mode
export default {
    get apiBaseUrl(): string {
        return process.env.REACT_APP_API_BASE_URL || "";
    },
}
Enter fullscreen mode Exit fullscreen mode

Then we will write an abstract client itself, not tied to this domain. It will require the axios and axios-extensions libraries to work.

Client Code:

import axios, {AxiosInstance, AxiosRequestConfig} from "axios";
import {
    Forbidden,
    HttpError,
    Unauthorized
} from '../errors';
import {Headers} from "../types";

export class ApiClient {
    constructor(
        private readonly baseUrl: string,
        private readonly headers: Headers,
        private readonly authToken: string = ""
    ) {}

    public async get(endpoint: string = "", params?: any, signal?: AbortSignal): Promise<any> {
        try {
            const client = this.createClient(params);
            const response = await client.get(endpoint, { signal });
            return response.data;
        } catch (error: any) {
            this.handleError(error);
        }
    }

    public async post(endpoint: string = "", data?: any, signal?: AbortSignal): Promise<any> {
        try {
            const client = this.createClient();
            const response = await client.post(endpoint, data, { signal });
            return response.data;
        } catch (error) {
            this.handleError(error);
        }
    }

    public async uploadFile(endpoint: string = "", formData: FormData): Promise<any> {
        try {
            const client = this.createClient();
            const response = await client.post(endpoint, formData, {
                headers: {
                    "Content-Type": "multipart/form-data",
                }
            })
            return response.data;
        } catch (error) {
            this.handleError(error);
        }
    }

    private createClient(params: object = {}): AxiosInstance {
        const config: AxiosRequestConfig = {
            baseURL: this.baseUrl,
            headers: this.headers,
            params: params
        }
        if (this.authToken) {
            config.headers = {
                Authorization: `Bearer ${this.authToken}`,
            }
        }
        return axios.create(config);
    }

    private handleError(error: any): never {
        if (!error.response) {
            throw new HttpError(error.message)
        } else if (error.response.status === 401) {
            throw new Unauthorized(error.response.data);
        } else if (error.response.status === 403) {
            throw new Forbidden(error.response.data);
        } else {
            throw error
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The client uses custom types such as Headers, which, in fact, is just a dictionary [key: string]: string, and various errors that inherit the global Error class (Unauthorized, Forbidden, HTTPError), so that in the future it will be easier to understand what caused them.

The class has only three public methods, which generate an axios client every time they are used. This client can work with both public API endpoints and protected ones by adding a header with a Bearer token. How the client receives this very token will be discussed later. Both the get and post methods use the optional abort Signal parameter, which allows you to interrupt the sending of the request depending on the user's actions.

In the case of sending any files to the server, the client uses the uploadFile() method, sending a request to the server with the Content-Type: multipart/form-data header.

To encapsulate the logic of creating these clients, we will write a factory.

Factory Code:

import {Headers} from "../../types";
import {ApiClient} from "../../clients";

export class ApiClientFactory {
    constructor(
        private readonly baseUrl: string,
        private readonly headers: Headers = {}
    ) {}

    public createClient(): ApiClient {
        return new ApiClient(this.baseUrl, this.headers);
    }

    public createAuthorizedClient(authToken: string): ApiClient {
        return new ApiClient(this.baseUrl, this.headers, authToken);
    }
}
Enter fullscreen mode Exit fullscreen mode

It doesn't do anything complicated: it just creates either a regular client or an authorized one, passing a token to the constructor.

Specific implementation

Now we need to adapt this abstract client to some specific endpoint. For example, let's create a manager that receives the latest status of the user profile from the server:

import {ApiClientInterface} from "./clients";
import {Profile} from "./models";

export class ProfileManager {
    constructor(private readonly apiClient: ApiClientInterface) {}

    public async get(): Promise<Profile> {
        return this.apiClient.get("");
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, we don't care about the model we use for the profile. Let's just assume that it is compatible with the value transmitted from the server.

The manager class itself uses composition and stores the client object in its state in order to forward all API requests to it, and if necessary, it can add some logic of its own to the received value (perform validation, create its own endpoint, and so on).

Most often, APIs group domain logic by adding a specific prefix to their endpoints. There are also cases of API migration from one version to a newer one. To provide for all this, we will create a factory for this particular manager.

Factory Code:

import {ApiClientFactory} from "./clients";
import {Headers} from "../types";
import {ProfileManager} from "../ProfileManager";

export class ProfileManagerFactory {
    private readonly apiClientFactory: ApiClientFactory;

    constructor(baseUrl: string, headers: Headers) {
        this.apiClientFactory = new ApiClientFactory(
            `${baseUrl}/api/v1/profile`,
            headers
        );
    }

    public createProfileManager(authToken: string): ProfileManager {
        return new ProfileManager(
            this.apiClientFactory.createAuthorizedClient(authToken)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

When creating this factory, the domain URL and headers for the request are passed to the constructor. Then these parameters are passed to the client API factory constructor, adding the API version and the same prefix denoting part of the domain logic after the passed URL. When creating a user profile manager, authorization is required, so a token is passed to the method, on the basis of which a client with an authorization header is created.

Dependency injection

Now all that remains is to write a function that will be responsible for providing a working profile manager in any part of the code, be it a React component or an independent TypeScript class. It will look something like this:

export async function createProfileManager(): Promise<apiClient.ProfileManager> {
    const factory = new apiClient.ProfileManagerFactory(apiClientConfig.apiBaseUrl, getBaseHeaders());
    return factory.createProfileManager(await getAuthToken());
}
Enter fullscreen mode Exit fullscreen mode

First, a factory of these managers is created inside, to which the server domain and the base headers are transferred, which look like this:

function getBaseHeaders(): apiClient.Headers {
    return {
        "Accept-Language": "en"
    }
}
Enter fullscreen mode Exit fullscreen mode

If desired, you can add any of your own headers at the level of the manager creation function.

I will not discuss the method of obtaining an API token and the operation of the getAuthToken() function in this article, because this topic deserves a separate publication.

async function getAuthToken(): Promise<string> {
    // ЗThere would be a token receipt code here, but for now...
    return localStorage.getItem("auth-token");
}
Enter fullscreen mode Exit fullscreen mode

Use in components

An example of the profile manager is presented below:

useEffect(() => {
        (async () => {
            try {
                await initProfile();
            } catch (error: any) {
                await handleError(error);
            } finally {
                setLoading(false);
            }
        })()
    }, []);

    const initProfile = async () => {
        const manager = await createProfileManager();
        const profile = await manager.get();
        await dispatch(set(profile));
    }
Enter fullscreen mode Exit fullscreen mode

When the function is run in the useEffect hook, a profile manager is asynchronously created, which then requests the current status of the user profile from the server. In this example, we simply write the received state to the Redux storage, so that we can then work with this profile without re-requesting it from the server each time. In case of a client error, the handleError() function is launched, which, depending on the type of error, as I mentioned earlier, performs certain actions.

Results

This implementation is independent of the framework you are working with, it can be used even on native JS (TS). There are a lot of things you can refine in it, for example, add a "Builder" pattern to create an API client and transfer parameters, abortsignals and other things to it, or make a variable authentication system via a JWT token. Everything is at your discretion). In the next article I will tell you about the method of obtaining and working with API tokens on the client.

Follow me on Github <3

Top comments (14)

Collapse
 
kbirgerdev profile image
Kirill Birger

This is pretty good, and I think you're on the right track, but it could be improved in a few ways.

  1. You don't need to create so many instances. You're in JS which is single threaded. Your code is mostly stateless.

  2. Use unknown instead of any. It saves mistakes later, and communicates your intent better to the caller.

  3. Abstract more. The caller of your code should know nothing of HTTP. You stated that a goal was to avoid change if your API changes. That's a good goal.

Collapse
 
ra1nbow1 profile image
Matvey Romanov

Thanks

Collapse
 
joshkarp profile image
Joshua Karp

Curious about the abstraction part? I may be missing it but how does the caller know anything of the HTTP?

Collapse
 
cowedeveloper profile image
Muhammad Owais

Do you have a video of this? As a newbie i hardly able to catch that.

Collapse
 
raibtoffoletto profile image
Raí B. Toffoletto

This ia a bit over engineered, isn't it!? It's easier and cleaner to use native es modules over classes. Then, there's no need to have a createClient in all methods, just important the axios instance. 😉

Collapse
 
ra1nbow1 profile image
Matvey Romanov • Edited

It's an irregular approach :)

Collapse
 
raibtoffoletto profile image
Raí B. Toffoletto

Irregular!? Are you sure!? 🙃

Thread Thread
 
ra1nbow1 profile image
Matvey Romanov

I mean my approach that I've shown in this article

Collapse
 
joshkarp profile image
Joshua Karp

Great article.

Collapse
 
ra1nbow1 profile image
Matvey Romanov

Thank you

Collapse
 
nfroidure profile image
Nicolas Froidure

I'd recommend not to write an API client but instead generate it. I wrote this github.com/nfroidure/openapi-ts-sd... for generating a SDK from OpenAPI, it is still highly customizable but It avoids repeating myself.

I agree with other comments to avoid classes too.

Collapse
 
ra1nbow1 profile image
Matvey Romanov

This is really a good alternative

Collapse
 
raphydev profile image
Raphael Blehoue

Great Article Sir

Collapse
 
ra1nbow1 profile image
Matvey Romanov

Thanks a lot