DEV Community

Harry
Harry

Posted on • Edited on

Creating a framework-agnostic API module in TypeScript

This post originally appeared on my personal blog

Hello, today I'm going to be writing about how to write a framework-agnostic API module in TypeScript.

Due to the day job demands, I've recently transitioned from writing Angular UIs to writing primarily React UIs. I'm not going to get into the finer points of Angular vs React, framework vs library, as that horse is long dead!

However, one thing I do miss from the Angular days is the HttpClient provided by Angular. It 'provides simplified client HTTP API for Angular applications'. That it does.

Today I'm going to show how I write something similar using TypeScript, which is more explicit and more functional than our Angular counterpart.

Please note as this is written entirely in TypeScript I will make no assumptions about what framework you are using. I will also tailor this tutorial to be generic that either fetch or Axios can be used. However, I will touch on some Axios specific features further down the post.

Another assumption; if you are reading this, you are familiar with TypeScript. So let's jump straight into it.

If you just wanna see the code, no danger! I uploaded it here. Enjoy.

Why?

Good question. By writing an API module is allows us to encapsulate all our API calls into a single module/folder within our codebase. By splitting up each facet of an API call (creating the URL, passing the data, handling the correct Http verb) we can easily test, maintain and understand our codebase or someone else's. Code readability to me is of the utmost importance and should be valued above much else.

The API module will:

  1. Handle the sending and receiving of all server calls
  2. Create our server compliant URLs, handle all parsing of parameters required and set global headers for all requests
  3. Encapsulate a generic Client Factory layer that will handle our POST,PUT,GET and DELETE request, whilst also giving us type safety and IntelliSense
  4. Serve as a logical layer to declare and reference any data transfer objects (DTO) your API sends or receives
  5. Allow the developer to be as verbose as required for each server call, as to minimise confusion and emphasis readability of the codebase
  6. Be easy to test as each step on the process is small, simple and functional

To give you a brief example, we can see the differences:

// oh look at me I'm not using an API module 
function updateUserDetails (user: User): Promise<any> {
  return fetch(
    method: 'PUT',
    headers: {
      "Content-type": "application/x-www-form-urlencoded; charset=UTF-8"
    },
    body: JSON.stringify(user)
  )
}

import * as API from "api";
// ohohhh yeah look at that API module
function updateUserDetails (user: User): Promise<API.UserDTO> {
    try {
        return API.getUserDetails(user);
    } catch (error) {
        // handle the error
    }
}
Enter fullscreen mode Exit fullscreen mode

DRY!

Write an API module once and never worry about URLs, headers, return types ever again. Cover your API module in tests (easy using a functional approach) and never fret about your server calls again!

Let us begin

Right, if you've come this far your either sold or intrigued.

I will be using the getUserDetails and other user-centric calls in these examples.
Folder structure is simple:

/src
    /API
        /Client
            Client.ts
            Headers.ts
            HttpInstance.ts // axios only
            ResponseCode.ts
            index.ts
        /URL
            CreateQueryString.ts
            CreateUrl.ts
            JoinPaths.ts
            index.ts
        /User
            GetUserDetails.ts
            DeleteUser.ts
            // etc
        index.ts
    / //rest of app
Enter fullscreen mode Exit fullscreen mode

The Client folder is where the actual server calls happen through the use of factories that handle each respective Http verb.

The URL folder will parse our base url, any params and remove all unwanted characters.

Here, the User is an example but you can start to see how I am very explicit and verbose. Each file within /User performs one simple action, which is immediately identifiable at a glance.

How many times have you been burned by having say: user.service.ts which contains all server calls for your user domain, soon it is 600 LOC and completely unreadable. We mitigate that entirely by spitting each action our user can perform into its own file. This results in more files, however, this is an improvement over a monolithic service (IMO).

URL

Our URL layer needs to ultimately create the correct URL for the API. We need to:

  1. Sort any parameters into a key-value pair query string
  2. Join our base URL with the params
  3. Make our URL error-proof
export type CreateUrl = (baseUrl: string, template: string, param?: object | undefined) => string;
Enter fullscreen mode Exit fullscreen mode

I may call createUrl like so:

