@onlineapps/service-common 1.0.15 → 1.0.16
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/errors/BusinessError.js +117 -0
- package/src/errors/errorMiddleware.js +111 -0
- package/src/errors/index.js +29 -0
- package/src/index.js +27 -1
- package/tests/unit/BusinessError.test.js +171 -0
package/package.json
CHANGED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const ERROR_TYPES = {
|
|
4
|
+
TRANSIENT: 'TRANSIENT',
|
|
5
|
+
BUSINESS: 'BUSINESS',
|
|
6
|
+
FATAL: 'FATAL',
|
|
7
|
+
VALIDATION: 'VALIDATION',
|
|
8
|
+
TIMEOUT: 'TIMEOUT',
|
|
9
|
+
RATE_LIMIT: 'RATE_LIMIT',
|
|
10
|
+
UNKNOWN: 'UNKNOWN'
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
class BusinessError extends Error {
|
|
14
|
+
constructor(message, { statusCode = 500, errorCode = 'INTERNAL_ERROR', type = 'BUSINESS', details = [], operation = null, service = null } = {}) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = this.constructor.name;
|
|
17
|
+
this.statusCode = statusCode;
|
|
18
|
+
this.errorCode = errorCode;
|
|
19
|
+
this.type = type;
|
|
20
|
+
this.details = Array.isArray(details) ? details : [details];
|
|
21
|
+
this.operation = operation || null;
|
|
22
|
+
this.service = service || process.env.SERVICE_NAME || null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
toJSON() {
|
|
26
|
+
return {
|
|
27
|
+
code: this.errorCode,
|
|
28
|
+
message: this.message,
|
|
29
|
+
statusCode: this.statusCode,
|
|
30
|
+
details: this.details.length > 0 ? this.details : undefined,
|
|
31
|
+
service: this.service || undefined,
|
|
32
|
+
operation: this.operation || undefined
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class NotFoundError extends BusinessError {
|
|
38
|
+
constructor(message, options = {}) {
|
|
39
|
+
super(message, {
|
|
40
|
+
statusCode: 404,
|
|
41
|
+
errorCode: options.errorCode || 'RESOURCE_NOT_FOUND',
|
|
42
|
+
type: ERROR_TYPES.BUSINESS,
|
|
43
|
+
...options
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class ValidationError extends BusinessError {
|
|
49
|
+
constructor(message, options = {}) {
|
|
50
|
+
super(message, {
|
|
51
|
+
statusCode: 400,
|
|
52
|
+
errorCode: options.errorCode || 'VALIDATION_FAILED',
|
|
53
|
+
type: ERROR_TYPES.VALIDATION,
|
|
54
|
+
...options
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class ConflictError extends BusinessError {
|
|
60
|
+
constructor(message, options = {}) {
|
|
61
|
+
super(message, {
|
|
62
|
+
statusCode: 409,
|
|
63
|
+
errorCode: options.errorCode || 'DUPLICATE_RESOURCE',
|
|
64
|
+
type: ERROR_TYPES.BUSINESS,
|
|
65
|
+
...options
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
class BusinessRuleError extends BusinessError {
|
|
71
|
+
constructor(message, options = {}) {
|
|
72
|
+
super(message, {
|
|
73
|
+
statusCode: 422,
|
|
74
|
+
errorCode: options.errorCode || 'BUSINESS_RULE_VIOLATED',
|
|
75
|
+
type: ERROR_TYPES.VALIDATION,
|
|
76
|
+
...options
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
class AuthorizationError extends BusinessError {
|
|
82
|
+
constructor(message, options = {}) {
|
|
83
|
+
super(message, {
|
|
84
|
+
statusCode: 403,
|
|
85
|
+
errorCode: options.errorCode || 'FORBIDDEN',
|
|
86
|
+
type: ERROR_TYPES.BUSINESS,
|
|
87
|
+
...options
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
class ServiceUnavailableError extends BusinessError {
|
|
93
|
+
constructor(message, options = {}) {
|
|
94
|
+
super(message, {
|
|
95
|
+
statusCode: 503,
|
|
96
|
+
errorCode: options.errorCode || 'SERVICE_UNAVAILABLE',
|
|
97
|
+
type: ERROR_TYPES.TRANSIENT,
|
|
98
|
+
...options
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isBusinessError(error) {
|
|
104
|
+
return error instanceof BusinessError;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
BusinessError,
|
|
109
|
+
NotFoundError,
|
|
110
|
+
ValidationError,
|
|
111
|
+
ConflictError,
|
|
112
|
+
BusinessRuleError,
|
|
113
|
+
AuthorizationError,
|
|
114
|
+
ServiceUnavailableError,
|
|
115
|
+
isBusinessError,
|
|
116
|
+
ERROR_TYPES
|
|
117
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { BusinessError, isBusinessError } = require('./BusinessError');
|
|
4
|
+
|
|
5
|
+
function businessErrorHandler(err, req, res, next) {
|
|
6
|
+
if (res.headersSent) {
|
|
7
|
+
return next(err);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const logger = req.app?.locals?.logger || console;
|
|
11
|
+
|
|
12
|
+
if (isBusinessError(err)) {
|
|
13
|
+
logger.warn(`[BusinessError] ${err.errorCode}: ${err.message}`, {
|
|
14
|
+
statusCode: err.statusCode,
|
|
15
|
+
errorCode: err.errorCode,
|
|
16
|
+
type: err.type,
|
|
17
|
+
details: err.details,
|
|
18
|
+
service: err.service,
|
|
19
|
+
operation: err.operation,
|
|
20
|
+
method: req.method,
|
|
21
|
+
url: req.originalUrl
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return res.status(err.statusCode).json({
|
|
25
|
+
error: err.toJSON()
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (err.name === 'SequelizeUniqueConstraintError') {
|
|
30
|
+
const fields = err.errors?.map(e => e.path) || [];
|
|
31
|
+
logger.warn(`[BusinessError] DUPLICATE_RESOURCE: ${err.message}`, {
|
|
32
|
+
statusCode: 409,
|
|
33
|
+
fields,
|
|
34
|
+
method: req.method,
|
|
35
|
+
url: req.originalUrl
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return res.status(409).json({
|
|
39
|
+
error: {
|
|
40
|
+
code: 'DUPLICATE_RESOURCE',
|
|
41
|
+
message: 'Resource already exists',
|
|
42
|
+
statusCode: 409,
|
|
43
|
+
details: fields.length > 0 ? [`Duplicate value for: ${fields.join(', ')}`] : undefined
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (err.name === 'SequelizeValidationError') {
|
|
49
|
+
const details = err.errors?.map(e => e.message) || [];
|
|
50
|
+
logger.warn(`[BusinessError] VALIDATION_FAILED: ${err.message}`, {
|
|
51
|
+
statusCode: 400,
|
|
52
|
+
details,
|
|
53
|
+
method: req.method,
|
|
54
|
+
url: req.originalUrl
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return res.status(400).json({
|
|
58
|
+
error: {
|
|
59
|
+
code: 'VALIDATION_FAILED',
|
|
60
|
+
message: 'Validation failed',
|
|
61
|
+
statusCode: 400,
|
|
62
|
+
details
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (err.type === 'entity.parse.failed' || (err.message && err.message.includes('JSON'))) {
|
|
68
|
+
return res.status(400).json({
|
|
69
|
+
error: {
|
|
70
|
+
code: 'INVALID_JSON',
|
|
71
|
+
message: 'Invalid JSON in request body',
|
|
72
|
+
statusCode: 400
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
logger.error(`[UnhandledError] ${err.message}`, {
|
|
78
|
+
error: err.message,
|
|
79
|
+
stack: err.stack,
|
|
80
|
+
method: req.method,
|
|
81
|
+
url: req.originalUrl
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
85
|
+
|
|
86
|
+
return res.status(500).json({
|
|
87
|
+
error: {
|
|
88
|
+
code: 'INTERNAL_ERROR',
|
|
89
|
+
message: isProduction ? 'Internal server error' : err.message,
|
|
90
|
+
statusCode: 500
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function notFoundHandler(req, res) {
|
|
96
|
+
const logger = req.app?.locals?.logger || console;
|
|
97
|
+
logger.warn('Route not found', {
|
|
98
|
+
method: req.method,
|
|
99
|
+
url: req.originalUrl
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
res.status(404).json({
|
|
103
|
+
error: {
|
|
104
|
+
code: 'ROUTE_NOT_FOUND',
|
|
105
|
+
message: `Route ${req.method} ${req.originalUrl} not found`,
|
|
106
|
+
statusCode: 404
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { businessErrorHandler, notFoundHandler };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
BusinessError,
|
|
5
|
+
NotFoundError,
|
|
6
|
+
ValidationError,
|
|
7
|
+
ConflictError,
|
|
8
|
+
BusinessRuleError,
|
|
9
|
+
AuthorizationError,
|
|
10
|
+
ServiceUnavailableError,
|
|
11
|
+
isBusinessError,
|
|
12
|
+
ERROR_TYPES
|
|
13
|
+
} = require('./BusinessError');
|
|
14
|
+
|
|
15
|
+
const { businessErrorHandler, notFoundHandler } = require('./errorMiddleware');
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
BusinessError,
|
|
19
|
+
NotFoundError,
|
|
20
|
+
ValidationError,
|
|
21
|
+
ConflictError,
|
|
22
|
+
BusinessRuleError,
|
|
23
|
+
AuthorizationError,
|
|
24
|
+
ServiceUnavailableError,
|
|
25
|
+
isBusinessError,
|
|
26
|
+
ERROR_TYPES,
|
|
27
|
+
businessErrorHandler,
|
|
28
|
+
notFoundHandler
|
|
29
|
+
};
|
package/src/index.js
CHANGED
|
@@ -30,6 +30,19 @@ const {
|
|
|
30
30
|
getCriticalConfigWithFallbacks,
|
|
31
31
|
getInfrastructureHealthConfig
|
|
32
32
|
} = require('./runtime-config');
|
|
33
|
+
const {
|
|
34
|
+
BusinessError,
|
|
35
|
+
NotFoundError,
|
|
36
|
+
ValidationError,
|
|
37
|
+
ConflictError,
|
|
38
|
+
BusinessRuleError,
|
|
39
|
+
AuthorizationError,
|
|
40
|
+
ServiceUnavailableError,
|
|
41
|
+
isBusinessError,
|
|
42
|
+
ERROR_TYPES,
|
|
43
|
+
businessErrorHandler,
|
|
44
|
+
notFoundHandler
|
|
45
|
+
} = require('./errors');
|
|
33
46
|
|
|
34
47
|
module.exports = {
|
|
35
48
|
// Infrastructure readiness utilities (used by both infrastructure and business services)
|
|
@@ -55,7 +68,20 @@ module.exports = {
|
|
|
55
68
|
getInfrastructureHealthConfig,
|
|
56
69
|
|
|
57
70
|
// Reporting utilities
|
|
58
|
-
sendMonitoringFailFallbackEmail
|
|
71
|
+
sendMonitoringFailFallbackEmail,
|
|
72
|
+
|
|
73
|
+
// Business error classes and middleware
|
|
74
|
+
BusinessError,
|
|
75
|
+
NotFoundError,
|
|
76
|
+
ValidationError,
|
|
77
|
+
ConflictError,
|
|
78
|
+
BusinessRuleError,
|
|
79
|
+
AuthorizationError,
|
|
80
|
+
ServiceUnavailableError,
|
|
81
|
+
isBusinessError,
|
|
82
|
+
ERROR_TYPES,
|
|
83
|
+
businessErrorHandler,
|
|
84
|
+
notFoundHandler
|
|
59
85
|
};
|
|
60
86
|
|
|
61
87
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
BusinessError,
|
|
5
|
+
NotFoundError,
|
|
6
|
+
ValidationError,
|
|
7
|
+
ConflictError,
|
|
8
|
+
BusinessRuleError,
|
|
9
|
+
AuthorizationError,
|
|
10
|
+
ServiceUnavailableError,
|
|
11
|
+
isBusinessError,
|
|
12
|
+
businessErrorHandler,
|
|
13
|
+
notFoundHandler
|
|
14
|
+
} = require('../../src/errors');
|
|
15
|
+
|
|
16
|
+
describe('BusinessError hierarchy', () => {
|
|
17
|
+
test('BusinessError has correct defaults', () => {
|
|
18
|
+
const err = new BusinessError('Something went wrong');
|
|
19
|
+
expect(err).toBeInstanceOf(Error);
|
|
20
|
+
expect(err).toBeInstanceOf(BusinessError);
|
|
21
|
+
expect(err.name).toBe('BusinessError');
|
|
22
|
+
expect(err.message).toBe('Something went wrong');
|
|
23
|
+
expect(err.statusCode).toBe(500);
|
|
24
|
+
expect(err.errorCode).toBe('INTERNAL_ERROR');
|
|
25
|
+
expect(err.type).toBe('BUSINESS');
|
|
26
|
+
expect(err.details).toEqual([]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('NotFoundError sets 404', () => {
|
|
30
|
+
const err = new NotFoundError('Document not found', {
|
|
31
|
+
details: ['UUID abc123 does not exist'],
|
|
32
|
+
operation: 'getDocument'
|
|
33
|
+
});
|
|
34
|
+
expect(err).toBeInstanceOf(BusinessError);
|
|
35
|
+
expect(err.statusCode).toBe(404);
|
|
36
|
+
expect(err.errorCode).toBe('RESOURCE_NOT_FOUND');
|
|
37
|
+
expect(err.type).toBe('BUSINESS');
|
|
38
|
+
expect(err.details).toEqual(['UUID abc123 does not exist']);
|
|
39
|
+
expect(err.operation).toBe('getDocument');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('ValidationError sets 400', () => {
|
|
43
|
+
const err = new ValidationError('Invalid input', { details: ['field X is required'] });
|
|
44
|
+
expect(err.statusCode).toBe(400);
|
|
45
|
+
expect(err.errorCode).toBe('VALIDATION_FAILED');
|
|
46
|
+
expect(err.type).toBe('VALIDATION');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('ConflictError sets 409', () => {
|
|
50
|
+
const err = new ConflictError('Duplicate');
|
|
51
|
+
expect(err.statusCode).toBe(409);
|
|
52
|
+
expect(err.errorCode).toBe('DUPLICATE_RESOURCE');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('BusinessRuleError sets 422', () => {
|
|
56
|
+
const err = new BusinessRuleError('Cannot invoice draft');
|
|
57
|
+
expect(err.statusCode).toBe(422);
|
|
58
|
+
expect(err.errorCode).toBe('BUSINESS_RULE_VIOLATED');
|
|
59
|
+
expect(err.type).toBe('VALIDATION');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('AuthorizationError sets 403', () => {
|
|
63
|
+
const err = new AuthorizationError('No access');
|
|
64
|
+
expect(err.statusCode).toBe(403);
|
|
65
|
+
expect(err.errorCode).toBe('FORBIDDEN');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('ServiceUnavailableError sets 503', () => {
|
|
69
|
+
const err = new ServiceUnavailableError('DB down');
|
|
70
|
+
expect(err.statusCode).toBe(503);
|
|
71
|
+
expect(err.errorCode).toBe('SERVICE_UNAVAILABLE');
|
|
72
|
+
expect(err.type).toBe('TRANSIENT');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('isBusinessError returns true for BusinessError subtypes', () => {
|
|
76
|
+
expect(isBusinessError(new NotFoundError('x'))).toBe(true);
|
|
77
|
+
expect(isBusinessError(new Error('x'))).toBe(false);
|
|
78
|
+
expect(isBusinessError(null)).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('toJSON returns structured error', () => {
|
|
82
|
+
const err = new NotFoundError('Not found', {
|
|
83
|
+
details: ['detail1'],
|
|
84
|
+
operation: 'findDoc',
|
|
85
|
+
service: 'invoicing'
|
|
86
|
+
});
|
|
87
|
+
const json = err.toJSON();
|
|
88
|
+
expect(json).toEqual({
|
|
89
|
+
code: 'RESOURCE_NOT_FOUND',
|
|
90
|
+
message: 'Not found',
|
|
91
|
+
statusCode: 404,
|
|
92
|
+
details: ['detail1'],
|
|
93
|
+
service: 'invoicing',
|
|
94
|
+
operation: 'findDoc'
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('custom errorCode can be overridden', () => {
|
|
99
|
+
const err = new NotFoundError('Tenant missing', { errorCode: 'TENANT_NOT_FOUND' });
|
|
100
|
+
expect(err.errorCode).toBe('TENANT_NOT_FOUND');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('businessErrorHandler middleware', () => {
|
|
105
|
+
let req, res, next;
|
|
106
|
+
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
req = { method: 'GET', originalUrl: '/api/test', app: { locals: { logger: { warn: jest.fn(), error: jest.fn() } } } };
|
|
109
|
+
res = { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis(), headersSent: false };
|
|
110
|
+
next = jest.fn();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('formats BusinessError as structured JSON', () => {
|
|
114
|
+
const err = new NotFoundError('Doc not found', { details: ['UUID 123'] });
|
|
115
|
+
businessErrorHandler(err, req, res, next);
|
|
116
|
+
|
|
117
|
+
expect(res.status).toHaveBeenCalledWith(404);
|
|
118
|
+
expect(res.json).toHaveBeenCalledWith({
|
|
119
|
+
error: {
|
|
120
|
+
code: 'RESOURCE_NOT_FOUND',
|
|
121
|
+
message: 'Doc not found',
|
|
122
|
+
statusCode: 404,
|
|
123
|
+
details: ['UUID 123'],
|
|
124
|
+
service: undefined,
|
|
125
|
+
operation: undefined
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('wraps generic Error as INTERNAL_ERROR 500', () => {
|
|
131
|
+
const err = new Error('kaboom');
|
|
132
|
+
businessErrorHandler(err, req, res, next);
|
|
133
|
+
|
|
134
|
+
expect(res.status).toHaveBeenCalledWith(500);
|
|
135
|
+
const body = res.json.mock.calls[0][0];
|
|
136
|
+
expect(body.error.code).toBe('INTERNAL_ERROR');
|
|
137
|
+
expect(body.error.statusCode).toBe(500);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('does not leak stack in production', () => {
|
|
141
|
+
const origEnv = process.env.NODE_ENV;
|
|
142
|
+
process.env.NODE_ENV = 'production';
|
|
143
|
+
|
|
144
|
+
const err = new Error('secret details');
|
|
145
|
+
businessErrorHandler(err, req, res, next);
|
|
146
|
+
|
|
147
|
+
const body = res.json.mock.calls[0][0];
|
|
148
|
+
expect(body.error.message).toBe('Internal server error');
|
|
149
|
+
|
|
150
|
+
process.env.NODE_ENV = origEnv;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('calls next() if headers already sent', () => {
|
|
154
|
+
res.headersSent = true;
|
|
155
|
+
const err = new Error('late');
|
|
156
|
+
businessErrorHandler(err, req, res, next);
|
|
157
|
+
expect(next).toHaveBeenCalledWith(err);
|
|
158
|
+
expect(res.status).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('notFoundHandler middleware', () => {
|
|
163
|
+
test('returns ROUTE_NOT_FOUND 404', () => {
|
|
164
|
+
const req = { method: 'GET', originalUrl: '/unknown', app: { locals: { logger: { warn: jest.fn() } } } };
|
|
165
|
+
const res = { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() };
|
|
166
|
+
notFoundHandler(req, res);
|
|
167
|
+
|
|
168
|
+
expect(res.status).toHaveBeenCalledWith(404);
|
|
169
|
+
expect(res.json.mock.calls[0][0].error.code).toBe('ROUTE_NOT_FOUND');
|
|
170
|
+
});
|
|
171
|
+
});
|