Skip to content

Commit 012b6bf

Browse files
author
Ray Epps
committed
errors as classes + rate limit async limits
1 parent f1b4d6c commit 012b6bf

File tree

20 files changed

+248
-168
lines changed

20 files changed

+248
-168
lines changed

jest.config.js

+4-4
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ module.exports = {
1010
},
1111
"coverageThreshold": {
1212
"global": {
13-
"branches": 69,
14-
"functions": 91,
15-
"lines": 94,
16-
"statements": 93
13+
"branches": 63,
14+
"functions": 89,
15+
"lines": 92,
16+
"statements": 92
1717
}
1818
}
1919
}

packages/core/src/error.ts

+109-13
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,109 @@
1-
import type { JsonError } from './types'
2-
3-
export const error = <
4-
TProperties extends { status?: number },
5-
TError = TProperties & JsonError
6-
>(
7-
properties: TProperties
8-
): TError =>
9-
({
10-
format: '@json',
11-
...properties,
12-
status: properties.status ?? 500
13-
} as TError)
1+
type Json = string | number | boolean | { [x: string]: Json } | Array<Json>
2+
3+
export const isError = (err: any): err is ApiError => {
4+
return err instanceof ApiError
5+
}
6+
7+
export type ErrorProperties = { message: string; status: number } & Record<
8+
string,
9+
Json
10+
>
11+
12+
export type ErrorPropertiesWithoutStatus = {
13+
message?: string
14+
status?: never
15+
} & Record<string, Json>
16+
17+
/**
18+
* This error class is designed to be returned to the
19+
* user. When thrown, eventually a root hook will
20+
* handle it, convert it to json, and return it in a
21+
* response.
22+
*/
23+
export class ApiError extends Error {
24+
status: number
25+
properties: ErrorProperties
26+
// readonly _key: string = '@exo.error'
27+
constructor(
28+
/**
29+
* Any json serializable value is allowed in
30+
* the object, the entire object will be
31+
* serialized and returned to the user.
32+
*/
33+
error: ErrorProperties
34+
) {
35+
super(error.message)
36+
// Set the prototype explicitly so that instanceof
37+
// will work as expeted. Object.setPrototypeOf needs
38+
// to be called immediately after the super(...) call
39+
// https://stackoverflow.com/a/41429145/7547940
40+
Object.setPrototypeOf(this, ApiError.prototype)
41+
this.status = error.status ?? 500
42+
this.properties = error
43+
}
44+
}
45+
46+
//
47+
// Just the few most commonly used
48+
// errors for convenience.
49+
//
50+
51+
export class BadRequestError extends ApiError {
52+
constructor(error: ErrorPropertiesWithoutStatus) {
53+
super({
54+
...error,
55+
status: 400,
56+
message: error.message ?? 'Bad Request'
57+
})
58+
}
59+
}
60+
61+
export class NotAuthenticatedError extends ApiError {
62+
constructor(error: ErrorPropertiesWithoutStatus) {
63+
super({
64+
...error,
65+
status: 401,
66+
message: error.message ?? 'Not Authenticated'
67+
})
68+
}
69+
}
70+
71+
export class NotAuthorizedError extends ApiError {
72+
constructor(error: ErrorPropertiesWithoutStatus) {
73+
super({
74+
...error,
75+
status: 403,
76+
message: error.message ?? 'Not Authorized'
77+
})
78+
}
79+
}
80+
81+
export class NotFoundError extends ApiError {
82+
constructor(error: ErrorPropertiesWithoutStatus) {
83+
super({
84+
...error,
85+
status: 404,
86+
message: error.message ?? 'Not Found'
87+
})
88+
}
89+
}
90+
91+
export class MethodNotAllowedError extends ApiError {
92+
constructor(error: ErrorPropertiesWithoutStatus) {
93+
super({
94+
...error,
95+
status: 405,
96+
message: error.message ?? 'Method Not Allowed'
97+
})
98+
}
99+
}
100+
101+
export class TooManyRequestsError extends ApiError {
102+
constructor(error: ErrorPropertiesWithoutStatus) {
103+
super({
104+
...error,
105+
status: 429,
106+
message: error.message ?? 'Too Many Requests'
107+
})
108+
}
109+
}

