core-services-sdk 1.2.1 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/fastify/error-codes.js +7 -0
- package/src/fastify/error-handlers/with-error-handling.js +90 -0
- package/src/fastify/error-handlers/with-error-handling.types.js +10 -0
- package/src/fastify/index.js +2 -0
- package/src/http/HttpError.js +58 -31
- package/src/index.js +1 -0
- package/src/mongodb/index.js +1 -1
- package/tests/HttpError.test.js +80 -0
- package/tests/with-error-handling.test.js +124 -0
package/package.json
CHANGED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import httpStatus from 'http-status'
|
|
2
|
+
|
|
3
|
+
import { HttpError } from '../../http/HttpError.js'
|
|
4
|
+
import { GENERAL_ERROR } from '../error-codes.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generic error-handling wrapper that logs and throws a safe error.
|
|
8
|
+
* @param {object} log - Logger with an .error method.
|
|
9
|
+
* @param {HttpError} defaultError - Error to throw if original error is not an HttpError.
|
|
10
|
+
* @returns {(funcToInvoke: () => Promise<any>) => Promise<any>}
|
|
11
|
+
*/
|
|
12
|
+
export const withErrorHandling =
|
|
13
|
+
(log, defaultError) => async (funcToInvoke) => {
|
|
14
|
+
try {
|
|
15
|
+
return await funcToInvoke()
|
|
16
|
+
} catch (error) {
|
|
17
|
+
log.error(`[withErrorHandling] - Error: ${error?.stack || error}`)
|
|
18
|
+
|
|
19
|
+
if (!error) {
|
|
20
|
+
throw defaultError
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (HttpError.isInstanceOf(error)) {
|
|
24
|
+
throw error
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const httpError = HttpError.FromError(error)
|
|
28
|
+
|
|
29
|
+
throw httpError
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Executes function with error handling and replies on failure.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} params
|
|
37
|
+
* @param {Reply} params.reply - Fastify reply object
|
|
38
|
+
* @param {Logger} params.log - Logger object
|
|
39
|
+
* @param {HttpError} [params.defaultError] - Fallback error
|
|
40
|
+
*/
|
|
41
|
+
export const withErrorHandlingReply =
|
|
42
|
+
({ reply, log, defaultError = new HttpError(GENERAL_ERROR) }) =>
|
|
43
|
+
async (funcToInvoke) => {
|
|
44
|
+
try {
|
|
45
|
+
return await withErrorHandling(log, defaultError)(funcToInvoke)
|
|
46
|
+
} catch (error) {
|
|
47
|
+
const { code, httpStatusText, httpStatusCode } = error
|
|
48
|
+
reply.status(httpStatusCode).send({ code, httpStatusText })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Executes function with error handling and replies on failure.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} params
|
|
56
|
+
* @param {Reply} params.reply - Fastify reply object
|
|
57
|
+
* @param {Logger} params.log - Logger object
|
|
58
|
+
* @param {HttpError} [params.defaultError] - Fallback error
|
|
59
|
+
*/
|
|
60
|
+
export const replyOnErrorOnly =
|
|
61
|
+
({ reply, log, defaultError = new HttpError(GENERAL_ERROR) }) =>
|
|
62
|
+
async (funcToInvoke) => {
|
|
63
|
+
try {
|
|
64
|
+
return await withErrorHandling(log, defaultError)(funcToInvoke)
|
|
65
|
+
} catch (error) {
|
|
66
|
+
log.error(`[replyOnErrorOnly] - Error: ${error?.stack || error}`)
|
|
67
|
+
const isHttp = HttpError.isInstanceOf(error)
|
|
68
|
+
const errorMerged = isHttp
|
|
69
|
+
? error
|
|
70
|
+
: new HttpError({
|
|
71
|
+
...defaultError,
|
|
72
|
+
message: error?.message || defaultError.message,
|
|
73
|
+
code: error?.code || defaultError.code,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
if (!isHttp && error?.stack) {
|
|
77
|
+
errorMerged.stack = error.stack
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const exposed =
|
|
81
|
+
errorMerged.message ?? errorMerged.code ?? GENERAL_ERROR.httpStatusText
|
|
82
|
+
|
|
83
|
+
const status =
|
|
84
|
+
errorMerged.httpStatusCode && errorMerged.httpStatusCode in httpStatus
|
|
85
|
+
? errorMerged.httpStatusCode
|
|
86
|
+
: httpStatus.INTERNAL_SERVER_ERROR
|
|
87
|
+
|
|
88
|
+
reply.status(status).send({ error: exposed })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {object} Reply
|
|
3
|
+
* @property {(code: number) => Reply} status - Sets the HTTP status code
|
|
4
|
+
* @property {(payload: any) => void} send - Sends the response
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {object} Logger
|
|
9
|
+
* @property {(message: string, ...args: any[]) => void} error - Logs an error message
|
|
10
|
+
*/
|
package/src/http/HttpError.js
CHANGED
|
@@ -1,47 +1,74 @@
|
|
|
1
|
-
import
|
|
1
|
+
import httpStatus from 'http-status'
|
|
2
2
|
|
|
3
|
-
const DEFAULT_ERROR = {
|
|
4
|
-
httpStatusCode: httpStatus.INTERNAL_SERVER_ERROR,
|
|
5
|
-
code: 'UNKNOWN',
|
|
6
|
-
httpStatusText: httpStatus[httpStatus.INTERNAL_SERVER_ERROR],
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
*
|
|
11
|
-
*/
|
|
12
3
|
export class HttpError extends Error {
|
|
13
|
-
/** @type {
|
|
14
|
-
httpStatusCode
|
|
15
|
-
/** @type {string} */
|
|
4
|
+
/** @type {string|undefined} */
|
|
16
5
|
code
|
|
17
|
-
|
|
6
|
+
|
|
7
|
+
/** @type {number|undefined} */
|
|
8
|
+
httpStatusCode
|
|
9
|
+
|
|
10
|
+
/** @type {string|undefined} */
|
|
18
11
|
httpStatusText
|
|
19
12
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} [error]
|
|
15
|
+
* @param {string} [error.code]
|
|
16
|
+
* @param {string} [error.message]
|
|
17
|
+
* @param {number} [error.httpStatusCode]
|
|
18
|
+
* @param {string} [error.httpStatusText]
|
|
19
|
+
*/
|
|
20
|
+
constructor(error = {}) {
|
|
21
|
+
const { code, message, httpStatusCode, httpStatusText } = error
|
|
22
|
+
|
|
23
|
+
super(
|
|
24
|
+
message ||
|
|
25
|
+
(httpStatusCode && httpStatus[httpStatusCode]) ||
|
|
26
|
+
code ||
|
|
27
|
+
'Unknown error',
|
|
28
|
+
)
|
|
29
|
+
|
|
30
30
|
this.code = code
|
|
31
|
-
this.
|
|
32
|
-
this.
|
|
31
|
+
this.httpStatusCode = httpStatusCode
|
|
32
|
+
this.httpStatusText =
|
|
33
|
+
httpStatusText || (httpStatusCode && httpStatus[httpStatusCode])
|
|
33
34
|
|
|
34
35
|
if (typeof Error.captureStackTrace === 'function') {
|
|
35
36
|
Error.captureStackTrace(this, this.constructor)
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
/**
|
|
40
|
-
*
|
|
41
|
-
* @param {object} instance
|
|
42
|
-
* @returns {boolean}
|
|
43
|
-
*/
|
|
44
40
|
static isInstanceOf(instance) {
|
|
45
41
|
return instance instanceof HttpError
|
|
46
42
|
}
|
|
43
|
+
|
|
44
|
+
static [Symbol.hasInstance](instance) {
|
|
45
|
+
return (
|
|
46
|
+
instance &&
|
|
47
|
+
typeof instance === 'object' &&
|
|
48
|
+
'message' in instance &&
|
|
49
|
+
'httpStatusCode' in instance &&
|
|
50
|
+
'httpStatusText' in instance
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
toJSON() {
|
|
55
|
+
return {
|
|
56
|
+
code: this.code,
|
|
57
|
+
message: this.message,
|
|
58
|
+
httpStatusCode: this.httpStatusCode,
|
|
59
|
+
httpStatusText: this.httpStatusText,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static FromError(error) {
|
|
64
|
+
const httpError = new HttpError({
|
|
65
|
+
code: error.code || 'UNHANDLED_ERROR',
|
|
66
|
+
message: error.message || 'An unexpected error occurred',
|
|
67
|
+
httpStatusCode: httpStatus.INTERNAL_SERVER_ERROR,
|
|
68
|
+
httpStatusText: httpStatus[httpStatus.INTERNAL_SERVER_ERROR],
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
httpError.stack = error.stack
|
|
72
|
+
return httpError
|
|
73
|
+
}
|
|
47
74
|
}
|
package/src/index.js
CHANGED
package/src/mongodb/index.js
CHANGED
|
@@ -34,7 +34,7 @@ import { mongoConnect } from './connect.js'
|
|
|
34
34
|
*
|
|
35
35
|
* await client.close(); // Close connection manually
|
|
36
36
|
*/
|
|
37
|
-
export const initializeMongoDb = async ({ config, collectionNames }) => {
|
|
37
|
+
export const initializeMongoDb = async ({ config, collectionNames = {} }) => {
|
|
38
38
|
const client = await mongoConnect(config)
|
|
39
39
|
const db = client.db(config.options.dbName)
|
|
40
40
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { HttpError } from '../src/http/HttpError.js'
|
|
3
|
+
import httpStatus from 'http-status'
|
|
4
|
+
|
|
5
|
+
describe('HttpError', () => {
|
|
6
|
+
it('creates an error with all fields explicitly set', () => {
|
|
7
|
+
const err = new HttpError({
|
|
8
|
+
code: 'SOME_ERROR',
|
|
9
|
+
message: 'Something went wrong',
|
|
10
|
+
httpStatusCode: httpStatus.BAD_REQUEST,
|
|
11
|
+
httpStatusText: httpStatus[httpStatus.BAD_REQUEST],
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
expect(err).toBeInstanceOf(HttpError)
|
|
15
|
+
expect(err.message).toBe('Something went wrong')
|
|
16
|
+
expect(err.code).toBe('SOME_ERROR')
|
|
17
|
+
expect(err.httpStatusCode).toBe(httpStatus.BAD_REQUEST)
|
|
18
|
+
expect(err.httpStatusText).toBe(httpStatus[httpStatus.BAD_REQUEST])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('falls back to code if message and httpStatusCode are missing', () => {
|
|
22
|
+
const err = new HttpError({ code: 'MY_CODE' })
|
|
23
|
+
expect(err.message).toBe('MY_CODE')
|
|
24
|
+
expect(err.code).toBe('MY_CODE')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('falls back to status text if only httpStatusCode is provided', () => {
|
|
28
|
+
const err = new HttpError({ httpStatusCode: httpStatus.NOT_FOUND })
|
|
29
|
+
expect(err.message).toBe(httpStatus[httpStatus.NOT_FOUND])
|
|
30
|
+
expect(err.httpStatusText).toBe(httpStatus[httpStatus.NOT_FOUND])
|
|
31
|
+
expect(err.httpStatusCode).toBe(httpStatus.NOT_FOUND)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('falls back to "Unknown error" if nothing is provided', () => {
|
|
35
|
+
const err = new HttpError()
|
|
36
|
+
expect(err.message).toBe('Unknown error')
|
|
37
|
+
expect(err.code).toBeUndefined()
|
|
38
|
+
expect(err.httpStatusCode).toBeUndefined()
|
|
39
|
+
expect(err.httpStatusText).toBeUndefined()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('uses httpStatusText if not explicitly set but httpStatusCode is present', () => {
|
|
43
|
+
const err = new HttpError({ httpStatusCode: httpStatus.UNAUTHORIZED })
|
|
44
|
+
expect(err.httpStatusText).toBe(httpStatus[httpStatus.UNAUTHORIZED])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('supports static isInstanceOf check', () => {
|
|
48
|
+
const err = new HttpError()
|
|
49
|
+
expect(HttpError.isInstanceOf(err)).toBe(true)
|
|
50
|
+
expect(HttpError.isInstanceOf(new Error())).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('supports Symbol.hasInstance (dynamic instanceof)', () => {
|
|
54
|
+
const fake = {
|
|
55
|
+
code: 'FAKE',
|
|
56
|
+
message: 'Fake error',
|
|
57
|
+
httpStatusCode: httpStatus.UNAUTHORIZED,
|
|
58
|
+
httpStatusText: httpStatus[httpStatus.UNAUTHORIZED],
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
expect(fake instanceof HttpError).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('serializes correctly via toJSON', () => {
|
|
65
|
+
const err = new HttpError({
|
|
66
|
+
code: 'TOO_FAST',
|
|
67
|
+
message: 'You are too fast',
|
|
68
|
+
httpStatusCode: httpStatus.TOO_MANY_REQUESTS,
|
|
69
|
+
httpStatusText: httpStatus[httpStatus.TOO_MANY_REQUESTS],
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const json = JSON.stringify(err)
|
|
73
|
+
expect(JSON.parse(json)).toEqual({
|
|
74
|
+
code: 'TOO_FAST',
|
|
75
|
+
message: 'You are too fast',
|
|
76
|
+
httpStatusCode: httpStatus.TOO_MANY_REQUESTS,
|
|
77
|
+
httpStatusText: httpStatus[httpStatus.TOO_MANY_REQUESTS],
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import httpStatus from 'http-status'
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { HttpError } from '../src/http/HttpError.js'
|
|
5
|
+
import { GENERAL_ERROR } from '../src/fastify/error-codes.js'
|
|
6
|
+
import {
|
|
7
|
+
replyOnErrorOnly,
|
|
8
|
+
withErrorHandling,
|
|
9
|
+
withErrorHandlingReply,
|
|
10
|
+
} from '../src/fastify/error-handlers/with-error-handling.js'
|
|
11
|
+
|
|
12
|
+
describe('withErrorHandling', () => {
|
|
13
|
+
const log = { error: vi.fn() }
|
|
14
|
+
|
|
15
|
+
it('should return result on success', async () => {
|
|
16
|
+
const fn = vi.fn().mockResolvedValue('ok')
|
|
17
|
+
const result = await withErrorHandling(
|
|
18
|
+
log,
|
|
19
|
+
new HttpError(GENERAL_ERROR),
|
|
20
|
+
)(fn)
|
|
21
|
+
expect(result).toBe('ok')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should rethrow HttpError', async () => {
|
|
25
|
+
const err = new HttpError({
|
|
26
|
+
message: 'Bad',
|
|
27
|
+
httpStatusCode: httpStatus.BAD_REQUEST,
|
|
28
|
+
})
|
|
29
|
+
const fn = vi.fn().mockRejectedValue(err)
|
|
30
|
+
|
|
31
|
+
await expect(
|
|
32
|
+
withErrorHandling(log, new HttpError(GENERAL_ERROR))(fn),
|
|
33
|
+
).rejects.toThrow(err)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('should wrap non-HttpError with HttpError', async () => {
|
|
37
|
+
const fn = vi.fn().mockRejectedValue(new Error('oops'))
|
|
38
|
+
|
|
39
|
+
await expect(
|
|
40
|
+
withErrorHandling(log, new HttpError(GENERAL_ERROR))(fn),
|
|
41
|
+
).rejects.toBeInstanceOf(HttpError)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('withErrorHandlingReply', () => {
|
|
46
|
+
const reply = {
|
|
47
|
+
status: vi.fn().mockReturnThis(),
|
|
48
|
+
send: vi.fn(),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const log = { error: vi.fn() }
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
vi.clearAllMocks()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should return result on success', async () => {
|
|
58
|
+
const fn = vi.fn().mockResolvedValue('good')
|
|
59
|
+
const result = await withErrorHandlingReply({ reply, log })(fn)
|
|
60
|
+
expect(result).toBe('good')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('should send reply on HttpError', async () => {
|
|
64
|
+
const err = new HttpError({
|
|
65
|
+
message: 'not found',
|
|
66
|
+
code: 'NOT_FOUND',
|
|
67
|
+
httpStatusCode: httpStatus.NOT_FOUND,
|
|
68
|
+
httpStatusText: httpStatus[httpStatus.NOT_FOUND],
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const fn = vi.fn().mockRejectedValue(err)
|
|
72
|
+
await withErrorHandlingReply({ reply, log })(fn)
|
|
73
|
+
|
|
74
|
+
expect(reply.status).toHaveBeenCalledWith(httpStatus.NOT_FOUND)
|
|
75
|
+
expect(reply.send).toHaveBeenCalledWith({
|
|
76
|
+
code: 'NOT_FOUND',
|
|
77
|
+
httpStatusText: 'Not Found',
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('replyOnErrorOnly', () => {
|
|
83
|
+
const reply = {
|
|
84
|
+
status: vi.fn().mockReturnThis(),
|
|
85
|
+
send: vi.fn(),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const log = { error: vi.fn() }
|
|
89
|
+
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
vi.clearAllMocks()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('should return result on success', async () => {
|
|
95
|
+
const fn = vi.fn().mockResolvedValue('yay')
|
|
96
|
+
const result = await replyOnErrorOnly({ reply, log })(fn)
|
|
97
|
+
expect(result).toBe('yay')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('should send reply with default error when error is not HttpError', async () => {
|
|
101
|
+
const fn = vi.fn().mockRejectedValue(new Error('bad'))
|
|
102
|
+
await replyOnErrorOnly({ reply, log })(fn)
|
|
103
|
+
|
|
104
|
+
expect(reply.status).toHaveBeenCalledWith(httpStatus.INTERNAL_SERVER_ERROR)
|
|
105
|
+
expect(reply.send).toHaveBeenCalledWith(
|
|
106
|
+
expect.objectContaining({ error: 'bad' }),
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should send reply with HttpError if thrown', async () => {
|
|
111
|
+
const err = new HttpError({
|
|
112
|
+
message: 'forbidden',
|
|
113
|
+
code: 'NO_ACCESS',
|
|
114
|
+
httpStatusCode: httpStatus.FORBIDDEN,
|
|
115
|
+
httpStatusText: httpStatus[httpStatus.FORBIDDEN],
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const fn = vi.fn().mockRejectedValue(err)
|
|
119
|
+
await replyOnErrorOnly({ reply, log })(fn)
|
|
120
|
+
|
|
121
|
+
expect(reply.status).toHaveBeenCalledWith(httpStatus.FORBIDDEN)
|
|
122
|
+
expect(reply.send).toHaveBeenCalledWith({ error: 'forbidden' })
|
|
123
|
+
})
|
|
124
|
+
})
|