github.com/grafana/pyroscope@v1.18.0/public/app/services/base.ts (about)

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