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 }