Step-by-Step: Build Your own HTTP Client like Axios using Node.js and Typescript

Abubakar Balogun
12 min readMar 1, 2024

--

An HTTP client is a program embedded within a larger application that sends requests to a server using the Hypertext Transfer Protocol (HTTP) and receives and processes the responses. Many modern applications and services communicate with each other over the web using HTTP-based APIs (Application Programming Interfaces). Well-known web browsers like Chrome, Firefox, Safari, and Edge serve as examples of HTTP clients. They initiate HTTP requests to servers when users navigate web pages, submit forms, or engage in other interactions on the web. It’s also not uncommon for servers to communicate with each other to request resources for specific tasks. Consider a scenario where a node.js backend application needs to consume the OpenAI API. HTTP clients such as Axios, node-fetch, and the recently stable Node.js fetch API are commonly used to facilitate this communication.

As someone who uses Axios in both browser and Node.js environments, I’ve come to appreciate its convenience for handling HTTP requests on both the client and server sides. This experience sparked my curiosity about the inner workings of HTTP clients like Axios. If you share this curiosity, why not embark on a journey to build your own simple HTTP client capable of sending requests and processing responses?

This tutorial invites you to explore Node.js mastery by utilizing its powerful built-in module to create a rudimentary HTTP client that works similarly to Axios. Step-by-step, you’ll delve into the essential concepts of handling HTTP requests, decoding responses, managing headers, and gracefully managing errors. By the end, you’ll not only understand the mechanics of an HTTP client but also have your custom-built tool ready to seamlessly connect with web services. Let’s dive in!

Prerequisites

  • Make sure to have Node.js installed on your computer. You can head to the official node.js website to download the latest node.js version..
  • Node.js Basics: Ensure you have a good understanding of Node.js fundamentals. Know how to work with modules, handle asynchronous operations, and manage dependencies using npm or yarn.
  • JavaScript Async Operations: Understand asynchronous JavaScript, as most HTTP requests are asynchronous. Understand promises, async/await, and how they work to handle asynchronous tasks.
  • A basic knowledge of Typescript: This project uses Typescript but you can follow along in plain Javascript.

How does Axios work?

Before delving into building our HTTP client, let’s grasp how Axios operates. Axios is an isomorphic JavaScript library, meaning it operates in both browser and Node.js environments. Its isomorphic nature allows developers to write consistent code for making HTTP requests across different platforms. In Node.js environments, Axios takes advantage of the built-in http module, making it capable of handling HTTP requests without the need for external dependencies. This integration with Node.js allows Axios to tap into the server-side capabilities of the platform. On the other hand, in browser environments, Axios utilizes the XMLHttpRequest object, providing compatibility with various web browsers. This ensures that Axios can be incorporated into front-end applications seamlessly.

Understanding the HTTP Request and Response Lifecycle

Another important concept to understand when building an HTTP client is the HTTP Request and Response Lifecycle. The HTTP request and response lifecycle intricately details the steps involved when a client, like a web browser, interacts with a server to access information from a website. Below is a structured breakdown of this lifecycle:

Request Phase:

  1. Client Initiation:
  • A user action, like entering a URL, clicking a link or submitting a form, triggers the client (web browser) to send an HTTP request to a server.
  1. Request Formation:
  • The client assembles an HTTP request comprising:
  • HTTP Method: Specifies the type of request (GET, POST, etc.).
  • URL/URI: Identifies the requested resource.
  • Headers: Additional details like content type, accepted languages, and authentication.
  • Body (optional): Data accompanying POST or PUT requests.
  1. Transmission:
  • The request traverses the network via TCP/IP to reach the server.

Server-Side Processing:

  1. Server Actions:
  • Upon receiving the request, the server:
  • Routes it to the specified resource.
  • Handles the request by executing necessary operations.
  • Generates an HTTP response.

2. Response Composition:

  • The server constructs an HTTP response comprising:
  • Status Code: Indicates the request outcome (e.g., 200, 404, 500).
  • Headers: Details about content, server, and caching.
  • Body: Data/resource in HTML, JSON, XML, etc.

3. Transmission:

  • The server dispatches the HTTP response back to the client over the network.

Client-Side Handling:

  1. Response Processing:
  • On receiving the response, the client:
  • Checks Status Code: Determines success or errors.
  • Renders Content: Displays the data (webpage, images, text) for the user.
  • Performs Actions: Executes scripts or initiates further requests.

2. Lifecycle Continuation:

  • The specific request/response interaction concludes, presenting content in the browser.

This cyclical process persists as users engage with the web app, prompting additional HTTP requests for various resources or actions.

Implementation