createUrl('https://exmaple.com/', '/api/user/', { userId: '123', token: 'abc5' }),
Enter fullscreen mode Exit fullscreen mode

Which produces:

https://exmaple.com/api/user?userId=123&token=abc5
Enter fullscreen mode Exit fullscreen mode

Creating the API URL

Our createUrl needs to first and foremost join the baseUrl and the template together. This is where JoinPaths.ts comes into the ring, as seen in the folder structure overview above.

We need to ensure we don't simply join two strings together, as shown in the code above, this could result in a bad API URL, for example:

https://exmaple.com//api/user/?userId=123
Enter fullscreen mode Exit fullscreen mode

We need to take both strings in as an array to be able to trim the trailing and leading slashes and handle the slashes from with the JoinPaths file.

const trimLeadingSlash = (path: string): string => (
    // if first character is slash, return string minus first char
    path.charAt(0) === '/' ? path.substr(1) : path
);

const trimTrailingSlash = (path: string): string => (
    // if last character is slash, return string minus last char
    path.substr(-1) === '/' ? path.substr(0, path.length - 1) : path
);

export const joinPaths = (...segments: Array<string>): string => (
    segments.reduce(
        (path: string, currentSegment: string) => (`${trimTrailingSlash(path)}/${trimLeadingSlash(currentSegment)}`),
    )
);
Enter fullscreen mode Exit fullscreen mode

This allows anyone working on this codebase to not have to worry about trivial problems like leading and trailing slashes. We move the responsibility into our API module and if you write some unit tests you can sleep safely knowing JoinPaths will look after your URLs.

Sort the parameters

So now we have a URL that has been parsed and formatted, we can append our API parameters to it. Ultimately, we need to iterate through the object we passed into createUrl().

Typically there are two ways to pass parameters to a server:

https://example.com/api/:token
Enter fullscreen mode Exit fullscreen mode

or

https://example.com/api?token=token
Enter fullscreen mode Exit fullscreen mode

Let's not discriminate and create a parameter pattern that will allow us to decide on the fly which way we please.

let url: string = joinPaths(baseUrl, template);
const queryParams: object = {};
Object
    .keys(params)
    .filter(key => params[key] !== undefined && params[key] !== null)
    .forEach(key => {
        const paramPlaceHolder: string = `${key}`;

        if (template.indexOf(paramPlaceHolder) > -1) {
            url = url.replace(paramPlaceHolder, params[key]);
        } else {
            queryParams[key] = params[key];
        }
    });
Enter fullscreen mode Exit fullscreen mode

Here we're iterating and filtering over each key in the parameter object, then we either do one of two things:

If the parameters being passed in match the template then we assume the token is being passed out of parameters like :token and we replace the template match with the parameter value.

Otherwise, we add the key-value pair to our new queryParams object defined above.

Now that all remains is to put it all together and sort the query parameters:

export const createKeyValuePair = (key: string, value: any): string => {
    if (Array.isArray(value)) {
        return value.map(x => createKeyValuePair(key, x)).join('&');
    }

    return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
};

export const createQueryString = (params: object): string => (
    params && Object
        .keys(params)
        .filter(key => params[key] !== undefined && params[key] !== null)
        .map(key => createKeyValuePair(key, params[key]))
        .join('&')
);

Enter fullscreen mode Exit fullscreen mode

The above code will iterate over all the parameters in the object we created in the previous step, each parameter we call a recursive function that first checks to if the passed in value is an array and if so then work through each element in the array and create the parameters.

Otherwise, return the value in the format the server is expecting. By joining each element with an ampersand we will create:

?key=value
Enter fullscreen mode Exit fullscreen mode

Again, array or object both use cases get covered.

Now all is todo is add the createQueryString reference and return the URI:

url = url.replace(/\/{.*}/, '');
const queryString: string = createQueryString(queryParams);
return `${encodeURI(url)}${queryString && `?${queryString}`}`;
Enter fullscreen mode Exit fullscreen mode

This code appears below the object parameter code we wrote above.

Now the URL is sorted we can turn our attention to the generic factories.

Factories

