UNPKG

express-http-client

Version:

middleware style interceptor; fetch based 0 dependancy imperative light weight http handler.

563 lines (511 loc) 21.3 kB
/** * 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 };