Visitar URL original
feat(errors): Recognize Feathers errors across duplicate module copies by fratzinger · Pull Request #3726 · feathersjs/feathers · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion packages/feathers/src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it } from 'vitest'
import { describe, it, vi } from 'vitest'
import assert from 'assert'
import * as errors from '../src/errors.js'

Expand Down Expand Up @@ -203,6 +203,55 @@ describe('@feathersjs/errors', () => {
assert.ok(error.constructor === errors.NotFound)
assert.ok(error.constructor.name === 'NotFound')
})

it('does not match custom subclasses by name', () => {
class CustomError extends errors.BadRequest {}

assert.ok(new CustomError() instanceof errors.BadRequest)
assert.ok(!(new errors.BadRequest() instanceof CustomError))
})
})

describe('isFeathersError', () => {
it('checks for any Feathers error', () => {
assert.ok(errors.isFeathersError(new errors.NotFound()))
assert.ok(!errors.isFeathersError(new Error()))
assert.ok(!errors.isFeathersError({ ...new errors.NotFound(), type: 'FeathersError' }))
assert.ok(!errors.isFeathersError(null))
assert.ok(!errors.isFeathersError('NotFound'))
})

it('checks for a specific error by name or code', () => {
const error: unknown = new errors.NotFound()

assert.ok(errors.isFeathersError(error, 'NotFound'))
assert.ok(errors.isFeathersError(error, 404))
assert.ok(!errors.isFeathersError(error, 'BadRequest'))
assert.ok(!errors.isFeathersError(error, 400))
assert.ok(!errors.isFeathersError(error, 'toString' as any))

if (errors.isFeathersError(error, 404)) {
const notFound: errors.NotFound = error
assert.strictEqual(notFound.code, 404)
}
})

it('recognizes errors from a duplicate module instance', async () => {
vi.resetModules()
const duplicate: typeof errors = await import('../src/errors.js')

assert.notStrictEqual(duplicate.FeathersError, errors.FeathersError)

const error = new duplicate.NotFound('Not here')

assert.ok(error instanceof errors.FeathersError)
assert.ok(error instanceof errors.NotFound)
assert.ok(!(error instanceof errors.BadRequest))
assert.ok(errors.isFeathersError(error, 'NotFound'))
assert.ok(errors.isFeathersError(error, 404))
assert.ok(!errors.isFeathersError(error, 'BadRequest'))
assert.ok(new errors.BadRequest() instanceof duplicate.BadRequest)
})
})