Let’s delve into a practical implementation for creating an HTTP client in Node.js by leveraging TypeScript.

Setting Up the Project

Let’s begin by initializing a new Node.js project. Open your terminal and create a new directory for the project:

mkdir http-client
cd http-client
npm init -y

Installing Dependencies

Since we’ll be using TypeScript for this project, we need to install a couple of dependencies:

npm install typescript @types/node ts-node

@types/node provides types for Node.js, and ts-node allows us to run TypeScript directly in the terminal without having to transpile to JavaScript..

Creating Project Files

Create the necessary files for your project structure. For project

  • Create an src directory to hold TypeScript files.
  • Inside src, create HttpClient.ts to start writing our HTTP client implementation.

Defining the interface for the HTTP client

In the src folder, create a file name type.ts. This will house type declaration for HttpClient.ts. Let’s start by defining the interfaces for request options and HTTP response.

import http from 'http';

export interface RequestOptions<T> extends http.RequestOptions {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
headers?: Record<string, string>;
queryParameters?: Record<string, string>;
body?: T;
}

export interface HttpResponse<T> {
data: T;
status?: number;
statusMessage?: string;
headers: Record<string, string>;
}

These interfaces establish a clear blueprint for creating HTTP requests and processing responses within our client. The RequestOptions interface encapsulates crucial details such as request method, headers, query parameters, and the request body. Meanwhile, the HttpResponse interface defines the structure of the received response, encompassing response data, status code, status message, and response headers.

Step 5: Define the structure of the HTTP client class

import https from "https";
import { RequestOptions, HttpResponse } from "./types";

export class HttpClient {
private baseURL?: string;
constructor(baseURL?: string) {
this.baseURL = baseURL;
}
}

In this step, we define the structure of the HTTP client class. We import the https module for making HTTP requests and import types needed for request options and response handling.

The HttpClient class contains a private optional variable baseURL, which represents the base URL for the HTTP client. It also includes a constructor function that accepts an optional baseURL parameter, allowing the client to be initialized with a base URL if needed.

Implement Utility Functions

Let’s begin by implementing private methods that handle the behind-the-scenes work to ensure the functionality of our HTTP client.

a. isValidUrl:

private isValidUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch (error) {
return false;
}
}

The isValidUrl method in the HttpClient class validates a given URL string by attempting to create a new URL object with it. If the creation is successful, indicating a valid URL, the method returns true; otherwise, it returns false. This method simplifies and standardizes URL validation across the application, ensuring consistent validation logic throughout different parts of the codebase.

b. buildUrl:

private buildUrl(path: string, queryParameters?: Record<string, string>) {
let url = this.baseURL || "";
if (url && !url.endsWith("/")) {
url += "/";
}

url += path;
if (queryParameters) {
const queryParams = new URLSearchParams(Object.entries(queryParameters));
url += `?${queryParams.toString()}`;
}
return url;
}

The buildUrl method in the HttpClient class is ensential for constructing complete URLs for HTTP requests. It takes a path parameter representing the endpoint path and an optional queryParameters parameter for specifying query string parameters. Its primary objective is to ensure the generated URL is valid and incorporates any necessary query parameters.

To start, the method initializes the url variable with the base URL (this.baseURL) if it exists, or an empty string otherwise. It then appends the endpoint path to the URL. Before doing so, it checks if the URL already ends with a trailing slash ("/"). If not, it appends one to ensure proper URL formatting.

Next, if queryParameters are provided, the method constructs a query string using the URLSearchParams API. This ensures that query parameters are correctly encoded and formatted. The resulting query string is then appended to the URL using the "?" separator.

Finally, the method returns the fully constructed URL, which includes the base URL (if any), endpoint path, and any specified query parameters. By centralizing URL construction logic within the buildUrl method, the HttpClient class ensures that URLs across the application are consistently formatted and include all necessary parameters. This approach enhances code readability, maintainability, and consistency in handling HTTP requests.

c. buildRequestOptions:

private buildRequestOptions<T>(
method: RequestOptions<T>["method"],
options: RequestOptions<T>
): RequestOptions<T> {
const requestOptions: RequestOptions<T> = {
method,
headers: options.headers || {},
};

if (options.body) {
requestOptions.headers!["Content-Type"] = "application/json";
}
return requestOptions;
}

The buildRequestOptions method constructs the options object to be passed to the underlying HTTP request library http. It takes the HTTP method and additional options provided by the user and builds a request options object. This includes setting headers, particularly the Content-Type header for requests with a body. Properly setting request options ensures that requests are sent with the necessary configuration.

d. sendRequest:

