IBM/node-celery-ts

View on GitHub
src/uri.ts

Summary

Maintainability
A
2 hrs
Test Coverage
// BSD 3-Clause License
//
// Copyright (c) 2018, IBM Corporation
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// * Redistributions of source code must retain the above copyright notice, this
//   list of conditions and the following disclaimer.
//
// * Redistributions in binary form must reproduce the above copyright notice,
//   this list of conditions and the following disclaimer in the documentation
//   and/or other materials provided with the distribution.
//
// * Neither the name of the copyright holder nor the names of its
//   contributors may be used to endorse or promote products derived from
//   this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE.

import { ParseError } from "./errors";
import { isNullOrUndefined, toCamelCase } from "./utility";

import * as _ from "underscore";
import * as UriJs from "urijs";

/**
 * Object representation of a URI.
 */
export interface Uri {
    readonly authority?: Authority;
    readonly path: string;
    readonly query?: Queries;
    readonly raw: string;
    readonly scheme: string;
}

/**
 * Supported URI schemes for Celery message brokers and result backends.
 */
export enum Scheme {
    Amqp = "amqp",
    AmqpSecure = "amqps",
    Redis = "redis",
    RedisSecure = "rediss",
    RedisSentinel = "sentinel",
    RedisSentinelSecure = "sentinels",
    RedisSocket = "redis+socket",
    RedisSocketSecure = "rediss+socket",
    Rpc = "rpc",
    RpcSecure = "rpcs",
}

/**
 * For convenience.
 */
const SCHEMES = new Set<string>(_.values(Scheme));

/**
 * URI authority.
 */
export interface Authority {
    readonly userInfo?: UserInfo;
    readonly host: string;
    readonly port?: number;
}

/**
 * Parsed URI query - can be not present or present one or more times.
 */
export interface Queries {
    readonly [key: string]: string | Array<string> | undefined;
}

/**
 * URI username and password.
 */
export interface UserInfo {
    readonly user: string;
    readonly pass?: string;
}

/**
 * @param toParse A valid URI.
 * @returns A normalized object representation of a URI.
 *
 * @throws ParseError If `toParse` is not a valid URI.
 */
export const parseUri = (toParse: string): Uri => {
    const parsed = (() => {
        try {
            return UriJs.parse(toParse);
        } catch (error) {
            throw new ParseError("Celery.Uri.parse: unable to parse "
                                 + `${toParse}: ${error}`);
        }
    })();

    if (isNullOrUndefined(parsed.path) || isNullOrUndefined(parsed.protocol)) {
        throw new ParseError(`Celery.Uri.parse: unable to parse ${parsed}: `
                             + "missing path or protocol");
    }

    const withRequired: Uri = {
        path: parsed.path,
        raw: toParse,
        scheme: parsed.protocol.toLowerCase(),
    };

    const withAuthority = addHostUserPassAndPort(parsed, withRequired);
    const withQuery = addQuery(parsed, withAuthority);

    return withQuery;
};

/**
 * Only looks at the beginning of the string to match a scheme.
 *
 * @param rawUri A valid URI.
 * @returns An enum corresponding to the scheme of `rawUri`.
 * @throws ParseError If `rawUri` has an unrecognized scheme.
 */
export const getScheme = (rawUri: string): Scheme => {
    const SCHEME_REGEX: RegExp = /^([A-Za-z][A-Za-z\d+.-]*):/;
    const SCHEME_INDEX: number = 1;

    const maybeMatches = rawUri.match(SCHEME_REGEX);

    if (isNullOrUndefined(maybeMatches)) {
        throw new ParseError(`invalid uri "${rawUri}"`);
    }

    const matches = maybeMatches;
    const scheme = matches[SCHEME_INDEX].toLowerCase();

    if (!SCHEMES.has(scheme)) {
        throw new ParseError(`unrecognized scheme "${scheme}"`);
    }

    return scheme as Scheme;
};

/**
 * A URI as parsed by `urijs`.
 */
interface RawUri {
    readonly fragment?: string;
    readonly hostname?: string;
    readonly password?: string;
    readonly path: string;
    readonly port?: string;
    readonly protocol: string;
    readonly query?: string;
    readonly username?: string;
}

/**
 * @param uri The output of `require("urijs").parse`.
 * @param parsing The object to add a hostname to.
 * @returns A copy of `parsing` with `authority.host` added if `uri.hostname` is
 *          defined.
 */
const addHost = (uri: RawUri, parsing: Uri): Uri => {
    if (isNullOrUndefined(uri.hostname)) {
        return parsing;
    }

    return {
        ...parsing,
        authority: {
            host: validateHost(uri.hostname).toLowerCase(),
        },
    };
};

