All files / src/helpers request.ts

96.92% Statements 63/65
60% Branches 6/10
100% Functions 4/4
96.92% Lines 63/65

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 1801x 1x   1x       1x   1x                                                                                           1x 1x 10x 10x 10x 10x     10x 10x 10x 10x 10x 10x   1x                                                         1x 1x 4x 4x   4x   4x 4x   4x 4x 4x   4x   4x 2x 2x 2x 2x 2x 2x   4x 4x 4x 4x 4x   1x                       1x 1x 2x 2x 2x 2x 2x 2x 2x 2x   1x                     1x 1x 42x 42x 42x     42x 42x   1x       1x 1x  
import { md5sum, serialize } from '@/helpers/utils';
import * as contentType from 'content-type';
 
/**
 * @group Helpers
 * @groupDescription HTTP request utilities for making API calls and handling responses.
 * @source
 */
 
/**
 * Generates a unique hash for a given Request object based on its method, URL path, search parameters, and body.
 * This hash can be used to identify and cache requests. This is used to store the mocked response body
 * at a location with a unique filename (based on the hash).
 * This is necessary because the only other way to differentiate between request really is to store the file in
 * a location that contains the hostname, request method, request path and then some way to differentiate between
 * the request body and parameters. But even trying that causes issues. Trying to dynamically import the file
 * `./responses/${reqUrl.hostname}/${reqUrl.path}/${searchQuery}.json` throws the exception:
 *
 * ```
 * Error: Unknown variable dynamic import: ./responses/www.biofuranchem.com/_api/wix-ecommerce-storefront-web/api/acid.json.
 * Note that variables only represent file names one level deep.
 * ```
 *
 * So instead, we just use the hash to store the file in a location that is guaranteed to be unique. This is the
 * same way that the python request_cache library works, and that worked pretty well.
 *
 * @param  request - The Request object to generate a hash for
 * @returns A RequestHashObject containing:
 *          - hash: The MD5 hash of the request
 *          - file: The suggested file location for caching the request
 *          - url: The parsed URL object from the request
 * @example
 * ```typescript
 * const request = new Request('https://api.example.com/data?q=test', {
 *   method: 'GET'
 * });
 * const hashObj = getRequestHash(request);
 * // Returns: {
 * //   hash: '01b5190db0c0f8c232d2ad4d8957d5f4',
 * //   file: 'api.example.com/01b5190db0c0f8c232d2ad4d8957d5f4.json',
 * //   url: {
 * //     href: "https://api.example.com/data?q=test",
 * //     host: "api.example.com",
 * //     hostname:"api.example.com",
 * //     pathname: "/data",
 * //     search: "?q=test"
 * //     searchParams: URLSearchParams,
 * //     origin: "https://api.example.com",
 * //     protocol: "https:",
 * //     ...other URL properties
 * //   }
 * // }
 * ```
 * @category Helpers
 * @source
 */
export function getRequestHash(request: Request): RequestHashObject {
  const url = new URL(request.url);
  const resultHash = md5sum(
    // POST
    request.method + (url.pathname ?? '') + (url.search ?? '') + (request.body ?? ''),
  );
 
  return {
    hash: resultHash,
    file: `${url.hostname}/${resultHash}.json`,
    url: url,
  } satisfies RequestHashObject;
}
 
/**
 * Creates a cacheable response object from a Request and Response pair.
 * This function serializes the response content based on its content type
 * and generates a hash for the request to be used as a cache key.
 *
 * @param request - The original Request object
 * @param response - The Response object to be cached
 * @returns A Promise that resolves to a CacheResponse object containing:
 *          - hash: The RequestHashObject with request details and hash
 *          - data: A SerializedResponse containing the content type and serialized content
 * @example
 * ```typescript
 * const request = new Request('https://api.example.com/data');
 * const response = await fetch(request);
 * const cacheResponse = await getCachableResponse(request, response);
 * // Returns: {
 * //   hash: {
 * //     hash: '01b5190db0c0f8c232d2ad4d8957d5f4',
 * //     file: 'api.example.com/01b5190db0c0f8c232d2ad4d8957d5f4.json',
 * //     url: URL object
 * //   },
 * //   data: {
 * //     contentType: 'application/json',
 * //     content: '{"serialized":"content"}'
 * //   }
 * // }
 * ```
 * @category Helpers
 * @source
 */
export async function getCachableResponse(
  request: Request,
  response: Response,
): Promise<CacheResponse> {
  const reqHash = getRequestHash(request);
 
  // Generate a serialized object to be saved
  const dataType = contentType.parse(String(response.headers.get('content-type') ?? ''));
 
  const serializedResponse: SerializedResponse = {
    contentType: dataType.type,
  };
 
  const clonedResponse = response.clone();
 
  if (serializedResponse.contentType === 'application/json') {
    // Json gets stringified
    serializedResponse.content = serialize(JSON.stringify(await clonedResponse.json()));
  } else {
    // Everything else is treated as text
    serializedResponse.content = serialize(await clonedResponse.text());
  }
 
  return {
    hash: reqHash,
    data: serializedResponse,
  } satisfies CacheResponse;
}
 
/**
 * Encodes a string to be used in a URL.
 * @param str - The string to encode
 * @returns The encoded string
 * @category Helpers
 * @example
 * ```typescript
 * const encoded = urlencode("Hello, world! - 95% ethanol");
 * // Returns a value with only safe characters:
 * //    "Hello%2C+world%21+-+95%25+ethanol"
 * ```
 * @source
 */
export function urlencode(str: string): string {
  return encodeURIComponent(str)
    .replace(/!/g, '%21')
    .replace(/'/g, '%27')
    .replace(/\(/g, '%28')
    .replace(/\)/g, '%29')
    .replace(/\*/g, '%2A')
    .replace(/%20/g, '+');
}
 
/**
 * Converts an HTML string to a DOM Document object.
 * @param html - The HTML string to convert
 * @returns A DOM Document object
 * @category Helpers
 * @example
 * ```typescript
 * const doc = createDOM("<html><body><h1>Hello, world!</h1></body></html>");
 * // Returns: Document object
 * ```
 * @source
 */
export function createDOM(html: string): Document {
  const parser = new DOMParser();
  const doc = parser.parseFromString(html, 'text/html');
  if (!doc) {
    throw new Error('Failed to parse HTML');
  }
  return doc;
}
 
/**
 * Re-exported from `./fetch` so callers can reach the request helpers through a
 * single entry point.
 * @category Helpers
 */
export { fetchDecorator, generateRequestHash, generateSimpleHash } from './fetch';