Making API Calls from Node.js
Tags: node, javascript, typescript, webdev
Published: 2023-05-18 07:46:57

---
title: Making API Calls from Node.js
published: true
description: Learn how to make HTTP requests to another API from your Node.js project, understand which libraries to use and patterns to follow.
tags: node, javascript, typescript, webdev
cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/articles/viljermb91w5fuc096ha.jpeg
# published_at: 2023-05-13 20:39 +0000
---

Calling another API from Node.js is straightforward.

Starting with Node.js version 18, a web-compatible `fetch()` function is available out of the box. However, as it is still marked as `experimental`, there are battle-tested libraries like [Axios](https://github.com/axios/axios) suitable for *production* environments, offering  convenient APIs and a comprehensive feature set:

```ts
import axios from 'axios';

const http = axios.create({
  baseURL: 'https://example.com/api'
});

export function getUser(userId: string) {
  // An equivalent to `GET /users?id=12345`
  return http.get('/user', {
    params: {
      id: 12345
    }
  });
}
```

That's it. Happy coding!

---

## Expanding the Scope

With the basics covered, let's discuss how to scale this solution. Not in terms of algorithmic or space complexity - which is often the focus of job interviews - but in terms of scaling it for a large application that will be worked on by a team of developers over several years. This is the kind of scaling we deal with in day-to-day work.

To illustrate this, we'll use a real-world API from a digital bank with public documentation: https://docs.solarisgroup.com/api-reference

> Disclaimer: I am not affiliated with Solaris SE. I just happened to be familiar with this API. Using realistic examples can be beneficial.

## Fetching Person Data

Let's begin with a basic [getPerson](https://docs.solarisgroup.com/api-reference/onboarding/persons/#tag/Persons/paths/~1v1~1persons~1{id}/get) call:

```ts
import axios from 'axios';

const http = axios.create({
  baseURL: process.env.API_BASE_URL
});

export function getPerson_v0(personId: string) {
  return http.get(`v1/persons/{id}`);
}
```

As you can see, it's not significantly different from the code in the *introductory* section. However, considering the returned value (see the response object in the docs), you might spot some issues.

Firstly, `axios` returns its own response object. Passing this up the stack isn't ideal as our callers (users of the API client we make) don't need to know what HTTP library we're using. We want to retain the flexibility to replace it later. Moreover, the users are primarily interested in the `Person` object itself, which is contained in the `data` property:

```ts
export function getPerson_v1(personId: string) {
  return http
    .get(`v1/persons/{id}`)
    .then((response) => response.data);
}
```

Now, let's consider the `Person` value itself:

1. Keys are in `snake_case`, which can lead to warnings from `ESLint` and complaints from other developers because our standard is `camelCase`.
1. Dates are represented as strings.

```ts
export async function getPerson_v2(personId: string) {
  return http
    .get<Person>(`v1/persons/{id}`)
    .then((response) => response.data)
    .then(snakeToCamelCase)
    .then(restoreDates);
}
```

In the above code, `snakeToCamelCase` and `restoreDates` are utility functions that iterate through the object's keys and map either the key or the value, depending on what needs to be corrected. The details of their implementation are not the focus of our discussion.

I've also added `<Person>` to the type parameters. Naturally, we need to create interfaces and enumerations for all data that we send or retrieve. These could be generated from an OpenAPI spec or created manually, depending on what the docs provide.

## An Unexpected Value

We restore date values, but we don't need to restore enumerations like `employment_status` as they are just strings. But what if a new unexpected value appears in the response that we don't have in our `EmploymentStatus`? Or even worse, some other field becomes a `string` instead of a `number`. We'll probably corrupt the application state at runtime.

An API is a living creature; it changes over time. It also has bugs (everything has bugs). We need to defend our code from invalid response data.

## Schema Validation

Schema validation can ensure that the data your application is receiving is of the correct type and format. This can help you catch bugs and discrepancies in the API, as well as protect your application from potential crashes or corrupt states due to unexpected data.

For Node.js, libraries such as `Joi`, `Yup`, `Zod` are often used. I will be using [Runtypes](https://github.com/pelotom/runtypes).

```ts
import * as t from 'runtypes';

export const PersonSchema = t.Record({
  // Other fields are omitted for simplicity
  id: t.String,
  first_name: t.String,
  last_name: t.String,
  email: t.String,
  birth_date: t.InstanceOf(Date),
  employment_status: EmploymentStatusSchema,
});

export type Person = t.Static<typeof PersonSchema>;

export async function getPerson_v3(personId: string) {
  return http
    .get(`v1/persons/{id}`)
    .then((response) => response.data)
    .then(snakeToCamelCase)
    .then(restoreDates)
    .then((person) => PersonSchema.check(person));
}
```

Calling `.check(person)` either returns back a typed Person or fails with an error.

## Authentication

We need to address authentication as well. The API is protected by a bearer token, which you can get via the oAuth endpoint. The token has an expiration time, and we will need to refresh it from time to time. I'm going to encapsulate all business logic about tokens in the `TokenManager` component. Read as "I will mostly skip it":

```ts
export class TokenManager {
  getToken(): Promise<string> {
    // ...
    return token;
  }

  close(): void {
    // ...
  }
}
```

It is really a separate client, which knows how to call the token endpoint and is using `setTimeout` to refresh the token prematurely. `getToken` returns a promise with the token value.

Axios provides `interceptors` as a mechanism, which we will utilize for injecting the token:

```ts
const tokenManager = new TokenManager();

async function injectNewToken<
  C extends InternalAxiosRequestConfig | AxiosRequestConfig
>(config: C): Promise<C> {
  const token = await tokenManager.getToken();
  if (typeof config.headers?.setAuthorization == "function") {
    config.headers.setAuthorization(`Bearer ${token}`);
    return config;
  }
  return {
    ...config,
    headers: {
      ...config.headers,
      Authorization: `Bearer ${token}`,
    },
  };
}

httpClient.interceptors.request.use(async (config) => {
  return injectNewToken(config);
});

httpClient.interceptors.response.use(undefined, async (error) => {
  if (isAxiosError(error) && error.response?.status === 401 && error.config) {
    return injectNewToken(error.config).then((c) => httpClient.request(c));
  }
  throw error;
});
```

The code is simplified, but what we're doing is:

1. Before actually dispatching a request, we ask `TokenManger` to give us a valid token, then we add it as the `Authorization` header to the request config.
2. In case of an error response with a 401 status code, we know from the documentation that the token was expired by the time it arrived at the API, and we need to get a new one and repeat the request.

## Errors

The API can respond with more than just 401 errors. Even more importantly, there's a whole page describing the error response format. At a bare minimum, we need to parse the response and throw something like `new Error(responseError.code)`. But we will need the error ID when we write to their support (and we will, a lot). Error description is helpful as well for debugging the issues.

The users of our client library should not dig into Axios errors and responses to understand that the person is not found. They should be able to easily pattern match on different error situations.

Introducing custom errors and error mapping. This is where I will be simplifying even more. Otherwise, it will be a thousand lines of code. For example, I will assume that there could be only 1 error in the response. And we will map only error codes, ignoring status codes and possible combinations.

Errors begin with making types and schemas for server error responses:

```ts
export enum SolarErrorCode {
  METHOD_NOT_ALLOWED = "method_not_allowed",
  MODEL_NOT_FOUND = "model_not_found",
  UNAUTHORIZED_ACTION = "unauthorized_action",
}

export const SolarErrorDataSchema = t.Record({
  // Other fields are omitted for simplicity
  id: t.String,
  status: t.Number,
  code: t.String,
  detail: t.String,
});

export const SolarErrorResponseSchema = t.Record({
  // Other fields are omitted for simplicity
  errors: t.Array(SolarErrorDataSchema),
});
```

After that we can define our own error class, which will be used by the code calling our client:

```ts
export enum ClientErrorCode {
  CONFIG_ISSUE = "config_issue",
  MODEL_NOT_FOUND = "model_not_found",
  NO_CONNECTION = "no_connection",
  UNAUTHORIZED = "unauthorized",
  UNEXPECTED_ERROR = "unexpected_error",
}

export class ClientError extends Error {
  static is(value: unknown): value is ClientError {
    return value instanceof ClientError;
  }

  static mapCode<R>(codeMap: { [key: string]: (error: ClientError) => R }) {
    return function clientErrorMapper(error: unknown) {
      if (ClientError.is(error)) {
        const remapper = codeMap[error.code] ?? codeMap["_"];
        if (typeof remapper === 'function') {
          return remapper(error);
        }
      }
      throw error;
    };
  }

  id?: string;
  code: string;

  constructor(
    errorData: SolarErrorData | Pick<SolarErrorData, "detail" | "code">
  ) {
    super(errorData.detail);
    if ("id" in errorData) {
      this.id = errorData.id;
    }
    this.code = errorData.code;
  }
}
```

This is how to *pattern match* a `ClientError`:

```ts
const person = await getPerson("id").catch(
  ClientError.mapCode({
    [ClientErrorCode.CONFIG_ISSUE]: (e) => {
      throw new Error(`Another error: ${e.code}`);
    },
    [ClientErrorCode.UNEXPECTED_ERROR]: () => 123 as const,
  })
);
```

Here users would be able to remap client errors to their own internal errors. Also, if you look at the inferred type of the `person`, it would be `Person | undefined | 123`.

But where does this `ClientError` come from? We parse error responses and map them into appropriate values (like `undefined` instead of 404) or `ClientError` instances depending on what is required. The goal is to hide the transport or API-specific stuff.

Here is the helper. Most of the conditions (`if` blocks) come from how you should [handle Axios errors](https://axios-http.com/docs/handling_errors):

```ts
export function mapAxiosError<R>(errorMap: {
  [key: string]: (() => R) | ClientErrorCode;
}) {
  return function axiosErrorMapper(error: unknown): R {
    if (!isAxiosError(error)) {
      throw error;
    }
    if (error.response) {
      const data = SolarErrorResponseSchema.check(error.response.data);
      const firstError = data.errors[0];
      const valueMapper = errorMap[firstError.code] ?? errorMap["_"];
      if (typeof valueMapper === "function") {
        return valueMapper();
      }
      throw new ClientError({
        id: firstError.id,
        code:
          typeof valueMapper === "string"
            ? valueMapper
            : ClientErrorCode.UNEXPECTED_ERROR,
        detail: firstError.detail,
        status: firstError.status,
      });
    } else if (error.request) {
      throw new ClientError({
        code: ClientErrorCode.NO_CONNECTION,
        detail: "Connection failed, please check your network",
      });
    } else {
      throw new ClientError({
        code: ClientErrorCode.CONFIG_ISSUE,
        detail: "Request configuration is invalid",
      });
    }
  };
}
```

The helper again, but applied to `getPerson`:

```ts
export async function getPerson_v4(personId: string) {
  return httpClient
    .get(`v1/persons/${personId}`)
    .then((response) => response.data)
    .then(snakeToCamelCase)
    .then(restoreDates)
    .then((person) => PersonSchema.check(person))
    .catch(
      mapAxiosError({
        [SolarErrorCode.MODEL_NOT_FOUND]: () => undefined,
        [SolarErrorCode.UNAUTHORIZED_ACTION]: ClientErrorCode.UNAUTHORIZED,
        _: ClientErrorCode.UNEXPECTED_ERROR,
      })
    );
}
```

Now, half of the code in the `getPerson` would be repeated in other API functions, so we need to extract that code:

```ts
export interface TypedRequestConfig<T> extends AxiosRequestConfig<T> {
  schema?: Runtype<T>;
}

export async function makeRequest<T>(
  config: TypedRequestConfig<T>
): Promise<T> {
  const response = await httpClient.request(config);

  const result = snakeToCamelCase(restoreDates(response.data));

  if (config.schema) {
    return config.schema.check(result);
  }

  return result;
}
```

Which removes a lot of duplication:

```ts
export async function getPerson_v5(personId: string) {
  return makeRequest({
    method: "GET",
    url: `v1/persons/${personId}`,
    schema: PersonSchema,
  }).catch(
    mapAxiosError({
      [SolarErrorCode.MODEL_NOT_FOUND]: () => undefined,
      [SolarErrorCode.UNAUTHORIZED_ACTION]: ClientErrorCode.UNAUTHORIZED,
      _: ClientErrorCode.UNEXPECTED_ERROR,
    })
  );
}
```

## Wrapping up

I bet you are already tired of writing code. But there's still so much to do:

1.  A circuit breaker and retries. If the network fails, it is convenient to automatically retry. But you need to control this behavior and prevent infinite loops, especially with 401 responses and invalid tokens.
2. Logs for debugging. Bad things happen, and you will need to debug the app, probably even in a live environment. It is nice to have some way to enable logs showing requests and responses.
3. Telemetry. You probably want to collect response times and other data and sent it to Prometheus or another system.
4. Request overrides. There should be a way to attach additional headers, set a specific timeout, etc., for every API function individually.
5. Distributed tracing. This could be done through request overrides, but you probably want to set it up once during client initialization.
6. Get rid of `process.env`. Use a reliable config component like [envalid](https://github.com/af/envalid). Use `lodash` or another library instead of `typeof`.
7. Who knows what else.

What I want you to understand is that making a production-grade client is difficult. You can definitely call an API from Node.js as shown at the beginning of this post. But after a couple of years of work of multiple developers or even teams, the app and the client will become enormous. Unmanaged and accidental complexity will silently waste the time of every developer when they add a new function, update it, or fix a bug. And it will not be visible on Jira charts. So, at some point in time you will need to refactor and you may use this post for inspiration.

## Bonus Content

If you look at the `getPerson_v4`, you might notice that response processing is like a **data pipeline** with multiple steps. A successful response has its own steps, while an error response has its own. These steps are simple.

Speaking of steps, if we were to create a function for something like `POST` (where we send data to the API), we would have one more pipeline. This pipeline would process the request body: converting `camelCase` to `snake_case`, and so on. It's similar to the response pipeline, but in the opposite direction.

You've seen data pipelines before. In `express`, request processing is a data pipeline. Every middleware somehow affects or transforms the request until it reaches your handler (controller).

Many of the recommendations I made are applicable to frontend and universal clients too.

The full source code is available on [CodeSandbox](https://codesandbox.io/p/sandbox/solaris-client-example-lmpc1n?file=%2Fsrc%2Fexample.ts).