-
Notifications
You must be signed in to change notification settings - Fork 352
Expand file tree
/
Copy pathutils.js
More file actions
628 lines (569 loc) · 20.7 KB
/
Copy pathutils.js
File metadata and controls
628 lines (569 loc) · 20.7 KB
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
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
// Copyright © 2021 The Things Network Foundation, The Things Industries B.V.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as Sentry from '@sentry/react'
import { isPlainObject, isObject } from 'lodash'
import { defineMessages } from 'react-intl'
import { error as errorLog, warn } from '@ttn-lw/lib/log'
import interpolate from '@ttn-lw/lib/interpolate'
import errorMessages from './error-messages'
import grpcErrToHttpErr from './grpc-error-map'
import { TokenError } from './custom-errors'
import sentryFilters from './sentry-filters'
/**
* Returns whether the given object has a valid `details` prop.
*
* @param {object} object - The object to be tested.
* @returns {boolean} `true` if `object` has a valid `details` prop, `false` otherwise.
*/
export const hasValidDetails = object =>
'details' in object &&
object.details instanceof Array &&
object.details.length !== 0 &&
typeof object.details[0] === 'object'
/**
* Tests whether the error is a backend error object.
*
* @param {object} error - The error to be tested.
* @returns {boolean} `true` if `error` is a well known backend error object.
*/
export const isBackend = error =>
isPlainObject(error) &&
!('id' in error) &&
error.message &&
(typeof error.code === 'number' || typeof error.grpc_code === 'number')
/**
* Returns whether the error is a frontend defined error object.
*
* @param {object} error - The error to be tested.
* @returns {boolean} `true` if `error` is a well known frontend error object.
*/
export const isFrontend = error => Boolean(error) && typeof error === 'object' && error.isFrontend
/**
* Returns whether `details` is a backend error details object.
*
* @param {object} details - The object to be tested.
* @returns {boolean} `true` if `details` is a well known backend error details object,
* `false` otherwise.
*/
export const isBackendErrorDetails = details =>
Boolean(details) &&
Boolean(details.namespace) &&
Boolean(details.name) &&
Boolean(details.message_format)
/**
* Returns whether the error has a shape that is not well-known.
*
* @param {object} error - The error to be tested.
* @returns {boolean} `true` if `error` is not of a well known shape.
*/
export const isUnknown = error =>
!isBackend(error) && !isFrontend(error) && !isTimeoutError(error) && !isNetworkError(error)
/**
* Returns a frontend error object, to be passed to error components.
*
* @param {object} errorTitle - The error message title (i18n message).
* @param {object} errorMessage - The error message object (i18n message).
* @param {string} errorCode - An optional error code to be used to identify
* a specific error type easily. E.g. `user_status_unapproved`.
* @param {number} statusCode - An optional status code corresponding to
* the well known HTTP status codes. This can help categorizing the error if
* necessary.
* @returns {object} A frontend error object to be passed to error components.
*/
export const createFrontendError = (errorTitle, errorMessage, errorCode, statusCode) => ({
errorTitle,
errorMessage,
errorCode,
statusCode,
isFrontend: true,
})
/**
* Maps the error type to a HTTP Status code. Useful for quickly
* determining the type of the error. Returns false if no status code can be
* determined.
*
* @param {object} error - The error to be tested.
* @returns {number} The (closest when grpc error) HTTP Status Code, otherwise
* `undefined`.
*/
export const httpStatusCode = error => {
if (!Boolean(error)) {
return undefined
}
let statusCode = undefined
if (isBackend(error)) {
statusCode = error.http_code || grpcErrToHttpErr(error.code || error.grpc_code)
} else if (isFrontend(error)) {
statusCode = error.statusCode
} else if (Boolean(error.statusCode)) {
statusCode = error.statusCode
} else if (Boolean(error.response) && Boolean(error.response.status)) {
statusCode = error.response.status
} else if (isObject(error) && error.cause) {
return httpStatusCode(error.cause)
}
return Boolean(statusCode) ? parseInt(statusCode) : undefined
}
/**
* Returns the GRPC Status code in case of a backend error.
*
* @param {object} error - The error to be tested.
* @returns {number} The GRPC error code, or `false`.
*/
export const grpcStatusCode = error => isBackend(error) && (error.code || error.grpc_code)
/**
* Tests whether the grpc error represents the not found erorr.
*
* @param {object} error - The error object to be tested.
* @returns {boolean} `true` if `error` represents the not found error,
* `false` otherwise.
*/
export const isNotFoundError = error => grpcStatusCode(error) === 5 || httpStatusCode(error) === 404
/**
* Returns whether the grpc error represents an internal server error.
*
* @param {object} error - The error to be tested.
* @returns {boolean} `true` if `error` represents an internal server error,
* `false` otherwise.
*/
export const isInternalError = error => grpcStatusCode(error) === 13 // NOTE: HTTP 500 can also be UnknownError.
/**
* Returns whether the grpc error represents an invalid argument.
*
* @param {object} error - The error to be tested.
* @returns {boolean} `true` if `error` represents an invalid argument error,
* `false` otherwise.
*/
export const isInvalidArgumentError = error =>
grpcStatusCode(error) === 3 || httpStatusCode(error) === 400
/**
* Returns whether the grpc error represents a bad request error.
*
* @param {object} error - The error to be tested.
* @returns {boolean} `true` if `error` represents an bad request error,
* `false` otherwise.
*/
export const isBadRequestError = error =>
grpcStatusCode(error) === 9 || httpStatusCode(error) === 400
/**
* Returns whether the grpc error represents an already exists error.
*
* @param {object} error - The error to be tested.
* @returns {boolean} `true` if `error` represents an already exists error,
* `false` otherwise.
*/
export const isAlreadyExistsError = error => grpcStatusCode(error) === 6 // NOTE: HTTP 409 can also be AbortedError.
/**
* Returns whether the grpc error represents a permission denied error.
*
* @param {object} error - The error to be tested.
* @returns {boolean} `true` if `error` represents a permission denied error,
* `false` otherwise.
*/
export const isPermissionDeniedError = error =>
grpcStatusCode(error) === 7 || httpStatusCode(error) === 403
/**
* Returns whether the grpc error represents an error due to not being
* authenticated.
*
* @param {object} error - The error to be tested.
* @returns {boolean} `true` if `error` represents an `Unauthenticated` error,
* `false` otherwise.
*/
export const isUnauthenticatedError = error =>
grpcStatusCode(error) === 16 || httpStatusCode(error) === 401
/**
* Returns whether the grpc error represents a conflict with the current state on the server.
*
* @param {object} error - The error to be tested.
* @returns {boolean} `true` if `error` represents a `Conflict` error, `false` otherwise.
*/
export const isConflictError = error =>
grpcStatusCode(error) === 10 || httpStatusCode(error) === 409
/**
* Returns whether `error` has translation ids.
*
* @param {object} error - The error to be tested.
* @returns {boolean} `true` if `error` has translation ids, `false` otherwise.
*/
export const isTranslated = error =>
isBackend(error) ||
isFrontend(error) ||
(isPlainObject(error) && typeof error.id === 'string' && typeof error.defaultMessage === 'string')
/**
* Returns whether `error` is a 'network error' as JavaScript TypeError.
*
* @param {object} error - The error to be tested.
* @returns {boolean} `true` if `error` is a network error, `false` otherwise.
*/
export const isNetworkError = error =>
error instanceof Error && error.message.toLowerCase() === 'network error'
/**
* Returns whether `error` is a 'ECONNABORTED' error as returned from axios.
*
* @param {object} error - The error to be tested.
* @returns {boolean} `true` if `error` is a timeout error, `false` otherwise.
*/
export const isTimeoutError = error =>
Boolean(error) && typeof error === 'object' && error.code === 'ECONNABORTED'
/**
* Returns whether `error` is a connection failure error that happens on the
* proxy layer when the service is currently unavailable, e.g. When updating
* the server.
*
* @param {object} error - The error to be tested.
* @returns {boolean} `true` if `error` is a connection failure error, `false` otherwise.
*/
export const isConnectionFailureError = error =>
isPlainObject(error) &&
hasValidDetails(error) &&
Boolean(error.details[0]?.name?.startsWith('503_upstream_reset_before_response_started'))
/**
* Returns whether `error` is a backend error with ID: 'pkg/web/oauthclient:refused'.
*
* @param {object} error - The error to be tested.
* @returns {boolean} `true` if `error` is a such error, `false` otherwise.
*/
export const isOAuthClientRefusedError = error =>
isBackend(error) && getBackendErrorId(error) === 'error:pkg/web/oauthclient:refused'
/**
* Returns whether the `error` is a invalid state error with ID: 'pkg/web/oauthclient:invalid_state'.
*
* @param {object} error - The error to be tested.
* @returns {boolean} `true` if `error` is such error, `false` otherwise.
*/
export const isOAuthInvalidStateError = error =>
isBackend(error) && getBackendErrorId(error) === 'error:pkg/web/oauthclient:invalid_state'
/**
* Returns whether the error is worth being sent to Sentry.
*
* @param {object} error - The error to be tested.
* @returns {boolean} `true` if `error` should be forwarded to Sentry,
* `false` otherwise.
*/
export const isSentryWorthy = error => {
const statusCode = httpStatusCode(error)
// Forward all server and bad request errors.
if (statusCode >= 500 || statusCode === 400) {
if (isBackend(error)) {
return !sentryFilters.includes(getBackendErrorId(error))
}
return true
}
// Forward token errors that are not network related.
if (error instanceof TokenError) {
if (isNetworkError(error.cause) || isTimeoutError(error.cause)) {
return false
}
return true
}
// Forward any other unknown errors without relevant status code,
// that are not network related.
if (isUnknown(error) && statusCode === undefined) {
if (typeof error === 'string' && /<html.*>|<!DOCTYPE/i.test(error)) {
// If the error is a string and resembles a HTML document, it is likely
// caused by a client side firewall or other security middleware.
// Such errors can be discarded.
return false
}
return true
}
// Discard all other errors.
return false
}
/**
* Returns an appropriate error title that can be used for Sentry.
*
* @param {object} error - The error object.
* @returns {string} The Sentry error title.
*/
export const getSentryErrorTitle = error => {
if (typeof error === 'string') {
return `invalid string error: "${error}"`
}
if (typeof error !== 'object') {
return `invalid error type: ${error}`
}
if (isBackend(error)) {
const title = error.message || error.message_format
if (hasValidDetails(error) && hasCauses(getBackendErrorDetails(error))) {
const rootCause = getBackendErrorRootCause(getBackendErrorDetails(error))
if (isBackendErrorDetails(rootCause)) {
const message =
'attributes' in rootCause
? interpolate(rootCause.message_format, rootCause.attributes)
: rootCause.message_format
return `${title}; error:${rootCause.namespace}:${rootCause.name} (${message})`
}
}
return title
} else if (isFrontend(error)) {
return error.errorTitle.defaultMessage
} else if ('message' in error) {
return error.message
} else if ('code' in error) {
return error.code
} else if ('statusCode' in error) {
return `status code: ${error.statusCode}`
} else if ('id' in error && 'defaultMessage' in error) {
return error.defaultMessage
}
return 'untitled or empty error'
}
/**
* Returns the id of the error, used as message id,
* `undefined` otherwise.
*
* @param {object} error - The error object.
* @returns {string} The ID.
*/
export const getBackendErrorId = error =>
isBackend(error) ? error.message.split(' ')[0] : undefined
/**
* Returns the id of the error details, used as message id.
*
* @param {object} details - The backend error details object.
* @returns {string} The ID.
*/
export const getBackendErrorDetailsId = details => `error:${details.namespace}:${details.name}`
/**
* Returns error details.
*
* @param {object} error - The backend error object.
* @returns {object} - The details of `error`.
*/
export const getBackendErrorDetails = error =>
isBackendErrorDetails(error) ? error : error.details[0]
/**
* Returns the error details' first path error, if any.
*
* @param {object} details - The backend error details object.
* @returns {object} - The first path error if exists, `undefined` otherwise.
*/
export const getBackendErrorDetailsPathError = details => {
if (!isBackendErrorDetails(details) || !hasValidDetails(details)) {
return undefined
}
const detailsDetails = details.details[0]
if (
!('path_errors' in detailsDetails) ||
!(detailsDetails.path_errors instanceof Array) ||
detailsDetails.path_errors.length === 0
) {
return undefined
}
return detailsDetails.path_errors[0]
}
/**
* Returns the name of the error extracted from the details array.
*
* @param {object} error - The backend error object.
* @returns {string} - The error name.
*/
export const getBackendErrorName = error =>
hasValidDetails(error) ? error.details[0].name : undefined
/**
* Returns the default message of the error, used as fallback translation.
*
* @param {object} error - The backend error object.
* @returns {string} The default message.
*/
export const getBackendErrorDefaultMessage = error =>
hasValidDetails(error)
? error.details[0].message_format || error.details[0].message
: error.message.replace(/^error:[a-z0-9-_.:/]*\s/, '')
/**
* Returns whether the error has one or more cause properties.
*
* @param {object} error - The backend error object.
* @returns {boolean} - Whether the error has one or more cuase properties.
*/
export const hasCauses = error => isBackendErrorDetails(error) && 'cause' in error
/**
* Returns the root cause of the backend error.
*
* @param {object} error - The backend error object.
* @returns {object} - The root cause of `error`.
*/
export const getBackendErrorRootCause = error => {
let rootCause
if (hasCauses(error)) {
rootCause = error.cause
} else {
rootCause = getBackendErrorDetails(error)
}
while ('cause' in rootCause) {
rootCause = rootCause.cause
}
return rootCause
}
/**
* Returns the attributes of the backend error message, if any.
*
* @param {object} error - The backend error object.
* @returns {string} The attributes or undefined.
*/
export const getBackendErrorMessageAttributes = error =>
hasValidDetails(error) ? error.details[0].attributes : undefined
/**
* Returns the correlation ID of the backend error message if present,
* `undefined` otherwise.
*
* @param {object} error - The backend error object.
* @returns {string} The correlation ID.
*/
export const getCorrelationId = error =>
hasValidDetails(error) ? error.details[0].correlation_id : undefined
/**
* Adapts the error object to props of message object, if possible.
*
* @param {object} error - The backend error object.
* @param {boolean} each - Whether to return an array of all messages contained
* in the error object, including causes and details.
* @returns {object|Array} Message props of the error object, or generic error object.
*/
export const toMessageProps = (error, each = false) => {
const props = []
// Check if it is an error message and transform it to a intl message.
if (isBackendErrorDetails(error) || isBackend(error)) {
const pathErrors = getBackendErrorDetailsPathError(error)
let errorDetails
if (
(hasValidDetails(error) && isBackendErrorDetails(error.details[0])) ||
isBackendErrorDetails(error)
) {
if (isBackendErrorDetails(pathErrors)) {
errorDetails = pathErrors
} else if ('details' in error && isBackendErrorDetails(error.details[0])) {
errorDetails = error.details[0]
} else {
errorDetails = error
}
if (hasCauses(errorDetails)) {
// Use the root cause if any.
const rootCause = getBackendErrorRootCause(errorDetails)
// If the attributes are missing values, use the generated default values based on the message.
const messageValues = rootCause?.message_format?.match(/[^{}]+(?=})/g) || []
const values = messageValues.reduce(
(obj, val) => {
if (!(val in obj)) {
obj[val] = `{${val}}`
}
return obj
},
{ ...rootCause.attributes },
)
props.push({
content: {
id: getBackendErrorDetailsId(rootCause),
defaultMessage: rootCause.message_format,
},
values,
})
}
props.push({
content: {
id: getBackendErrorDetailsId(errorDetails),
defaultMessage: errorDetails.message_format,
},
values: errorDetails.attributes,
})
} else {
props.push({
content: {
id: getBackendErrorId(error),
defaultMessage: getBackendErrorDefaultMessage(error),
},
})
}
} else if (isFrontend(error)) {
props.push({
content: error.errorMessage,
title: error.errorTitle,
})
} else if (isTranslated(error)) {
// Fall back to normal message.
props.push({ content: error })
} else if (isConnectionFailureError(error)) {
props.push({ content: errorMessages.connectionFailure })
}
if (props.length === 0) {
// Fall back to generic error message.
props.push({ content: errorMessages.genericError })
}
return each ? props : props[0]
}
/**
* `ingestError` provides a unified error ingestion handler, which manages
* forwarding to Sentry and other logic that should be applied when errors
* occur. The error object is not modified.
*
* @param {object} error - The error object.
* @param {object} extras - Sentry extras to be sent.
* @param {object} tags - Sentry tags to be sent.
*/
export const ingestError = (error, extras = {}, tags = {}) => {
// Log the error when in development mode
errorLog(error)
// Send to Sentry if necessary.
if (isSentryWorthy(error)) {
Sentry.withScope(scope => {
scope.setTags({ ...tags, frontendOrigin: true })
scope.setFingerprint(isBackend(error) ? getBackendErrorId(error) : error)
if (isPlainObject(error)) {
scope.setExtras({ ...error, ...extras })
} else {
scope.setExtras({ error, ...extras })
}
const passedError = error instanceof Error ? error : new Error(getSentryErrorTitle(error))
warn('The above error was considered Sentry-worthy.', 'It was captured as:', passedError)
Sentry.captureException(passedError)
})
}
}
/**
* Maps claim-related backend errors to appropriate user messages.
*
* @param {object} error - The error object.
* @returns {object|undefined} - The corresponding error message, or undefined if no match.
*/
export const getClaimGatewayErrorMessage = error => {
const m = defineMessages({
notFound: "Gateway doesn't exist. Please confirm that the gateway EUI is correct.",
subscriptionNotActive:
'There is no gateway subscription attached or active. Please get a <link>Gateway Subscription</link> or activate your subscription following the steps in the documentation and try again.',
activationCodeExpired:
'The activation code has expired. To reactivate it, extend your <link>Gateway Subscription</link>.',
permissionDenied: 'The owner token is invalid.',
})
const rootCause = getBackendErrorRootCause(error)
const errorCode = rootCause?.code
const backendErrorMessage = rootCause?.message_format
switch (errorCode) {
case 5: // NOT_FOUND
return m.notFound
case 9: // FAILED_PRECONDITION
if (backendErrorMessage.includes('gateway subscription not attached and active')) {
return m.subscriptionNotActive
}
if (backendErrorMessage.includes('activation code expired')) {
return m.activationCodeExpired
}
return undefined
case 7: // PERMISSION_DENIED
return m.permissionDenied
default:
return undefined
}
}