private async sendRequest<T>(
url: string,
options: RequestOptions<T>
): Promise<HttpResponse<T>> {
if (!this.isValidUrl(url)) {
return Promise.reject(new Error(`Invalid URL provided`));
}


const protocol = url.startsWith("http:") ? http : https;
return new Promise((resolve, reject) => {
try {
const request = protocol.request(url, options, (res) => {
let data = "";
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", (data: any) => {
if (
res.statusCode &&
res.statusCode >= 200 &&
res.statusCode < 300
) {
const response: HttpResponse<T> = {
data: JSON.parse(data),
status: res.statusCode,
statusMessage: res.statusMessage,
headers: res.headers,
};
resolve(response);
} else {
reject(new Error(""));
}
});
request.on("error", (error) => {
reject(new Error(`Request error: ${error}`));
});
request.end();
});
} catch (error) {
reject(new Error(`Request Error: ${error}`));
}
});
}

This method is responsible for actually sending the HTTP request. It utilizes the appropriate protocol (http or https) based on the URL's scheme. It constructs the request, handles data received from the server, and resolves or rejects the promise based on the response status code. This method encapsulates the low-level details of sending HTTP requests and handling responses, abstracting them away from the rest of the application code.

Implement the send Function

In this part we define the main send function that handles sending requests, including handling the request body.

private async send<T>(
method: RequestOptions<T>["method"],
path: string,
options: RequestOptions<T>,
body?: T
): Promise<HttpResponse<T>> {
const url = this.buildUrl(path, options.queryParameters);

if (body) {
options.body = body;
}
let requestOptions = this.buildRequestOptions(method, options);
try {
const response = await this.sendRequest<T>(url, requestOptions);
return response;
} catch (error) {
throw error;
}
}

The send function within the HttpClient class serves as the core method responsible for sending HTTP requests. It acts as a central piece of functionality for making HTTP requests using various methods such as GET, POST, PUT, PATCH, and DELETE. This function abstracts away the details of creating and sending HTTP requests, enabling other methods within the HttpClient class to delegate the actual request sent to it.

Functionally, send starts by constructing the full URL for the request using the buildUrl method, which incorporates the base URL (if provided) and any query parameters. It then builds the request options object using the buildRequestOptions method, ensuring that appropriate headers are set, particularly for requests with a body (e.g., setting the "Content-Type" header to "application/json").

Afterwards, the function initiates the HTTP request using the appropriate protocol (HTTP or HTTPS) based on the URL’s scheme. It manages the request lifecycle, including error handling for request errors and response status codes. Upon receiving a response, the function parses the response data and constructs an HttpResponse object containing relevant information such as the response data, status code, status message, and headers.

Finally, send returns a promise that resolves with the HttpResponse object upon successful completion of the request or rejects with an error if an error occurs during the request process. This encapsulation of HTTP request logic provides a unified interface for making requests of different types while abstracting away complexities, promoting code reusability, readability, and maintainability.

Step 5: Implement HTTP Methods (get, post, put, patch, delete)

In this phase, we implement public methods for common HTTP methods (GET, POST, PUT, PATCH, DELETE) that utilize the send function.

  public async get<T>(path: string, options: RequestOptions<T> = { method: "GET" }) {
return this.send<T>("GET", path, options);
}
public async post<T>(path: string, body: T, options: RequestOptions<T> = { method: "POST" }) {
return this.send<T>("POST", path, options, body);
}
public async put<T>(path: string, body: T, options: RequestOptions<T> = { method: "PUT" }) {
return this.send<T>("PUT", path, options, body);
}
public async patch<T>(path: string, body: T, options: RequestOptions<T> = { method: "PATCH" }) {
return this.send<T>("PATCH", path, options, body);
}
public async delete<T>(path: string, options: RequestOptions<T> = { method: "DELETE" }) {
return this.send<T>("DELETE", path, options);
}

The post, put, patch, and delete methods within the HttpClient class provide convenience wrappers around the send function for making HTTP requests with specific HTTP methods. These methods abstract away the details of constructing and sending requests, making it easier to perform common CRUD operations (Create, Read, Update, Delete) in HTTP-based applications.

Each of these methods takes parameters such as the path, request body (for methods that support it), and additional options for customization. They internally call the send function with the appropriate HTTP method and pass along the provided parameters, ensuring consistency and adherence to HTTP standards.

  • post: This method sends an HTTP POST request to the specified path with the provided request body. It utilizes the send function with the HTTP method set to "POST" and passes the path, request body, and any additional options.
  • put: Similar to post, this method sends an HTTP PUT request to the specified path with the provided request body. It uses the send function with the HTTP method set to "PUT" and passes the necessary parameters.
  • patch: The patch method sends an HTTP PATCH request to the specified path with the provided request body. It internally invokes the send function with the HTTP method set to "PATCH" and passes the path, request body, and any additional options.
  • delete: Finally, the delete method sends an HTTP DELETE request to the specified path. It calls the send function with the HTTP method set to "DELETE" and passes the path and any additional options.