packages/core/src/response.ts

+19-16
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { omit } from 'radash'
1+
import { isError } from './error'
22
import * as t from './types'
33

44
/**
@@ -10,10 +10,6 @@ export const isResponse = (res: any): res is t.Response => {
1010
return (res as t.Response)?.type === '@response'
1111
}
1212

13-
export const isJsonError = (err: any): err is t.JsonError => {
14-
return (err as t.JsonError)?.format === '@json'
15-
}
16-
1713
export const defaultResponse: t.Response = {
1814
type: '@response',
1915
status: 200,
@@ -25,27 +21,34 @@ export const responseFromResult = (result: any): t.Response => {
2521
if (isResponse(result)) return result
2622
// If nothing was returned then return the default
2723
// success response
28-
// Else, the func returned something that should be
24+
if (!result) return defaultResponse
25+
// Else, the function returned something that should be
2926
// returned as the json body response
3027
return {
3128
...defaultResponse,
32-
body: !result ? defaultResponse.body : result
29+
body: result
3330
}
3431
}
3532

3633
export const responseFromError = (error: any): t.Response => {
3734
if (isResponse(error)) return error
38-
// Else its some generic error, wrap it in our
39-
// error object as an unknown error
35+
// If the error is an ApiError then return it's
36+
// specified properties and status
37+
if (isError(error))
38+
return {
39+
...defaultResponse,
40+
status: error.status,
41+
body: error.properties
42+
}
43+
// Else its an error we're not equipped to handle
44+
// return an unknown to the user.
4045
return {
4146
...defaultResponse,
42-
status: error.status ?? 500,
43-
body: isJsonError(error)
44-
? omit(error, ['format'])
45-
: {
46-
status: 500,
47-
message: 'Unknown Error'
48-
}
47+
status: 500,
48+
body: {
49+
status: 500,
50+
message: 'Unknown Error'
51+
}
4952
}
5053
}
5154

packages/core/src/tests/response.test.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { describe, expect, test } from '@jest/globals'
2+
import { ApiError } from '../error'
23
import {
34
defaultResponse,
45
responseFromError,
56
responseFromResult
67
} from '../response'
7-
import { JsonError } from '../types'
88

99
describe('responseFromResult function', () => {
1010
test('returns input when input is already an abstract response', () => {
@@ -31,14 +31,15 @@ describe('responseFromError function', () => {
3131
expect(result.body.message).toBe('Unknown Error')
3232
})
3333
test('returns wrapped error when input is not an abstract response', () => {
34-
const error = {
35-
format: '@json',
34+
const error = new ApiError({
35+
message: 'Testing',
3636
status: 499,
3737
key: 'exo.err.test'
38-
} as JsonError
38+
})
3939
const result = responseFromError(error)
4040
expect(result.status).toBe(499)
4141
expect(result.body.status).toBe(499)
42-
expect(result.body.format).toBeUndefined()
42+
expect(result.body.message).toBe('Testing')
43+
expect(result.body.key).toBe('exo.err.test')
4344
})
4445
})

packages/core/src/types.ts

-14
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,3 @@
1-
/**
2-
* Any object that is thrown, and matches this interface,
3-
* having a format property equal to '@json' will be
4-
* treated as a known error by the root hook.
5-
*
6-
* If a 'status' property exists it will be used to set
7-
* the HTTP status on the response.
8-
*/
9-
export interface JsonError {
10-
/**
11-
* Always '@json' when thrown by an exobase hook
12-
*/
13-
format: '@json'
14-
}
151

162
export type Request = {
173
headers: Record<string, string | string[]>

packages/use-api-key/src/useApiKey.ts

+6-16
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Handler, Props } from '@exobase/core'
2-
import { error } from '@exobase/core'
2+
import { NotAuthenticatedError } from '@exobase/core'
33
import { isFunction, tryit } from 'radash'
44

