@tumbaland/backend-core 1.16.0
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/.versionrc.json +7 -0
- package/README.md +179 -0
- package/__mocks__/uuid.js +8 -0
- package/dist/app/createBaseApp.d.ts +44 -0
- package/dist/app/createBaseApp.d.ts.map +1 -0
- package/dist/app/createBaseApp.js +54 -0
- package/dist/app/createBaseApp.js.map +1 -0
- package/dist/config/env.d.ts +8 -0
- package/dist/config/env.d.ts.map +1 -0
- package/dist/config/env.js +17 -0
- package/dist/config/env.js.map +1 -0
- package/dist/database/connection.d.ts +3 -0
- package/dist/database/connection.d.ts.map +1 -0
- package/dist/database/connection.js +49 -0
- package/dist/database/connection.js.map +1 -0
- package/dist/errors/HttpError.d.ts +32 -0
- package/dist/errors/HttpError.d.ts.map +1 -0
- package/dist/errors/HttpError.js +61 -0
- package/dist/errors/HttpError.js.map +1 -0
- package/dist/health/healthController.d.ts +21 -0
- package/dist/health/healthController.d.ts.map +1 -0
- package/dist/health/healthController.js +56 -0
- package/dist/health/healthController.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +81 -0
- package/dist/index.js.map +1 -0
- package/dist/logging/logger.d.ts +4 -0
- package/dist/logging/logger.d.ts.map +1 -0
- package/dist/logging/logger.js +78 -0
- package/dist/logging/logger.js.map +1 -0
- package/dist/metrics/index.d.ts +17 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +99 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/middleware/authMiddleware.d.ts +9 -0
- package/dist/middleware/authMiddleware.d.ts.map +1 -0
- package/dist/middleware/authMiddleware.js +32 -0
- package/dist/middleware/authMiddleware.js.map +1 -0
- package/dist/middleware/corsMiddleware.d.ts +20 -0
- package/dist/middleware/corsMiddleware.d.ts.map +1 -0
- package/dist/middleware/corsMiddleware.js +33 -0
- package/dist/middleware/corsMiddleware.js.map +1 -0
- package/dist/middleware/errorHandler.d.ts +14 -0
- package/dist/middleware/errorHandler.d.ts.map +1 -0
- package/dist/middleware/errorHandler.js +88 -0
- package/dist/middleware/errorHandler.js.map +1 -0
- package/dist/middleware/requestLogger.d.ts +16 -0
- package/dist/middleware/requestLogger.d.ts.map +1 -0
- package/dist/middleware/requestLogger.js +49 -0
- package/dist/middleware/requestLogger.js.map +1 -0
- package/dist/middleware/security.d.ts +26 -0
- package/dist/middleware/security.d.ts.map +1 -0
- package/dist/middleware/security.js +47 -0
- package/dist/middleware/security.js.map +1 -0
- package/dist/middleware/validate.d.ts +11 -0
- package/dist/middleware/validate.d.ts.map +1 -0
- package/dist/middleware/validate.js +24 -0
- package/dist/middleware/validate.js.map +1 -0
- package/dist/tracing/index.d.ts +13 -0
- package/dist/tracing/index.d.ts.map +1 -0
- package/dist/tracing/index.js +89 -0
- package/dist/tracing/index.js.map +1 -0
- package/dist/types/auth.d.ts +32 -0
- package/dist/types/auth.d.ts.map +1 -0
- package/dist/types/auth.js +3 -0
- package/dist/types/auth.js.map +1 -0
- package/dist/utils/correlation.d.ts +11 -0
- package/dist/utils/correlation.d.ts.map +1 -0
- package/dist/utils/correlation.js +23 -0
- package/dist/utils/correlation.js.map +1 -0
- package/dist/utils/response.d.ts +24 -0
- package/dist/utils/response.d.ts.map +1 -0
- package/dist/utils/response.js +35 -0
- package/dist/utils/response.js.map +1 -0
- package/jest.config.js +14 -0
- package/package.json +61 -0
- package/src/app/createBaseApp.test.ts +69 -0
- package/src/app/createBaseApp.ts +75 -0
- package/src/config/env.test.ts +28 -0
- package/src/config/env.ts +13 -0
- package/src/database/connection.test.ts +142 -0
- package/src/database/connection.ts +46 -0
- package/src/errors/HttpError.test.ts +38 -0
- package/src/errors/HttpError.ts +57 -0
- package/src/health/healthController.test.ts +91 -0
- package/src/health/healthController.ts +56 -0
- package/src/index.ts +60 -0
- package/src/logging/logger.test.ts +76 -0
- package/src/logging/logger.ts +103 -0
- package/src/metrics/index.test.ts +91 -0
- package/src/metrics/index.ts +110 -0
- package/src/middleware/authMiddleware.test.ts +104 -0
- package/src/middleware/authMiddleware.ts +29 -0
- package/src/middleware/corsMiddleware.test.ts +58 -0
- package/src/middleware/corsMiddleware.ts +36 -0
- package/src/middleware/errorHandler.test.ts +146 -0
- package/src/middleware/errorHandler.ts +98 -0
- package/src/middleware/requestLogger.test.ts +81 -0
- package/src/middleware/requestLogger.ts +47 -0
- package/src/middleware/security.test.ts +45 -0
- package/src/middleware/security.ts +43 -0
- package/src/middleware/validate.test.ts +60 -0
- package/src/middleware/validate.ts +23 -0
- package/src/tracing/index.test.ts +250 -0
- package/src/tracing/index.ts +97 -0
- package/src/types/auth.ts +33 -0
- package/src/utils/correlation.test.ts +47 -0
- package/src/utils/correlation.ts +22 -0
- package/src/utils/response.test.ts +64 -0
- package/src/utils/response.ts +60 -0
- package/tsconfig.build.json +4 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { requireEnv } from './env';
|
|
2
|
+
|
|
3
|
+
describe('requireEnv', () => {
|
|
4
|
+
const ORIGINAL_ENV = process.env;
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
process.env = { ...ORIGINAL_ENV };
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterAll(() => {
|
|
11
|
+
process.env = ORIGINAL_ENV;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('returns the value when the variable is set', () => {
|
|
15
|
+
process.env.MY_VAR = 'hello';
|
|
16
|
+
expect(requireEnv('MY_VAR')).toBe('hello');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('throws when the variable is missing', () => {
|
|
20
|
+
delete process.env.MY_VAR;
|
|
21
|
+
expect(() => requireEnv('MY_VAR')).toThrow('Missing required environment variable: MY_VAR');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('throws when the variable is an empty string', () => {
|
|
25
|
+
process.env.MY_VAR = '';
|
|
26
|
+
expect(() => requireEnv('MY_VAR')).toThrow('Missing required environment variable: MY_VAR');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns the value of a required environment variable.
|
|
3
|
+
* Throws if the variable is missing or empty — call at service startup
|
|
4
|
+
* (after dotenv.config()) so misconfigured services fail fast instead of
|
|
5
|
+
* running with insecure defaults.
|
|
6
|
+
*/
|
|
7
|
+
export function requireEnv(name: string): string {
|
|
8
|
+
const value = process.env[name];
|
|
9
|
+
if (!value) {
|
|
10
|
+
throw new Error(`Missing required environment variable: ${name}`);
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
jest.mock('../logging/logger', () => ({
|
|
2
|
+
__esModule: true,
|
|
3
|
+
default: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(), http: jest.fn() }
|
|
4
|
+
}));
|
|
5
|
+
|
|
6
|
+
const connectionHandlers: Record<string, (...args: any[]) => void> = {};
|
|
7
|
+
|
|
8
|
+
jest.mock('mongoose', () => ({
|
|
9
|
+
__esModule: true,
|
|
10
|
+
default: {
|
|
11
|
+
connect: jest.fn(),
|
|
12
|
+
connection: {
|
|
13
|
+
on: jest.fn((event: string, handler: (...args: any[]) => void) => {
|
|
14
|
+
connectionHandlers[event] = handler;
|
|
15
|
+
}),
|
|
16
|
+
close: jest.fn()
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
import mongoose from 'mongoose';
|
|
22
|
+
import logger from '../logging/logger';
|
|
23
|
+
import { disconnectDB } from './connection';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `connection.ts` keeps a module-level `connected` flag, so each connectDB
|
|
27
|
+
* scenario needs its own fresh copy of the module (and of its mongoose/logger
|
|
28
|
+
* mocks) — isolateModulesAsync sandboxes that without leaking into other
|
|
29
|
+
* tests in this file the way a blanket jest.resetModules() would.
|
|
30
|
+
*/
|
|
31
|
+
async function freshConnectDB() {
|
|
32
|
+
let connectDBFn!: typeof import('./connection').connectDB;
|
|
33
|
+
let mongooseMock!: typeof mongoose;
|
|
34
|
+
let loggerMock!: typeof logger;
|
|
35
|
+
|
|
36
|
+
await jest.isolateModulesAsync(async () => {
|
|
37
|
+
mongooseMock = (await import('mongoose')).default as unknown as typeof mongoose;
|
|
38
|
+
loggerMock = (await import('../logging/logger')).default as unknown as typeof logger;
|
|
39
|
+
connectDBFn = (await import('./connection')).connectDB;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return { connectDB: connectDBFn, mongoose: mongooseMock, logger: loggerMock };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('connectDB', () => {
|
|
46
|
+
const ORIGINAL_ENV = process.env;
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
process.env = { ...ORIGINAL_ENV };
|
|
50
|
+
delete process.env.MONGODB_URI;
|
|
51
|
+
for (const key of Object.keys(connectionHandlers)) delete connectionHandlers[key];
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterAll(() => {
|
|
55
|
+
process.env = ORIGINAL_ENV;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('throws when no MongoDB URI is available', async () => {
|
|
59
|
+
const { connectDB } = await freshConnectDB();
|
|
60
|
+
await expect(connectDB('my-service')).rejects.toThrow('MONGODB_URI environment variable is not set');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('connects using the explicit uri argument over the env var', async () => {
|
|
64
|
+
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
65
|
+
const { connectDB, mongoose, logger } = await freshConnectDB();
|
|
66
|
+
|
|
67
|
+
await connectDB('my-service', 'mongodb://explicit-uri');
|
|
68
|
+
|
|
69
|
+
expect(mongoose.connect).toHaveBeenCalledWith('mongodb://explicit-uri');
|
|
70
|
+
expect(logger.info).toHaveBeenCalledWith('MongoDB connected', { service: 'my-service' });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('falls back to MONGODB_URI when no explicit uri is given', async () => {
|
|
74
|
+
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
75
|
+
const { connectDB, mongoose } = await freshConnectDB();
|
|
76
|
+
|
|
77
|
+
await connectDB('my-service');
|
|
78
|
+
|
|
79
|
+
expect(mongoose.connect).toHaveBeenCalledWith('mongodb://env-uri');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('is a no-op on a second call once already connected', async () => {
|
|
83
|
+
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
84
|
+
const { connectDB, mongoose } = await freshConnectDB();
|
|
85
|
+
|
|
86
|
+
await connectDB('my-service');
|
|
87
|
+
await connectDB('my-service');
|
|
88
|
+
|
|
89
|
+
expect(mongoose.connect).toHaveBeenCalledTimes(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('logs and rethrows when mongoose.connect fails', async () => {
|
|
93
|
+
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
94
|
+
const { connectDB, mongoose, logger } = await freshConnectDB();
|
|
95
|
+
(mongoose.connect as jest.Mock).mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
|
96
|
+
|
|
97
|
+
await expect(connectDB('my-service')).rejects.toThrow('ECONNREFUSED');
|
|
98
|
+
expect(logger.error).toHaveBeenCalledWith('Failed to connect to MongoDB', {
|
|
99
|
+
service: 'my-service',
|
|
100
|
+
error: 'ECONNREFUSED'
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('registers error/disconnected/reconnected handlers that log through the shared logger', async () => {
|
|
105
|
+
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
106
|
+
const { connectDB, logger } = await freshConnectDB();
|
|
107
|
+
|
|
108
|
+
await connectDB('my-service');
|
|
109
|
+
|
|
110
|
+
connectionHandlers.error(new Error('boom'));
|
|
111
|
+
expect(logger.error).toHaveBeenCalledWith('MongoDB connection error', { service: 'my-service', error: 'boom' });
|
|
112
|
+
|
|
113
|
+
connectionHandlers.disconnected();
|
|
114
|
+
expect(logger.warn).toHaveBeenCalledWith('MongoDB disconnected', { service: 'my-service' });
|
|
115
|
+
|
|
116
|
+
connectionHandlers.reconnected();
|
|
117
|
+
expect(logger.info).toHaveBeenCalledWith('MongoDB reconnected', { service: 'my-service' });
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('disconnectDB', () => {
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
jest.clearAllMocks();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('closes the connection and logs success', async () => {
|
|
127
|
+
await disconnectDB('my-service');
|
|
128
|
+
|
|
129
|
+
expect(mongoose.connection.close).toHaveBeenCalled();
|
|
130
|
+
expect(logger.info).toHaveBeenCalledWith('MongoDB connection closed', { service: 'my-service' });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('logs and rethrows when closing fails', async () => {
|
|
134
|
+
(mongoose.connection.close as jest.Mock).mockRejectedValueOnce(new Error('close failed'));
|
|
135
|
+
|
|
136
|
+
await expect(disconnectDB('my-service')).rejects.toThrow('close failed');
|
|
137
|
+
expect(logger.error).toHaveBeenCalledWith('Error closing MongoDB connection', {
|
|
138
|
+
service: 'my-service',
|
|
139
|
+
error: 'close failed'
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import mongoose from 'mongoose';
|
|
2
|
+
import logger from '../logging/logger';
|
|
3
|
+
|
|
4
|
+
let connected = false;
|
|
5
|
+
|
|
6
|
+
export const connectDB = async (serviceName?: string, uri?: string): Promise<void> => {
|
|
7
|
+
if (connected) return;
|
|
8
|
+
|
|
9
|
+
const mongoUri = uri || process.env.MONGODB_URI;
|
|
10
|
+
if (!mongoUri) {
|
|
11
|
+
throw new Error('MONGODB_URI environment variable is not set');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
await mongoose.connect(mongoUri);
|
|
16
|
+
connected = true;
|
|
17
|
+
|
|
18
|
+
logger.info('MongoDB connected', { service: serviceName });
|
|
19
|
+
|
|
20
|
+
mongoose.connection.on('error', (err: Error) => {
|
|
21
|
+
logger.error('MongoDB connection error', { service: serviceName, error: err.message });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
mongoose.connection.on('disconnected', () => {
|
|
25
|
+
logger.warn('MongoDB disconnected', { service: serviceName });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
mongoose.connection.on('reconnected', () => {
|
|
29
|
+
logger.info('MongoDB reconnected', { service: serviceName });
|
|
30
|
+
});
|
|
31
|
+
} catch (error) {
|
|
32
|
+
logger.error('Failed to connect to MongoDB', { service: serviceName, error: (error as Error).message });
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const disconnectDB = async (serviceName?: string): Promise<void> => {
|
|
38
|
+
try {
|
|
39
|
+
await mongoose.connection.close();
|
|
40
|
+
connected = false;
|
|
41
|
+
logger.info('MongoDB connection closed', { service: serviceName });
|
|
42
|
+
} catch (error) {
|
|
43
|
+
logger.error('Error closing MongoDB connection', { service: serviceName, error: (error as Error).message });
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { HttpError, BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ConflictError, TooManyRequestsError } from './HttpError';
|
|
2
|
+
|
|
3
|
+
describe('HttpError', () => {
|
|
4
|
+
it('carries the given status code and message', () => {
|
|
5
|
+
const error = new HttpError(418, "I'm a teapot");
|
|
6
|
+
expect(error.statusCode).toBe(418);
|
|
7
|
+
expect(error.message).toBe("I'm a teapot");
|
|
8
|
+
expect(error).toBeInstanceOf(Error);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('sets name to the concrete subclass name', () => {
|
|
12
|
+
expect(new HttpError(400, 'x').name).toBe('HttpError');
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe.each([
|
|
17
|
+
[BadRequestError, 400, 'Bad request'],
|
|
18
|
+
[UnauthorizedError, 401, 'Unauthorized'],
|
|
19
|
+
[ForbiddenError, 403, 'Access denied'],
|
|
20
|
+
[NotFoundError, 404, 'Not found'],
|
|
21
|
+
[ConflictError, 409, 'Conflict'],
|
|
22
|
+
[TooManyRequestsError, 429, 'Too many requests']
|
|
23
|
+
] as const)('%p', (ErrorClass, expectedStatus, defaultMessage) => {
|
|
24
|
+
it(`defaults to status ${expectedStatus} and a sensible message`, () => {
|
|
25
|
+
const error = new ErrorClass();
|
|
26
|
+
expect(error.statusCode).toBe(expectedStatus);
|
|
27
|
+
expect(error.message).toBe(defaultMessage);
|
|
28
|
+
expect(error).toBeInstanceOf(HttpError);
|
|
29
|
+
expect(error).toBeInstanceOf(Error);
|
|
30
|
+
expect(error.name).toBe(ErrorClass.name);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('accepts a custom message', () => {
|
|
34
|
+
const error = new ErrorClass('custom message');
|
|
35
|
+
expect(error.statusCode).toBe(expectedStatus);
|
|
36
|
+
expect(error.message).toBe('custom message');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class for errors that should be turned into a specific HTTP response
|
|
3
|
+
* by the shared errorHandler, instead of every controller hand-rolling
|
|
4
|
+
* try/catch + string-matching on error.message to pick a status code.
|
|
5
|
+
*
|
|
6
|
+
* Express 5 forwards rejected promises from async route handlers to the
|
|
7
|
+
* mounted error handler automatically, so controllers can just
|
|
8
|
+
* `throw new NotFoundError(...)` and stop wrapping in try/catch.
|
|
9
|
+
*/
|
|
10
|
+
export class HttpError extends Error {
|
|
11
|
+
statusCode: number;
|
|
12
|
+
|
|
13
|
+
constructor(statusCode: number, message: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = new.target.name;
|
|
16
|
+
this.statusCode = statusCode;
|
|
17
|
+
// Restore the prototype chain (needed when compiling to ES2018 targets
|
|
18
|
+
// where `extends Error` doesn't preserve instanceof checks correctly).
|
|
19
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class BadRequestError extends HttpError {
|
|
24
|
+
constructor(message = 'Bad request') {
|
|
25
|
+
super(400, message);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class UnauthorizedError extends HttpError {
|
|
30
|
+
constructor(message = 'Unauthorized') {
|
|
31
|
+
super(401, message);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class ForbiddenError extends HttpError {
|
|
36
|
+
constructor(message = 'Access denied') {
|
|
37
|
+
super(403, message);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class NotFoundError extends HttpError {
|
|
42
|
+
constructor(message = 'Not found') {
|
|
43
|
+
super(404, message);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class ConflictError extends HttpError {
|
|
48
|
+
constructor(message = 'Conflict') {
|
|
49
|
+
super(409, message);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class TooManyRequestsError extends HttpError {
|
|
54
|
+
constructor(message = 'Too many requests') {
|
|
55
|
+
super(429, message);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Request, Response } from 'express';
|
|
2
|
+
import mongoose from 'mongoose';
|
|
3
|
+
import { healthCheck, healthLive } from './healthController';
|
|
4
|
+
|
|
5
|
+
function mockRes(): Response {
|
|
6
|
+
const res: Partial<Response> = {};
|
|
7
|
+
res.status = jest.fn().mockReturnValue(res);
|
|
8
|
+
res.json = jest.fn().mockReturnValue(res);
|
|
9
|
+
return res as Response;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('healthLive', () => {
|
|
13
|
+
it('always responds 200 ok, independent of any dependency state', () => {
|
|
14
|
+
const res = mockRes();
|
|
15
|
+
healthLive({} as Request, res);
|
|
16
|
+
|
|
17
|
+
expect(res.status).toHaveBeenCalledWith(200);
|
|
18
|
+
expect(res.json).toHaveBeenCalledWith({ status: 'ok' });
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('healthCheck', () => {
|
|
23
|
+
const ORIGINAL_ENV = process.env;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
process.env = { ...ORIGINAL_ENV };
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterAll(() => {
|
|
30
|
+
process.env = ORIGINAL_ENV;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('responds 200 with dependencies.mongodb "connected" when Mongo readyState is 1', async () => {
|
|
34
|
+
jest.spyOn(mongoose, 'connection', 'get').mockReturnValue({ readyState: 1 } as any);
|
|
35
|
+
process.env.SERVICE_NAME = 'album-service';
|
|
36
|
+
const res = mockRes();
|
|
37
|
+
|
|
38
|
+
await healthCheck({} as Request, res);
|
|
39
|
+
|
|
40
|
+
expect(res.status).toHaveBeenCalledWith(200);
|
|
41
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
42
|
+
expect.objectContaining({
|
|
43
|
+
status: 'ok',
|
|
44
|
+
service: 'album-service',
|
|
45
|
+
dependencies: { mongodb: 'connected' }
|
|
46
|
+
})
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('responds 503 with dependencies.mongodb "disconnected" when Mongo readyState is not 1', async () => {
|
|
51
|
+
jest.spyOn(mongoose, 'connection', 'get').mockReturnValue({ readyState: 0 } as any);
|
|
52
|
+
const res = mockRes();
|
|
53
|
+
|
|
54
|
+
await healthCheck({} as Request, res);
|
|
55
|
+
|
|
56
|
+
expect(res.status).toHaveBeenCalledWith(503);
|
|
57
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
58
|
+
expect.objectContaining({ status: 'error', dependencies: { mongodb: 'disconnected' } })
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('falls back to default service/version/description when env vars are unset', async () => {
|
|
63
|
+
delete process.env.SERVICE_NAME;
|
|
64
|
+
delete process.env.npm_package_version;
|
|
65
|
+
delete process.env.SERVICE_DESCRIPTION;
|
|
66
|
+
jest.spyOn(mongoose, 'connection', 'get').mockReturnValue({ readyState: 1 } as any);
|
|
67
|
+
const res = mockRes();
|
|
68
|
+
|
|
69
|
+
await healthCheck({} as Request, res);
|
|
70
|
+
|
|
71
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
72
|
+
expect.objectContaining({
|
|
73
|
+
service: 'unknown-service',
|
|
74
|
+
version: '1.0.0',
|
|
75
|
+
description: 'Backend service'
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('responds 503 with a generic error body when reading connection state throws', async () => {
|
|
81
|
+
jest.spyOn(mongoose, 'connection', 'get').mockImplementation(() => {
|
|
82
|
+
throw new Error('boom');
|
|
83
|
+
});
|
|
84
|
+
const res = mockRes();
|
|
85
|
+
|
|
86
|
+
await healthCheck({} as Request, res);
|
|
87
|
+
|
|
88
|
+
expect(res.status).toHaveBeenCalledWith(503);
|
|
89
|
+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ status: 'error', error: 'Health check failed' }));
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Request, Response } from 'express';
|
|
2
|
+
import mongoose from 'mongoose';
|
|
3
|
+
import { metricsHandler } from '../metrics';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Health check endpoint with database connectivity
|
|
7
|
+
* Returns comprehensive health status for monitoring
|
|
8
|
+
*/
|
|
9
|
+
export const healthCheck = async (req: Request, res: Response) => {
|
|
10
|
+
try {
|
|
11
|
+
// Check MongoDB connection
|
|
12
|
+
const mongoState = mongoose.connection.readyState;
|
|
13
|
+
const isMongoHealthy = mongoState === 1; // 1 = connected
|
|
14
|
+
|
|
15
|
+
const healthResponse = {
|
|
16
|
+
status: isMongoHealthy ? 'ok' : 'error',
|
|
17
|
+
service: process.env.SERVICE_NAME || 'unknown-service',
|
|
18
|
+
type: 'backend',
|
|
19
|
+
timestamp: new Date().toISOString(),
|
|
20
|
+
version: process.env.npm_package_version || '1.0.0',
|
|
21
|
+
description: process.env.SERVICE_DESCRIPTION || 'Backend service',
|
|
22
|
+
dependencies: {
|
|
23
|
+
mongodb: isMongoHealthy ? 'connected' : 'disconnected'
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
res.status(isMongoHealthy ? 200 : 503).json(healthResponse);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
res.status(503).json({
|
|
30
|
+
status: 'error',
|
|
31
|
+
service: process.env.SERVICE_NAME || 'unknown-service',
|
|
32
|
+
type: 'backend',
|
|
33
|
+
timestamp: new Date().toISOString(),
|
|
34
|
+
version: process.env.npm_package_version || '1.0.0',
|
|
35
|
+
description: process.env.SERVICE_DESCRIPTION || 'Backend service',
|
|
36
|
+
error: 'Health check failed'
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Liveness check: confirms the process is up and serving HTTP, without
|
|
43
|
+
* touching MongoDB or any other dependency. Used by the Docker-level
|
|
44
|
+
* HEALTHCHECK so a transient DB outage doesn't get reported as the
|
|
45
|
+
* container itself being unhealthy — `/health` (above) is the DB-aware
|
|
46
|
+
* readiness check for dashboards/monitoring.
|
|
47
|
+
*/
|
|
48
|
+
export const healthLive = (req: Request, res: Response) => {
|
|
49
|
+
res.status(200).json({ status: 'ok' });
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Prometheus metrics endpoint
|
|
54
|
+
* Exposes application metrics for monitoring
|
|
55
|
+
*/
|
|
56
|
+
export { metricsHandler };
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// @tumbaland/backend-core - Core shared functionality for Tumbaland backend services
|
|
2
|
+
|
|
3
|
+
// App bootstrap
|
|
4
|
+
export { createBaseApp } from './app/createBaseApp';
|
|
5
|
+
export type { CreateBaseAppOptions } from './app/createBaseApp';
|
|
6
|
+
|
|
7
|
+
// Logging
|
|
8
|
+
export { default as logger } from './logging/logger';
|
|
9
|
+
|
|
10
|
+
// Health checks
|
|
11
|
+
export { healthCheck, healthLive, metricsHandler } from './health/healthController';
|
|
12
|
+
|
|
13
|
+
// Database
|
|
14
|
+
export { connectDB, disconnectDB } from './database/connection';
|
|
15
|
+
|
|
16
|
+
// Config
|
|
17
|
+
export { requireEnv } from './config/env';
|
|
18
|
+
|
|
19
|
+
// Errors
|
|
20
|
+
export { HttpError, BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ConflictError, TooManyRequestsError } from './errors/HttpError';
|
|
21
|
+
|
|
22
|
+
// Middleware
|
|
23
|
+
export { authenticateToken } from './middleware/authMiddleware';
|
|
24
|
+
export { createCorsMiddleware } from './middleware/corsMiddleware';
|
|
25
|
+
export { errorHandler } from './middleware/errorHandler';
|
|
26
|
+
export { requestLogger, requestLoggerWithMetrics, simpleRequestLogger } from './middleware/requestLogger';
|
|
27
|
+
export { securityHeaders, createRateLimiter, standardRateLimiter, strictRateLimiter } from './middleware/security';
|
|
28
|
+
export { validate } from './middleware/validate';
|
|
29
|
+
|
|
30
|
+
// Types
|
|
31
|
+
export type { UserPayload } from './types/auth';
|
|
32
|
+
|
|
33
|
+
// Utils
|
|
34
|
+
export { generateCorrelationId, correlationMiddleware } from './utils/correlation';
|
|
35
|
+
export { createApiResponse, sendSuccess, sendError } from './utils/response';
|
|
36
|
+
export type { ApiResponse } from './utils/response';
|
|
37
|
+
|
|
38
|
+
// Metrics
|
|
39
|
+
export {
|
|
40
|
+
metricsMiddleware,
|
|
41
|
+
httpRequestDuration,
|
|
42
|
+
httpRequestsTotal,
|
|
43
|
+
databaseQueryDuration,
|
|
44
|
+
databaseQueriesTotal,
|
|
45
|
+
businessMetrics,
|
|
46
|
+
register
|
|
47
|
+
} from './metrics';
|
|
48
|
+
|
|
49
|
+
// Tracing
|
|
50
|
+
export {
|
|
51
|
+
initTracer,
|
|
52
|
+
getTracer,
|
|
53
|
+
startSpan,
|
|
54
|
+
tracingMiddleware,
|
|
55
|
+
createChildSpan,
|
|
56
|
+
logToSpan,
|
|
57
|
+
setSpanTag,
|
|
58
|
+
injectHeaders,
|
|
59
|
+
extractSpanContext
|
|
60
|
+
} from './tracing';
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* logger.ts builds its transport list at module-load time based on
|
|
5
|
+
* NODE_ENV/LOG_TO_FILE, so each scenario needs a fresh module load via
|
|
6
|
+
* isolateModulesAsync rather than a single shared import.
|
|
7
|
+
*/
|
|
8
|
+
async function loadLogger(env: Record<string, string | undefined>) {
|
|
9
|
+
const ORIGINAL_ENV = process.env;
|
|
10
|
+
process.env = { ...ORIGINAL_ENV, ...env };
|
|
11
|
+
|
|
12
|
+
let loggerModule!: typeof import('./logger').default;
|
|
13
|
+
await jest.isolateModulesAsync(async () => {
|
|
14
|
+
loggerModule = (await import('./logger')).default;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
process.env = ORIGINAL_ENV;
|
|
18
|
+
return loggerModule;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// winston's `.transports` also holds the wrapped exception/rejection-handler
|
|
22
|
+
// transports (always present), so assert on named regular transports only.
|
|
23
|
+
function namedTransports(logger: import('winston').Logger, name: string) {
|
|
24
|
+
return logger.transports.filter((t) => (t as unknown as { name?: string }).name === name);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('logger', () => {
|
|
28
|
+
it('logs at debug level in development', async () => {
|
|
29
|
+
const logger = await loadLogger({ NODE_ENV: 'development', LOG_TO_FILE: undefined });
|
|
30
|
+
expect(logger.level).toBe('debug');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('logs at info level in production', async () => {
|
|
34
|
+
const logger = await loadLogger({ NODE_ENV: 'production' });
|
|
35
|
+
expect(logger.level).toBe('info');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('does not create a file transport when LOG_TO_FILE is unset', async () => {
|
|
39
|
+
const logger = await loadLogger({ NODE_ENV: 'development', LOG_TO_FILE: undefined });
|
|
40
|
+
expect(namedTransports(logger, 'file')).toHaveLength(0);
|
|
41
|
+
expect(namedTransports(logger, 'console')).toHaveLength(1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('does not create a file transport in production, even if LOG_TO_FILE is true', async () => {
|
|
45
|
+
const logger = await loadLogger({ NODE_ENV: 'production', LOG_TO_FILE: 'true' });
|
|
46
|
+
expect(namedTransports(logger, 'file')).toHaveLength(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('with LOG_TO_FILE=true in development', () => {
|
|
50
|
+
it('creates the logs directory when it does not exist and adds a file transport', async () => {
|
|
51
|
+
const existsSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(false);
|
|
52
|
+
const mkdirSpy = jest.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined as any);
|
|
53
|
+
|
|
54
|
+
const logger = await loadLogger({ NODE_ENV: 'development', LOG_TO_FILE: 'true' });
|
|
55
|
+
|
|
56
|
+
expect(mkdirSpy).toHaveBeenCalledWith(expect.stringContaining('logs'), { recursive: true });
|
|
57
|
+
expect(namedTransports(logger, 'file')).toHaveLength(1);
|
|
58
|
+
|
|
59
|
+
existsSpy.mockRestore();
|
|
60
|
+
mkdirSpy.mockRestore();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('does not recreate the logs directory when it already exists', async () => {
|
|
64
|
+
const existsSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
|
65
|
+
const mkdirSpy = jest.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined as any);
|
|
66
|
+
|
|
67
|
+
const logger = await loadLogger({ NODE_ENV: 'development', LOG_TO_FILE: 'true' });
|
|
68
|
+
|
|
69
|
+
expect(mkdirSpy).not.toHaveBeenCalled();
|
|
70
|
+
expect(namedTransports(logger, 'file')).toHaveLength(1);
|
|
71
|
+
|
|
72
|
+
existsSpy.mockRestore();
|
|
73
|
+
mkdirSpy.mockRestore();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|