mentoss
Version: 
A utility to mock fetch requests and responses.
165 lines (161 loc) • 5.38 kB
JavaScript
/**
 * @fileoverview Various utility functions.
 * @author Nicholas C. Zakas
 */
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
 * Formats headers into a string that matches how they are displayed in
 * a network panel. Header names are capitalized and separated by a colon
 * from the header value.
 * @param {Headers} headers The headers to format.
 * @returns {string} The formatted headers.
 */
function formatHeaders(headers) {
    return Array.from(headers.entries())
        .map(([name, value]) => `${name
        .split("-")
        .map(part => part.charAt(0).toUpperCase() + part.slice(1))
        .join("-")}: ${value}`)
        .join("\n");
}
/**
 * Formats a body into a string that matches how it is displayed in a network
 * panel. If the body is an object, it is stringified as JSON. If the body is
 * a string, it is returned as-is. Otherwise, the body is converted to a string.
 * @param {string|any|FormData|null} body The body to format.
 * @returns {string} The formatted body.
 */
function formatBody(body) {
    if (!body) {
        return "";
    }
    if (typeof body === "string") {
        return body;
    }
    if (body.constructor === Object) {
        return JSON.stringify(body);
    }
    // TODO: Other types of bodies
    return body.toString();
}
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
 * Represents an error that occurs when a URL is invalid.
 * @extends {Error}
 */
export class URLParseError extends Error {
    /**
     * Creates a new URLParseError instance.
     * @param {string} url The URL that caused the error.
     */
    constructor(url) {
        super(`Invalid URL: ${url}`);
        this.name = "URLParseError";
    }
}
/**
 * Represents an error that occurs when no route matched a request.
 * @extends {Error}
 */
export class NoRouteMatchedError extends Error {
    /**
     * Creates a new NoRouteMatchedError instance.
     * @param {Request} request The request that wasn't matched.
     * @param {string|any|FormData|null} body The body of the request.
     * @param {Array<{title: string, messages: Array<string>}>} traces The traces from the servers.
     */
    constructor(request, body, traces) {
        const message = `No route matched for ${request.method} ${request.url}.
Full Request:
${stringifyRequest(request, body)}
${traces.length === 0
            ? "No partial matches found."
            : "Partial matches:\n\n" +
                traces
                    .map(trace => {
                    let traceMessage = `${trace.title}:`;
                    trace.messages.forEach(message => {
                        traceMessage += `\n  ${message}`;
                    });
                    return traceMessage;
                })
                    .join("\n\n")}`;
        super(message);
        this.name = "NoRouteMatchedError";
        this.request = request;
        this.body = body;
        this.traces = traces;
    }
}
/**
 * Parses a URL and returns a URL object. This is used instead
 * of the URL constructor to provide a standard error message,
 * because different runtimes use different messages.
 * @param {string|URL} url The URL to parse.
 * @returns {URL} The parsed URL.
 */
export function parseUrl(url) {
    if (url instanceof URL) {
        return url;
    }
    try {
        return new URL(url);
    }
    catch {
        throw new URLParseError(url);
    }
}
/**
 * Reads the body from a request based on the HTTP headers.
 * @param {Request} request The request to read the body from.
 * @returns {Promise<string|any|FormData|null>} The body of the request.
 */
export async function getBody(request) {
    // first get the content type
    const contentType = request.headers.get("content-type");
    // if there's no content type, there's no body
    if (!contentType) {
        return Promise.resolve(null);
    }
    // next try to read the body as text to see if there's a body
    const text = await request.clone().text();
    // if there's no body, return null
    if (!text) {
        return Promise.resolve(null);
    }
    // if it's a text format then return a string
    if (contentType.startsWith("text")) {
        return text;
    }
    // if the content type is JSON, parse the body as JSON
    if (contentType.startsWith("application/json")) {
        return request.json();
    }
    // if the content type is form data, parse the body as form data
    if (contentType.startsWith("multipart/form-data")) {
        return request.formData();
    }
    // otherwise return the body as bytes
    return request.arrayBuffer();
}
/**
 * Creates a text representation of a request in the same format as it would
 * appear in a network panel.
 * @param {Request} request The request to stringify.
 * @param {string|any|FormData|null} body The body of the request
 * @returns {string} The stringified request.
 */
export function stringifyRequest(request, body) {
    let text = `${request.method} ${request.url}`;
    if (request.headers) {
        text += `\n${formatHeaders(request.headers)}`;
    }
    if (body) {
        text += `\n\n${formatBody(body)}`;
    }
    return text.trim();
}