What are the factories? They will be the layer that is responsible for actually making the request to the server. This layer acts as a wrapper around either the Axios client or the native fetch client. We also need to handle each HTTP verb and leverage TypeScript to allow IntelliSense. This is the /Client folder from the folder structure overview above.

import { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
import { axios } from './HttpInstance';

const sendRequest = <T>(config: AxiosRequestConfig): Promise<T> => axios.request<T, AxiosResponse<T>>(config)
    .then(res => Promise.resolve(res.data))
    .catch((error: AxiosError) => Promise.reject(error?.response?.data ?? error));

export const getFactory = <T>(url: string) => sendRequest<T>(
    { url, method: 'GET' as const },
);

export const postFactory = <T, K>(url: string, body: T) => sendRequest<K>(
    {
        url,
        method: 'POST' as const,
        data: body,
    },
);

export const putFactory = <T, K>(url: string, body: T) => sendRequest<K>(
    {
        url,
        method: 'PUT' as const,
        data: body,
    },
);

export const deleteFactory = (url: string) => sendRequest<void>(
    { url, method: 'GET' as const },
);

Enter fullscreen mode Exit fullscreen mode

The above code is for the Axios client, however, for fetch it's almost identical but you will need to handle the stringification yourself and make sure to reject the request promise if res.ok is false, as with fetch a failed request does not get caught in the catch block of a promise.

We declare each verb as it's own factory function, and through the use of generics and declare the response and request types we will declare when calling these functions.

Actions

Now we've sorted the request URL, parameters and created a layer to communicate with the API, now we need to introduce our actions layer. This is where I like to declare DTOs and will act as a buffer between the application and our API module.

// ../API/User/createAccount.ts
import { createUrl } from '../URL/CreateUrl';
import { environmentVariables } from '../../../environment';
import { postFactory } from '../Client';

const route: string = 'user';
export type CreateDTO = {
    password: string;
    confirmPassword: string;
    email: string;
    name: string;
}
export const createAccount = async (payload: CreateDTO): Promise<{ 
    token: string, 
    userId: string 
}> => postFactory(
    createUrl(environmentVariables().apiUrl, route),
    payload,
);
Enter fullscreen mode Exit fullscreen mode

From the example above you can see all the pieces coming together and can see the TypeScript generics in play. It's clear from a glance exactly what this code is doing, what the data looks like and how the data will look on a successful request.

Please note the environmentVariables() function just returns the base URL of the server.

Now all you need to do is call and await createAccount and make sure to wrap it in a try/catch block. That's it! You now have an API module that handles all the things you need to make a request to your server without you ever having to repeat yourself or worry about incorrect syntax/data structures.

Axios specifics

Headers

I like to create an Axios instance on application load that will take a headers object as an argument, this ensures that you don't have to keep creating/passing in a headers object. Fire and forget!

export const defaultHeaders: Record<string, string> = {
    'Content-Type': 'application/json',
    Accept: 'application/json',
    credentials: 'include',
};
export const axios: AxiosInstance = Axios.create({
    timeout: 10000,
    headers: {
        ...defaultHeaders,
    },
});
Enter fullscreen mode Exit fullscreen mode

Interceptors

I also like to register interceptors for the client (axios) we initialise above (so all requests and responses get accounted for)

export function RegisterInterceptor(setState: (value: Action) => void) {
    axios.interceptors.response.use(response => response, async (error: AxiosError) => {
        if (!error.response) {
            // no response from the server (is the user offline?)
            throw error;
        }
        if (error.response.status === 403) {
            await clearCache();
            setState({
                type: StateActions.LOGGED_OUT,
            });

            throw error;
        }

        throw error;
    });
}
Enter fullscreen mode Exit fullscreen mode

You can see my interceptor is used to handle unauthorised messages and log out the user. You can also use this layer to handle any response type or no response at all for your client.

Lastly, register this interceptor on application load, something like:

// app.tsx
import * as API from './src/API';

// app component code:
React.useEffect(() => {
    API.RegisterInterceptor(setState);
}, []);
Enter fullscreen mode Exit fullscreen mode

That is it all! I hope you learnt something today. All the code I've uploaded to a GitHub repository here. Enjoy. :)

Top comments (0)