github.com/pyroscope-io/pyroscope@v0.37.3-0.20230725203016-5f6947968bd0/webapp/javascript/services/base.ts (about)

     1  /* eslint-disable max-classes-per-file */
     2  /* eslint-disable import/prefer-default-export */
     3  import { Result } from '@webapp/util/fp';
     4  import type { ZodError } from 'zod';
     5  import { modelToResult } from '@webapp/models/utils';
     6  import { CustomError } from 'ts-custom-error';
     7  import basename from '@webapp/util/baseurl';
     8  
     9  // RequestNotOkError refers to when the Response is not within the 2xx range
    10  export class RequestNotOkError extends CustomError {
    11    public constructor(public code: number, public description: string) {
    12      super(
    13        `Request failed with statusCode: '${code}' and description: '${description}'`
    14      );
    15    }
    16  }
    17  
    18  export class RequestAbortedError extends CustomError {
    19    public constructor(public description: string) {
    20      super(`Request was aborted by user. Description: '${description}'`);
    21    }
    22  }
    23  
    24  // RequestError refers to when the request is not completed
    25  // For example CORS errors or timeouts
    26  // or simply the address is wrong
    27  export class RequestIncompleteError extends CustomError {
    28    public constructor(public description: string) {
    29      super(`Request failed to be completed. Description: '${description}'`);
    30    }
    31  }
    32  
    33  // When the server returns a list of errors
    34  export class RequestNotOkWithErrorsList extends CustomError {
    35    public constructor(public code: number, public errors: string[]) {
    36      super(`Error(s) were found: ${errors.map((e) => `"${e}"`).join(', ')}`);
    37    }
    38  }
    39  
    40  export class ResponseNotOkInHTMLFormat extends CustomError {
    41    public constructor(public code: number, public body: string) {
    42      super(
    43        `Server returned with code: '${code}'. The body contains an HTML page`
    44      );
    45    }
    46  }
    47  
    48  export class ResponseOkNotInJSONFormat extends CustomError {
    49    public constructor(public code: number, public body: string) {
    50      super(
    51        `Server returned with code: '${code}'. The body that could not be parsed contains '${body}'`
    52      );
    53    }
    54  }
    55  
    56  export type RequestError =
    57    | RequestNotOkError
    58    | RequestNotOkWithErrorsList
    59    | RequestIncompleteError
    60    | ResponseOkNotInJSONFormat
    61    | ResponseNotOkInHTMLFormat;
    62  
    63  function join(base: string, path: string): string {
    64    path = path.replace(/^\/+/, '');
    65    base = base.replace(/\/+$/, '');
    66    return `${base}/${path}`;
    67  }
    68  
    69  export function mountURL(req: RequestInfo): string {
    70    const baseName = basename();
    71  
    72    if (baseName) {
    73      if (typeof req === 'string') {
    74        return new URL(join(baseName, req), window.location.href).href;
    75      }
    76  
    77      // req is an object
    78      return new URL(join(baseName, req.url), window.location.href).href;
    79    }
    80  
    81    // no basename
    82    if (typeof req === 'string') {
    83      return new URL(`${req}`, window.location.href).href;
    84    }
    85    return new URL(`${req}`, window.location.href).href;
    86  }
    87  
    88  export function mountRequest(req: RequestInfo): RequestInfo {
    89    const url = mountURL(req);
    90  
    91    if (typeof req === 'string') {
    92      return url;
    93    }
    94  
    95    return {
    96      ...req,
    97      url: new URL(req.url, url).href,
    98    };
    99  }
   100  
   101  export async function request(
   102    request: RequestInfo,
   103    config?: RequestInit
   104  ): Promise<Result<unknown, RequestError>> {
   105    const req = mountRequest(request);
   106    let response: Response;
   107    try {
   108      response = await fetch(req, config);
   109    } catch (e) {
   110      // 'e' is unknown, but most cases it should be an Error
   111      let message = '';
   112      if (e instanceof Error) {
   113        message = e.message;
   114      }
   115  
   116      if (e instanceof Error && e.name === 'AbortError') {
   117        return Result.err(new RequestAbortedError(message));
   118      }
   119  
   120      return Result.err(new RequestIncompleteError(message));
   121    }
   122  
   123    if (!response.ok) {
   124      const textBody = await response.text();
   125  
   126      // There's nothing in the body, so let's use a default message
   127      if (!textBody || !textBody.length) {
   128        return Result.err(
   129          new RequestNotOkError(response.status, 'No description available')
   130        );
   131      }
   132  
   133      // We know there's data, so let's check if it's in JSON format
   134      try {
   135        const data = JSON.parse(textBody);
   136  
   137        // Check if it's 401 unauthorized error
   138        if (response.status === 401) {
   139          // TODO: Introduce some kind of interceptor (?)
   140          // if (!/\/(login|signup)$/.test(window?.location?.pathname)) {
   141          //   window.location.href = mountURL('/login');
   142          // }
   143          return Result.err(new RequestNotOkError(response.status, data.error));
   144        }
   145  
   146        // Usually it's a feedback on user's actions like form validation
   147        if ('errors' in data && Array.isArray(data.errors)) {
   148          return Result.err(
   149            new RequestNotOkWithErrorsList(response.status, data.errors)
   150          );
   151        }
   152  
   153        // Error message may come in an 'error' field
   154        if ('error' in data && typeof data.error === 'string') {
   155          return Result.err(new RequestNotOkError(response.status, data.error));
   156        }
   157  
   158        // Error message may come in an 'message' field
   159        if ('message' in data && typeof data.message === 'string') {
   160          return Result.err(new RequestNotOkError(response.status, data.message));
   161        }
   162  
   163        return Result.err(
   164          new RequestNotOkError(
   165            response.status,
   166            `Could not identify an error message. Payload is ${JSON.stringify(
   167              data
   168            )}`
   169          )
   170        );
   171      } catch (e) {
   172        // We couldn't parse, but there's definitly some data
   173        // We must handle this case since the go server sometimes responds with plain text
   174  
   175        // It's HTML
   176        // Which normally happens when hitting a broken URL, which makes the server return the SPA
   177        // Poor heuristic for identifying it's a html file
   178        if (/<\/?[a-z][\s\S]*>/i.test(textBody)) {
   179          return Result.err(
   180            new ResponseNotOkInHTMLFormat(response.status, textBody)
   181          );
   182        }
   183        return Result.err(new RequestNotOkError(response.status, textBody));
   184      }
   185    }
   186  
   187    // Server responded with 2xx
   188    const textBody = await response.text();
   189  
   190    // There's nothing in the body
   191    if (!textBody || !textBody.length) {
   192      return Result.ok({
   193        statusCode: response.status,
   194      });
   195    }
   196  
   197    // We know there's data, so let's check if it's in JSON format
   198    try {
   199      const data = JSON.parse(textBody);
   200  
   201      // We could parse the response
   202      return Result.ok(data);
   203    } catch (e) {
   204      // We couldn't parse, but there's definitly some data
   205      return Result.err(new ResponseOkNotInJSONFormat(response.status, textBody));
   206    }
   207  }
   208  
   209  // We have to call it something else otherwise it will conflict with the global "Response"
   210  type ResponseFromRequest = Awaited<ReturnType<typeof request>>;
   211  type Schema = Parameters<typeof modelToResult>[0];
   212  
   213  // parseResponse parses a response with given schema if the request has not failed
   214  export function parseResponse<T>(
   215    res: ResponseFromRequest,
   216    schema: Schema
   217  ): Result<T, RequestError | ZodError> {
   218    if (res.isErr) {
   219      return Result.err<T, RequestError>(res.error);
   220    }
   221  
   222    return modelToResult(schema, res.value) as Result<T, ZodError<ShamefulAny>>;
   223  }