express-http-client
Version:
middleware style interceptor; fetch based 0 dependancy imperative light weight http handler.
563 lines (511 loc) • 21.3 kB
JavaScript
/**
* Executes a chain of request interceptors sequentially
*
* @async
* @param {Object} data - The data object containing the request and a store
* @param {Request} data.request - The Request object to be processed
* @param {Map} data.store - A Map object for storing data between interceptors
* @param {Function[]} interceptors - Array of request interceptor functions
* @returns {Promise<Request|Error>} The processed request or an Error if interceptor chain fails
*
* @description
* Each interceptor in the chain receives:
* - data: The data object containing the request
* - next: Function to proceed to the next interceptor
* - end: Function to end the interceptor chain early
*
* Interceptors can modify the request, pass control to the next interceptor,
* end the chain early, or reject with an error.
*/
async function executeRequestInterceptors(data, interceptors) {
let index = 0;
return await new Promise((resolve, reject) => {
const end = () => {
resolve(data.request);
};
const next = async (error) => {
if (error) {
if (error instanceof Error) {
console.error(error.message);
reject(error);
return;
} else {
console.error(error);
reject(new Error(error));
return;
}
}
const interceptor = interceptors[index++];
if (!interceptor) {
// End of interceptor chain, resolve with request
resolve(data.request);
return;
}
try {
// Call the current interceptor with request, end and next
await interceptor(data, next, end);
} catch (err) {
reject(err);
}
};
// Start the interceptor chain
next();
});
}
/**
* Executes a chain of response interceptors sequentially
*
* @async
* @param {Object} data - The data object containing the response
* @param {Request} data.request - The Request object used in the original request if not modified by other response interceptors
* @param {Response} data.response - The Response object to be processed
* @param {number} data.requestTime - The timestamp of the original request
* @param {number} data.responseTime - The timestamp of the processed response
* @param {Map} data.store - A Map object for storing data between interceptors in one request chain execution
* @param {Function[]} interceptors - Array of response interceptor functions
* @returns {Promise<Response>} The processed response
*
* @description
* Each interceptor in the chain receives:
* - data: The data object containing the response
* - next: Function to proceed to the next interceptor
* - end: Function to end the interceptor chain early
*
* Interceptors can modify the response, pass control to the next interceptor,
* end the chain early, or handle errors. Errors in response interceptors
* result in a 424 Failed Dependency response rather than rejecting the promise.
*/
async function executeResponseInterceptors(data, interceptors) {
let index = 0;
return await new Promise((resolve, reject) => {
const end = () => {
resolve(data.response);
};
const next = async (error) => {
if (error) {
if (error instanceof Error) {
console.error(error.message);
resolve(new Response(error.message, { status: 424 }));
}else {
console.error(error);
resolve(new Response(error, { status: 424 }));
}
}
const interceptor = interceptors[index++];
if (!interceptor) {
// End of interceptor chain, return response
resolve(data.response);
return;
}
try {
// Call the current interceptor with response and next
await interceptor(data, next, end);
} catch (err) {
resolve(new Response(err, { status: 424 }));
}
};
// Start the interceptor chain
next();
});
}
/**
* HTTP client with request/response interceptor support
*
* @class
* @description
* A fetch-based HTTP client that supports middleware-style interceptors
* for both requests and responses. Allows setting a base URL that will
* be prepended to all request URLs.
*/
class HttpClient {
/** @type {Function[]} Private array of request interceptor functions */
#requestInterceptors = [];
/** @type {Function[]} Private array of response interceptor functions */
#responseInterceptors = [];
/** @type {string} Private base URL to prepend to all requests */
#baseUrl = '';
/**
* Creates a new HTTP client
*
* @constructor
* @param {string} [baseUrl=''] - Base URL to prepend to all requests
* @param {Function[]} [requestInterceptors=[]] - Array of request interceptor functions
* @param {Function[]} [responseInterceptors=[]] - Array of response interceptor functions
*/
constructor(
baseUrl='',
requestInterceptors=[],
responseInterceptors=[]
) {
this.#baseUrl = baseUrl;
this.#requestInterceptors = requestInterceptors;
this.#responseInterceptors = responseInterceptors;
}
/**
* Sends an HTTP request with the given URL and options.
*
* @async
* @param {string} url - The URL path to send the request to (will be appended to baseUrl)
* @param {RequestInit} [options] - Fetch API options for the request
* @returns {Promise<Response>} The response after being processed by any response interceptors
*
* @description
* This method:
* 1. Creates a Request object combining baseUrl and the provided url
* 2. Initializes a shared Map store for data persistence between interceptors
* 3. Processes the request through any request interceptors
* 4. If request processing fails, returns a 424 (Failed Dependency) response
* 5. Executes the fetch operation with the processed request
* 6. Processes the response through any response interceptors
* 7. Returns the final response
*/
async send(url, options) {
let data = {
request: new Request(this.#baseUrl + url, options),
store: new Map()
};
const request = await executeRequestInterceptors(data, this.#requestInterceptors);
if(request instanceof Error) {
return new Response(request.message, { status: 424 });
}
data.requestTime = Date.now();
let response;
try {
response = await fetch(request);
} catch (error) {
response = new Response(error, { status: 424 });
}
data.responseTime = Date.now();
data.response = response;
return await executeResponseInterceptors(data, this.#responseInterceptors);
}
}
/**
* Factory class for creating HttpClient instances with configured interceptors
*
* @class
* @description
* HttpClientFactory provides a fluent interface for configuring and creating
* HttpClient instances with custom request and response interceptors.
* Interceptors can be used to modify requests before they are sent or
* responses before they are returned to the caller.
*/
class HttpClientFactory {
/**
* Creates a new HttpClientFactory instance
*
* @constructor
*/
constructor() {
this.requestInterceptors = [];
this.responseInterceptors = [];
}
/**
* Adds one or more request interceptors to the factory
*
* @param {...Function} interceptors - Functions that will intercept requests
* @returns {HttpClientFactory} The current factory instance for chaining
* @throws {Error} If any interceptor is not a function
*
* @description
* Request interceptors are executed in the order they are added before a request is sent.
* Each interceptor should be a function that accepts and can modify the request.
*/
addRequestInterceptor(...interceptors) {
interceptors.forEach(interceptor => {
if (typeof interceptor !== 'function') {
throw new Error('Request interceptor must be a function');
}
this.requestInterceptors.push(interceptor);
});
return this;
}
/**
* Adds one or more response interceptors to the factory
*
* @param {...Function} interceptors - Functions that will intercept responses
* @returns {HttpClientFactory} The current factory instance for chaining
* @throws {Error} If any interceptor is not a function
*
* @description
* Response interceptors are executed in the order they are added after a response
* is received but before it is returned to the caller. Each interceptor should be
* a function that accepts and can modify the response.
*/
addResponseInterceptor(...interceptors) {
interceptors.forEach(interceptor => {
if (typeof interceptor !== 'function') {
throw new Error('Response interceptor must be a function');
}
this.responseInterceptors.push(interceptor);
});
return this;
}
/**
* Creates a new HttpClient instance with the configured interceptors
*
* @param {string} [baseUrl=''] - The base URL to use for all requests made by this client
* @returns {HttpClient} A new HttpClient instance configured with the specified interceptors
*
* @description
* Creates and returns a new HttpClient instance with the base URL and all
* request and response interceptors that have been added to this factory.
*/
create(baseUrl='') {
return new HttpClient(
baseUrl,
this.requestInterceptors,
this.responseInterceptors
);
}
}
/**
* Makes an HTTP request with support for request and response interceptors
*
* @async
* @function request
* @param {string} url - The URL to send the request to
* @param {RequestInit|Function|Array} [options] - Fetch API options, a request interceptor function, or an array of request interceptors
* @param {Function|Array} [responseInterceptors] - A response interceptor function or an array of response interceptors
* @param {Array} [requestInterceptors] - An array of request interceptors (only used when all 4 args are provided)
* @returns {Promise<Response>} The response after being processed by any response interceptors
*
* @description
* This function is flexible and can be called with different parameter combinations:
* - request(url): Simple GET request
* - request(url, options): Request with fetch options
* - request(url, requestInterceptor): Request with a single request interceptor
* - request(url, requestInterceptors[]): Request with multiple request interceptors
* - request(url, options, responseInterceptor): Request with options and a response interceptor
* - request(url, options, responseInterceptors[]): Request with options and multiple response interceptors
* - request(url, requestInterceptor, responseInterceptor): Request with both interceptor types
* - request(url, options, responseInterceptors, requestInterceptors): Full specification
*
* @throws {Error} When URL is missing or too many arguments are provided
*/
async function request$1(...args) {
let url = '';
let options = {};
let responseInterceptors = [];
let requestInterceptors = [];
if (args.length === 0) {
throw new Error('URL is required');
}else if (args.length === 1) {
url = args[0];
}else if (args.length === 2) {
url = args[0];
//optinal options
if (typeof args[1] === 'object') {
options = args[1];
}else if (typeof args[1] === 'function') {
requestInterceptors.push(args[1]);
}else if (typeof args[1] === 'array') {
requestInterceptors.push(...args[1]);
}
}else if (args.length === 3) {
url = args[0];
//optinal options
if (typeof args[1] === 'object') {
options = args[1];
}else if (typeof args[1] === 'function') {
requestInterceptors.push(args[1]);
}else if (typeof args[1] === 'array') {
requestInterceptors.push(...args[1]);
}
if (typeof args[2] === 'function') {
responseInterceptors.push(args[2]);
}else if (typeof args[2] === 'array') {
responseInterceptors.push(...args[2]);
}
}else if (args.length === 4) {
url = args[0];
options = args[1];
responseInterceptors = args[2];
requestInterceptors = args[3];
}else {
throw new Error(`4 arguments required, but ${args.length} are supplied.`);
}
let data = {
request: new Request(url, options),
};
const request = await executeRequestInterceptors(data, requestInterceptors);
if(request instanceof Error) {
return new Response(request.message, { status: 424 });
}
let response;
try {
response = await fetch(request);
} catch (error) {
response = new Response(error, { status: 424 });
}
data.response = response;
return await executeResponseInterceptors(data, responseInterceptors);
}
/**
* Creates a middleware that logs HTTP requests and responses with appropriate formatting
* for both browser and Node.js environments
*
* @returns {function({
* request: Request,
* response: Response,
* requestTime: number,
* responseTime: number,
* store: Map<any, any>
* }, function():void, function():void):Promise<void>}
* A middleware function that logs request details with the following parameters:
* - {Object} data - The request/response data object containing:
* - {Request} request - The HTTP request object with method and url properties
* - {Response} response - The HTTP response object with status property
* - {number} requestTime - The timestamp when the request was received
* - {number} responseTime - The timestamp when the response was sent
* - {Map<any, any>} store - A Map object for storing data between interceptors
* - {function():void} next - Function to call to proceed to the next middleware
* - {function():void} end - Function to call to end the middleware chain
*
* @description
* This middleware logs HTTP requests with the following information:
* - Timestamp in ISO format
* - Status code (color-coded by response type)
* - HTTP method (color-coded by method type)
* - Request URL
* - Response time (color-coded by performance)
*
* Color coding:
* - Status: Green for 2xx, Yellow for 3xx, Red for 4xx/5xx
* - Method: Cyan for most methods, Magenta for POST, Red for DELETE
* - Response time: Green for <100ms, Yellow for <1000ms, Red for ≥1000ms
*/
function Logger (){
return async function (data, next, end) {
const { status } = data.response;
const { method, url } = data.request;
const responseTime = data.responseTime - data.requestTime;
const timestamp = new Date(data.responseTime).toISOString();
// Detect environment
const isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined';
if (isBrowser) {
// Browser formatting with console styling API
// Status styling
let statusStyle = 'color: green; font-weight: bold;';
if (status >= 400) statusStyle = 'color: red; font-weight: bold;';
else if (status >= 300) statusStyle = 'color: orange; font-weight: bold;';
// Method styling
let methodStyle = 'color: cyan; font-weight: bold;';
if (method === 'POST') methodStyle = 'color: magenta; font-weight: bold;';
if (method === 'DELETE') methodStyle = 'color: red; font-weight: bold;';
// Response time styling
let timeStyle = 'color: green;';
if (responseTime >= 1000) timeStyle = 'color: red;';
else if (responseTime >= 100) timeStyle = 'color: orange;';
// Log with browser console styling
console.log(
`[${timestamp}] ` +
`%c${status}%c %c${method.padEnd(7)}%c ${url.padEnd(30)} %c${responseTime}ms`,
statusStyle, 'color: inherit;',
methodStyle, 'color: inherit;',
timeStyle
);
} else {
// Node.js terminal formatting with ANSI color codes
// Status color based on code
let statusColor = '\x1b[32m'; // Green for success (2xx)
if (status >= 400) statusColor = '\x1b[31m'; // Red for errors (4xx, 5xx)
else if (status >= 300) statusColor = '\x1b[33m'; // Yellow for redirects (3xx)
// Method color
let methodColor = '\x1b[36m'; // Cyan for most methods
if (method === 'POST') methodColor = '\x1b[35m'; // Magenta for POST
if (method === 'DELETE') methodColor = '\x1b[31m'; // Red for DELETE
// Reset color code
const reset = '\x1b[0m';
// Format response time
const formattedTime = responseTime < 100
? `\x1b[32m${responseTime}ms\x1b[0m` // Green if fast
: responseTime < 1000
? `\x1b[33m${responseTime}ms\x1b[0m` // Yellow if medium
: `\x1b[31m${responseTime}ms\x1b[0m`; // Red if slow
console.log(
`[${timestamp}] ` +
`${statusColor}${status}${reset} ` +
`${methodColor}${method.padEnd(7)}${reset} ` +
`${url.padEnd(30)} ` +
`${formattedTime}`
);
}
next();
};
}
/**
* Creates a middleware that mocks HTTP responses based on URL and method
*
* @param {boolean} [isMocked=true] - Whether to enable response mocking
* @param {Object.<string, Object.<string, function(Request):Response>>} [reqResMap={}] -
* A mapping of URLs to HTTP methods to response handlers.
* Format: { '/api/users': { 'GET': (req) => {...}, 'POST': (req) => {...} } }
* @param {function(Request):Response} [defaultResponse] -
* Function that returns a default Response when no matching URL/method is found.
* Defaults to returning a 404 "Resource Not Found" response.
*
* @returns {function({
* request: Request,
* response: Response,
* requestTime: number,
* responseTime: number,
* store: Map<any, any>
* }, function():Promise<Response>):Promise<Response>}
* A middleware function that intercepts requests and returns mock responses when enabled
*
* @description
* This middleware allows for easy mocking of HTTP responses during development or testing.
* When enabled, it intercepts requests and returns mock responses based on the URL and method.
*
* The reqResMap parameter should be structured as:
* {
* '/api/endpoint': {
* 'GET': (request) => new Response(JSON.stringify({ data: 'mock data' }), {
* headers: { 'Content-Type': 'application/json' }
* }),
* 'POST': (request) => new Response('Created', { status: 201 })
* }
* }
*
* If a request doesn't match any entry in the reqResMap, the defaultResponse function is used.
*
* @example
* // Basic usage
* const mockMiddleware = MockResponse(true, {
* '/api/users': {
* 'GET': () => new Response(JSON.stringify([{ id: 1, name: 'User' }]), {
* headers: { 'Content-Type': 'application/json' }
* })
* }
* });
*
* // Disable mocking
* const conditionalMock = MockResponse(process.env.NODE_ENV === 'development', {...});
*/
function MockResponse(isMocked=true, reqResMap={}, defaultResponse=()=>new Response("Resource Not Found",{status:404})) {
return (data, next) => {
const url = data.request.url;
const method = data.request.method;
if(isMocked) {
if(
reqResMap[url] && reqResMap[url][method]
) {
data.response = reqResMap[url][method](data.request);
} else if(reqResMap[url] && reqResMap[url]["ALL"]){
data.response = reqResMap[url]["ALL"](data.request);
} else {
data.response = defaultResponse(data.request);
}
}
return next();
}
}
function createHttpClientFactory() {
return new HttpClientFactory();
}
const httpClient = createHttpClientFactory;
const request = request$1;
const logger = Logger;
const mockResponse = MockResponse;
export { httpClient, logger, mockResponse, request };