RocketChat/Rocket.Chat

View on GitHub
apps/meteor/server/lib/http/call.ts

Summary

Maintainability
C
7 hrs
Test Coverage
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { HTTP } from 'meteor/http';
import { URL, URLSearchParams } from 'meteor/url';

import { truncate } from '../../../lib/utils/stringUtils';

// Code extracted from https://github.com/meteor/meteor/blob/master/packages/deprecated/http
// Modified to:
//   - Respect proxy envvars such as HTTP_PROXY and NO_PROXY
//   - Respect HTTP_DEFAULT_TIMEOUT envvar or use 20s when it is not set

const envTimeout = parseInt(process.env.HTTP_DEFAULT_TIMEOUT || '', 10);
const defaultTimeout = !isNaN(envTimeout) ? envTimeout : 20000;

type HttpCallOptions = {
    content?: string | URLSearchParams;
    data?: Record<string, any>;
    query?: string;
    params?: Record<string, string>;
    auth?: string;
    headers?: Record<string, string>;
    timeout?: number;
    followRedirects?: boolean;
    referrer?: string;
    integrity?: string;
};

// eslint-disable-next-line @typescript-eslint/naming-convention
interface HTTPResponse {
    statusCode?: number;
    headers?: { [id: string]: string };
    content?: string;
    data?: any;
    ok?: boolean;
    redirected?: boolean;
}

type callbackFn = (error: Error | undefined, result?: HTTPResponse) => void;

// Fill in `response.data` if the content-type is JSON.
function populateData(response: Record<string, any>): void {
    // Read Content-Type header, up to a ';' if there is one.
    // A typical header might be "application/json; charset=utf-8"
    // or just "application/json".
    const contentType = (response.headers['content-type'] || ';').split(';')[0];

    // Only try to parse data as JSON if server sets correct content type.
    if (['application/json', 'text/javascript', 'application/javascript', 'application/x-javascript'].indexOf(contentType) >= 0) {
        try {
            response.data = JSON.parse(response.content);
        } catch (err) {
            response.data = null;
        }
    } else {
        response.data = null;
    }
}

function makeErrorByStatus(statusCode: number, content: string): Error {
    let message = `failed [${statusCode}]`;

    if (content) {
        message += `${truncate(content.replace(/\n/g, ' '), 500)}`;
    }

    return new Error(message);
}

function _call(httpMethod: string, url: string, options: HttpCallOptions, callback: callbackFn): void {
    const method = (httpMethod || '').toUpperCase();

    if (!/^https?:\/\//.test(url)) {
        throw new Error('url must be absolute and start with http:// or https://');
    }

    const headers: Record<string, string> = {};
    let { content } = options;

    if (!('timeout' in options)) {
        options.timeout = defaultTimeout;
    }

    if (options.data) {
        content = JSON.stringify(options.data);
        headers['Content-Type'] = 'application/json';
    }

    let paramsForUrl;
    let paramsForBody;

    if (content || method === 'GET' || method === 'HEAD') {
        paramsForUrl = options.params;
    } else {
        paramsForBody = options.params;
    }

    const newUrl = URL._constructUrl(url, options.query, paramsForUrl);

    if (options.auth) {
        if (options.auth.indexOf(':') < 0) {
            throw new Error('auth option should be of the form "username:password"');
        }

        const base64 = Buffer.from(options.auth, 'ascii').toString('base64');
        headers.Authorization = `Basic ${base64}`;
    }

    if (paramsForBody) {
        const data = new URLSearchParams();
        Object.entries(paramsForBody).forEach(([key, value]) => {
            data.append(key, value);
        });
        content = data;
        headers['Content-Type'] = 'application/x-www-form-urlencoded';
    }

    const { headers: receivedHeaders } = options;

    if (receivedHeaders) {
        Object.keys(receivedHeaders).forEach((key) => {
            headers[key] = receivedHeaders[key];
        });
    }

    // wrap callback to add a 'response' property on an error, in case
    // we have both (http 4xx/5xx error, which has a response payload)
    const wrappedCallback = ((cb: callbackFn): { (error: Error | undefined, response?: HTTPResponse): void } => {
        let called = false;
        return (error: Error | undefined, response?: HTTPResponse): void => {
            if (!called) {
                called = true;
                if (error && response) {
                    (error as any).response = response;
                }
                cb(error, response);
            }
        };
    })(callback);

    // is false if false, otherwise always true
    const followRedirects = options.followRedirects === false ? 'manual' : 'follow';

    const requestOptions = {
        method,
        jar: false,
        timeout: options.timeout,
        body: content,
        redirect: followRedirects,
        referrer: options.referrer,
        integrity: options.integrity,
        headers,
    } as const;

    fetch(newUrl, requestOptions)
        .then(async (res) => {
            const content = await res.text();
            const response: HTTPResponse = {};
            response.statusCode = res.status;
            response.content = `${content}`;

            // fetch headers don't allow simple read using bracket notation
            // so we iterate their entries and assign them to a new Object
            response.headers = {};
            for (const entry of (res.headers as any).entries()) {
                const [key, val] = entry;
                response.headers[key] = val;
            }

            response.ok = res.ok;
            response.redirected = res.redirected;

            populateData(response);

            if (response.statusCode >= 400) {
                const error = makeErrorByStatus(response.statusCode, response.content);
                wrappedCallback(error, response);
            } else {
                wrappedCallback(undefined, response);
            }
        })
        .catch((err) => wrappedCallback(err));
}

function httpCallAsync(httpMethod: string, url: string, options: HttpCallOptions, callback: callbackFn): void;
function httpCallAsync(httpMethod: string, url: string, callback: callbackFn): void;
function httpCallAsync(httpMethod: string, url: string, optionsOrCallback: HttpCallOptions | callbackFn = {}, callback?: callbackFn): void {
    // If the options argument was omitted, adjust the arguments:
    if (!callback && typeof optionsOrCallback === 'function') {
        return _call(httpMethod, url, {}, optionsOrCallback as callbackFn);
    }

    return _call(httpMethod, url, optionsOrCallback as HttpCallOptions, callback as callbackFn);
}

export const httpCall = async (httpMethod: string, url: string, options: HttpCallOptions) => {
    return new Promise((resolve, reject) => {
        httpCallAsync.bind(HTTP)(httpMethod, url, options, (error, result) => {
            if (error) {
                return reject(error);
            }
            resolve(result);
        });
    });
};