/**
 * @param uri The output of `require("urijs").parse`.
 * @param parsing The object to add a hostname and username to.
 * @returns A copy of `parsing` with the fields added.
 */
const addHostAndUser = (uri: RawUri, parsing: Uri): Uri => {
    const withHost = addHost(uri, parsing);

    if (isNullOrUndefined(withHost.authority)
        || isNullOrUndefined(uri.username)) {
        return withHost;
    }

    return {
        ...withHost,
        authority: {
            ...withHost.authority,
            userInfo: {
                user: uri.username,
            },
        },
    };
};

/**
 * @param uri The output of `require("urijs").parse`.
 * @param parsing The object to add a hostname, username, and password to.
 * @returns A copy of `parsing` with the fields added.
 */
const addHostUserAndPass = (uri: RawUri, parsing: Uri): Uri => {
    const withUser = addHostAndUser(uri, parsing);

    if (isNullOrUndefined(withUser.authority)) {
        return withUser;
    }

    if (isNullOrUndefined(withUser.authority.userInfo)
        && !isNullOrUndefined(uri.password)) {
        return {
            ...withUser,
            authority: {
                ...withUser.authority,
                userInfo: {
                    pass: uri.password,
                    user: "",
                },
            },
        };
    }

    if (isNullOrUndefined(withUser.authority.userInfo)
        || isNullOrUndefined(uri.password)) {
        return withUser;
    }

    return {
        ...withUser,
        authority: {
            ...withUser.authority,
            userInfo: {
                ...withUser.authority.userInfo,
                pass: uri.password,
            },
        },
    };
};

/**
 * @param uri The output of `require("urijs").parse`.
 * @param parsing The object to add a hostname, username, password, and port to.
 * @returns A copy of `parsing` with the fields added.
 */
const addHostUserPassAndPort = (uri: RawUri, parsing: Uri): Uri => {
    const withPass = addHostUserAndPass(uri, parsing);

    if (isNullOrUndefined(withPass.authority)
        || isNullOrUndefined(uri.port)) {
        return withPass;
    }

    return {
        ...withPass,
        authority: {
            ...withPass.authority,
            port: parsePort(uri.port),
        },
    };
};

/**
 * @param uri The output of `require("urijs").parse`.
 * @param parsing The object to append queries to.
 * @returns A copy of `parsing` with the parsed query appended.
 */
const addQuery = (uri: RawUri, parsing: Uri): Uri => {
    const REGEX: RegExp = // tslint:disable:max-line-length
        /^[A-Za-z\d*-._+%]+=[A-Za-z\d*-._+%]*(?:&[A-Za-z\d*-._+%]+=[A-Za-z\d*-._+%+]*)*$/;

    if (isNullOrUndefined(uri.query)) {
        return parsing;
    }

    if (!REGEX.test(uri.query)) {
        throw new ParseError(`query "${uri.query}" is not a valid HTML query`);
    }

    const unconverted = UriJs.parseQuery(uri.query) as Queries;

    const camelCaseQuery = (queries: Queries, key: string) => {
        const cased = toCamelCase(key);

        if (cased === key) {
            return queries;
        }

        const { [key]: _key, ...renamed } = {
            ...queries,
            [cased]: queries[key],
        };

        return renamed;
    };

    const query = Object.keys(unconverted)
        .reduce(camelCaseQuery, unconverted);

    return {
        ...parsing,
        query,
    };
};

/**
 * Uses `Number.parseInt` with a base of 10.
 *
 * @param maybePort A valid base-10 representation of a number.
 * @returns The parsed port number.
 *
 * @throws ParseError If `maybePort` is not a number.
 */
const parsePort = (maybePort: string): number => {
    const maybeMatches = /^\d+$/.exec(maybePort);

    if (isNullOrUndefined(maybeMatches)) {
        throw new ParseError(`could not parse "${maybePort}" as port`);
    }

    const parsed = Number.parseInt(maybePort, 10);

    return parsed;
};

/**
 * @param maybeHost A string to validate as URI authority hostname.
 * @returns `maybeHost` if it is a valid hostname.
 *
 * @throws ParseError If `maybeHost` is not a valid URI authority hostname.
 */
const validateHost = (maybeHost: string): string => {
    if (HOST_REGEX.test(maybeHost) === false) {
        throw new ParseError(`invalid host "${maybeHost}"`);
    }

    return maybeHost;
};

// tslint:disable:max-line-length
const HOST_REGEX: RegExp = /^(?:[A-Za-z\d]|[A-Za-z\d][A-Za-z\d-]{0,61}?[A-Za-z\d])(?:\.(?:[A-Za-z\d]|[A-Za-z\d][A-Za-z\d-]{0,61}?[A-Za-z\d]))*$/;