Hello, today I'm going to be writing about how to write a framework-agnostic API module in TypeScript. This is my first ever technical blog so please be my harshest critic.
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:
- Handle the sending and receiving of all server calls
- Create our server compliant URLs, handle all parsing of parameters required and set global headers for all requests
- Encapsulate a generic Client Factory layer that will handle our
POST
,PUT
,GET
andDELETE
request, whilst also giving us type safety and IntelliSense - Serve as a logical layer to declare and reference any data transfer objects (DTO) your API sends or receives
- Allow the developer to be as verbose as required for each server call, as to minimise confusion and emphasis readability of the codebase
- 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
}
}
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
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:
- Sort any parameters into a key-value pair query string
- Join our base URL with the params
- Make our URL error-proof
export type CreateUrl = (baseUrl: string, template: string, param?: object | undefined) => string;
I may call createUrl
like so:
createUrl('https://example.com/', '/api/user/', { userId: '123', token: 'abc5' }),
Which produces:
https://example.com/api/user?userId=123&token=abc5
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://example.com//api/user/?userId=123
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)}`),
)
);
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
or
https://example.com/api?token=token
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];
}
});
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('&')
);
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
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}`}`;
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 },
);
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,
);
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,
},
});
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;
});
}
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);
}, []);
That is it all! I hope you learnt something today. All the code I've uploaded to a GitHub repository here. Enjoy. :)