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 }