describe('successful error creation', () => {
Expand Down
71 changes: 71 additions & 0 deletions packages/feathers/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,37 @@ interface ErrorProperties extends Omit<FeathersErrorJSON, 'message'> {
type: string
}

// Registered globally so that errors are recognized across duplicate package
// versions, CJS/ESM builds and realms where a plain `instanceof` would fail
const FEATHERS_ERROR = Symbol.for('feathers.error')

const hasPrototypeInstance = Function.prototype[Symbol.hasInstance]
const hasOwn = (obj: object, key: PropertyKey) => Object.prototype.hasOwnProperty.call(obj, key)

export class FeathersError extends Error {
readonly type: string
readonly code: number
readonly className: string
readonly data: any
readonly errors: any

static [Symbol.hasInstance]<T>(this: abstract new (...args: any[]) => T, value: unknown): value is T {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand what this is for. From what I know Symbol.for already ensures that you get the same Symbol instance across the entire runtime. I thought just defining the property and checking if it exists should work.

if (hasPrototypeInstance.call(this, value)) {
return true
}

const name: unknown =
value !== null && typeof value === 'object' ? (value as any)[FEATHERS_ERROR] : undefined

if (typeof name !== 'string') {
return false
}

const ErrorClass = this as unknown

return ErrorClass === FeathersError || (hasOwn(errorsByName, name) && errorsByName[name] === ErrorClass)
}

constructor(err: ErrorMessage, name: string, code: number, className: string, _data: any) {
let msg = typeof err === 'string' ? err : 'Error'
const properties: ErrorProperties = {
Expand All @@ -42,6 +66,8 @@ export class FeathersError extends Error {

super(msg)
Object.assign(this, properties)
// Non-enumerable so that it is not copied by spreading or `Object.assign`
Object.defineProperty(this, FEATHERS_ERROR, { value: name })
}

toJSON() {
Expand Down Expand Up @@ -193,6 +219,7 @@ export interface Errors {
NotAcceptable: NotAcceptable
Timeout: Timeout
Conflict: Conflict
Gone: Gone
LengthRequired: LengthRequired
Unprocessable: Unprocessable
TooManyRequests: TooManyRequests
Expand All @@ -209,6 +236,7 @@ export interface Errors {
406: NotAcceptable
408: Timeout
409: Conflict
410: Gone
411: LengthRequired
422: Unprocessable
429: TooManyRequests
Expand All @@ -218,6 +246,26 @@ export interface Errors {
503: Unavailable
}

const errorsByName: Record<string, abstract new (...args: any[]) => FeathersError> = {
BadRequest,
NotAuthenticated,
PaymentError,
Forbidden,
NotFound,
MethodNotAllowed,
NotAcceptable,
Timeout,
Conflict,
Gone,
LengthRequired,
Unprocessable,
TooManyRequests,
GeneralError,
NotImplemented,
BadGateway,
Unavailable
}

export const errors = {
FeathersError,
BadRequest,
Expand All @@ -229,6 +277,7 @@ export const errors = {
NotAcceptable,
Timeout,
Conflict,
Gone,
LengthRequired,
Unprocessable,
TooManyRequests,
Expand All @@ -255,6 +304,28 @@ export const errors = {
503: Unavailable
}

/**
* Type guard for Feathers errors that also works across duplicate package versions.
* Optionally checks for a specific error by name or status code.
*
* @example
* isFeathersError(error) // error is FeathersError
* isFeathersError(error, 'NotFound') // error is NotFound
* isFeathersError(error, 404) // error is NotFound
*/
export function isFeathersError<K extends keyof Errors = 'FeathersError'>(
error: unknown,
type?: K
): error is Errors[K] {
if (type === undefined) {
return error instanceof FeathersError
}

const ErrorClass = hasOwn(errors, type) ? errors[type as keyof typeof errors] : undefined

return ErrorClass !== undefined && error instanceof ErrorClass
}

export function convert(error: any, status?: number) {
if (!error) {
return error
Expand Down
4 changes: 2 additions & 2 deletions packages/feathers/src/http/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Params, Service, Query } from '../index.js'
import type { HookContext, NextFunction } from '../hooks/index.js'
import { BadRequest, FeathersError } from '../errors.js'
import { BadRequest, isFeathersError } from '../errors.js'
import { parse } from '../qs.js'

interface RouteLookup {
Expand Down Expand Up @@ -80,7 +80,7 @@ export function errorHandler() {
await next()
} catch (error: any) {
const errorData = error.toJSON ? error.toJSON() : { message: error.message }
const status = error instanceof FeathersError ? error.code || 500 : 500
const status = isFeathersError(error) ? error.code || 500 : 500
const origin = context.request.headers.get('origin') || '*'

context.result = Response.json(errorData, {
Expand Down
23 changes: 23 additions & 0 deletions website/content/api/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,29 @@ Feathers errors contain the following fields:
To convert a Feathers error back to an object call `error.toJSON()`. A normal `console.log` of a JavaScript Error object will not automatically show those additional properties described above (even though they can be accessed directly).
::

## Checking errors

Use `isFeathersError` to check if a value is a Feathers error. It is a type guard and can optionally check for a specific error by name or status code:

```ts
import { isFeathersError } from 'feathers/errors'

try {
await app.service('messages').get(1)
} catch (error: unknown) {
if (isFeathersError(error, 'NotFound')) {
// error is NotFound
} else if (isFeathersError(error, 403)) {
// error is Forbidden
} else if (isFeathersError(error)) {
// error is FeathersError
console.log(error.code)
}
}
```

`isFeathersError` and `instanceof` (e.g. `error instanceof NotFound`) also recognize errors created by a different copy of `feathers`, for example when multiple versions are installed or CommonJS and ES modules are mixed. Custom errors (see below) are only recognized by `instanceof` within the same copy of the module that defines them.

## Custom errors

You can create custom errors by extending from the `FeathersError` class and calling its constructor with `(message, name, code, className, data)`:
Expand Down
Loading