These methods encapsulate common HTTP request patterns, promoting code readability, reusability, and maintainability by providing a clear and consistent interface for performing CRUD operations in HTTP-based communication.

Step 6: Usage

Let’s explore how to utilize our HTTP client to make an HTTP request to https://dummyjson.com.

import { HttpClient } from "./main";
const axios = new HttpClient("<https://dummyjson.com/>");

(async () => {
try {
const res = await axios.get("products/1");
console.log("Result: ", res);
} catch (error) {
console.log("Error featcting data: ");
}
})();

After execution, the console output should resemble the following:

Result:  {
data: {
id: 1,
title: 'iPhone 9',
description: 'An apple mobile which is nothing like apple',
price: 549,
discountPercentage: 12.96,
rating: 4.69,
stock: 94,
brand: 'Apple',
category: 'smartphones',
thumbnail: '<https://cdn.dummyjson.com/product-images/1/thumbnail.jpg>',
images: [
'<https://cdn.dummyjson.com/product-images/1/1.jpg>',
'<https://cdn.dummyjson.com/product-images/1/2.jpg>',
'<https://cdn.dummyjson.com/product-images/1/3.jpg>',
'<https://cdn.dummyjson.com/product-images/1/4.jpg>',
'<https://cdn.dummyjson.com/product-images/1/thumbnail.jpg>'
]
},
status: 200,
statusMessage: 'OK',
headers: {
'access-control-allow-origin': '*',
'x-dns-prefetch-control': 'off',
'x-frame-options': 'SAMEORIGIN',
'strict-transport-security': 'max-age=15552000; includeSubDomains',
'x-download-options': 'noopen',
'x-content-type-options': 'nosniff',
'x-xss-protection': '1; mode=block',
'x-ratelimit-limit': '100',
'x-ratelimit-remaining': '99',
date: 'Thu, 22 Feb 2024 20:10:52 GMT',
'x-ratelimit-reset': '1708632662',
'content-type': 'application/json; charset=utf-8',
'content-length': '537',
etag: 'W/"219-Qb5jNZGxbDZom9db3B0+RJe2d+4"',
vary: 'Accept-Encoding',
server: 'railway'
}
}

Challenges Ahead

We’ve made significant progress in constructing our HTTP client, which can now send requests and handle responses. However, our current implementation represents only a fraction of the potential capabilities available. The aim of this tutorial is to establish a foundation for creating a custom HTTP client. Below, I outline several enhancements that can be integrated to further solidify your understanding.

  1. Timeout Implementation
  • Challenge: Implement timeout functionality to gracefully handle scenarios where responses take longer than expected.
  • Impact: Enhance the client’s reliability by preventing prolonged waits for responses.

2. Interceptor Integration

  • Challenge: Implement interceptors to manipulate requests or responses before they are sent or received.
  • Impact: Provide flexibility to the client by intercepting and modifying requests or responses based on predefined logic.

3. Diverse Response Types

  • Challenge: Expand the options for response types beyond the basics (text, document, media) to effectively handle diverse content formats.
  • Impact: Enrich the client’s capabilities to seamlessly manage a wider range of content types, increasing versatility.

4. Error Handling and Retry Logic

  • Challenge: Develop a robust error handling mechanism along with retry logic to manage transient errors and ensure more reliable communication with servers.
  • Impact: Improve reliability by gracefully managing errors and enhancing the client’s resilience.

By implementing these challenges and expanding the functionality, you will not only gain knowledge about building an HTTP client but also deepen your understanding of Node.js as a whole.

Conclusion

Engaging in project-based learning, such as building an HTTP client as demonstrated in this tutorial, is one of the most effective methods for solidifying newly acquired knowledge. While it’s unlikely that you’ll often need to reinvent the wheel by constructing your own HTTP client in real-world scenarios, this exercise offers invaluable insights into the workings of Node.js. Additionally, it presents an opportunity to contribute to libraries like Axios. Continuously refine your client, iterate upon implemented features, and explore additional functionalities. Dive into advanced concepts, experiment with real-world scenarios, and push the boundaries of your HTTP client’s capabilities.

--

--

Abubakar Balogun
Abubakar Balogun

Written by Abubakar Balogun

I share insights on Software development and other subjects that pique my interest.

Responses (1)