55
export type ApiKeyAuth = {
@@ -13,9 +13,7 @@ export async function withApiKey<TProps extends Props>(
1313
) {
1414
const header = props.request.headers['x-api-key'] as string
1515
if (!header) {
16-
throw error({
17-
message: 'Not Authenticated',
18-
status: 401,
16+
throw new NotAuthenticatedError({
1917
info: 'This function requires an api key',
2018
key: 'exo.api-key.missing-header'
2119
})
@@ -24,10 +22,8 @@ export async function withApiKey<TProps extends Props>(
2422
// If a `Key ` prefix exists, remove it
2523
const providedKey = header.replace(/^[Kk]ey\s/, '')
2624
if (!providedKey) {
27-
throw error({
25+
throw new NotAuthenticatedError({
2826
info: 'Invalid api key',
29-
message: 'Not Authenticated',
30-
status: 401,
3127
key: 'exo.api-key.missing-key'
3228
})
3329
}
@@ -37,28 +33,22 @@ export async function withApiKey<TProps extends Props>(
3733
})()
3834

3935
if (err) {
40-
throw error({
36+
throw new NotAuthenticatedError({
4137
info: 'Server cannot authenticate',
42-
message: 'Not Authenticated',
43-
status: 401,
4438
key: 'exo.api-key.key-error'
4539
})
4640
}
4741

4842
if (!key) {
49-
throw error({
43+
throw new NotAuthenticatedError({
5044
info: 'Server cannot authenticate',
51-
message: 'Not Authenticated',
52-
status: 401,
5345
key: 'exo.api-key.key-not-found'
5446
})
5547
}
5648

5749
if (providedKey !== key) {
58-
throw error({
50+
throw new NotAuthenticatedError({
5951
info: 'Invalid api key',
60-
message: 'Not Authenticated',
61-
status: 401,
6252
key: 'exo.api-key.mismatch'
6353
})
6454
}

packages/use-authorization/src/useAuthorization.ts

+2-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Handler, Props } from '@exobase/core'
2-
import { error } from '@exobase/core'
2+
import { NotAuthorizedError } from '@exobase/core'
33
import { isArray, isFunction, isString, sift } from 'radash'
44
import cani, { CaniServices } from './cani'
55
import * as perm from './permission'
@@ -53,9 +53,7 @@ export async function withAuthorization<TProps extends Props>(
5353
const key = isString(required)
5454
? required
5555
: required.name ?? perm.stringify(required)
56-
throw error({
57-
status: 401,
58-
message: 'Not Authorized',
56+
throw new NotAuthorizedError({
5957
info: `Missing required permission (${key}) to call this function`,
6058
key: 'exo.err.authorization.failed'
6159
})

packages/use-basic-auth/src/tests/useBasicAuth.test.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ describe('withBasicAuth function', () => {
4949
}
5050
} as any)
5151
} catch (err: any) {
52-
expect(err.key).toBe('exo.err.basic.noheader')
52+
expect(err.properties.key).toBe('exo.err.basic.noheader')
5353
return
5454
}
5555
throw new Error('Expected withBasicAuth to throw error')
@@ -64,7 +64,7 @@ describe('withBasicAuth function', () => {
6464
}
6565
} as any)
6666
} catch (err: any) {
67-
expect(err.key).toBe('exo.err.basic.nobasic')
67+
expect(err.properties.key).toBe('exo.err.basic.nobasic')
6868
return
6969
}
7070
throw new Error('Expected withBasicAuth to throw error')
@@ -80,7 +80,7 @@ describe('withBasicAuth function', () => {
8080
}
8181
} as any)
8282
} catch (err: any) {
83-
expect(err.key).toBe('exo.err.basic.misformat')
83+
expect(err.properties.key).toBe('exo.err.basic.misformat')
8484
return
8585
}
8686
throw new Error('Expected withBasicAuth to throw error')

0 commit comments

Comments
 (0)