From 16f713c0a1bea90daff6b675088c2bfd486f6d8a Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 26 Jul 2026 18:18:36 +0100 Subject: [PATCH] Refactor: Split monolithic index.js into modular Express application structure - Create src/config/index.js for centralized configuration - Create src/routes/index.js for route aggregation - Create src/services/index.js for service initialization - Create src/app.js for Express app configuration - Create src/server.js for HTTP server with graceful shutdown - Update index.js to minimal entry point - Update package.json scripts - Update test imports to use src/app.js - Add unit tests for app.js - Add integration tests for server.js This refactoring improves separation of concerns, testability, and maintainability. The app can now be imported without starting the server, enabling faster tests. Graceful shutdown handling for SIGTERM/SIGINT signals is implemented. Resolves #28 --- index.js | 55 +-------------- package.json | 3 +- src/app.js | 86 ++++++++++++++++++++++++ src/config/index.js | 33 +++++++++ src/routes/index.js | 19 ++++++ src/server.js | 115 ++++++++++++++++++++++++++++++++ src/services/index.js | 82 +++++++++++++++++++++++ test/app.test.js | 55 +++++++++++++++ test/server-integration.test.js | 78 ++++++++++++++++++++++ test/server.test.js | 2 +- 10 files changed, 472 insertions(+), 56 deletions(-) create mode 100644 src/app.js create mode 100644 src/config/index.js create mode 100644 src/routes/index.js create mode 100644 src/server.js create mode 100644 src/services/index.js create mode 100644 test/app.test.js create mode 100644 test/server-integration.test.js diff --git a/index.js b/index.js index 8a430664..440ef303 100644 --- a/index.js +++ b/index.js @@ -1,54 +1 @@ -require('./src/config/tracing'); - -const crypto = require('crypto'); -const express = require('express'); -const { trace } = require('@opentelemetry/api'); -const { childLogger } = require('./src/config/logger'); - -const app = express(); -const log = childLogger('http'); - -const contractId = process.env.CONTRACT_ID || 'CB7PSJZALNWNX7NLOAM6LOEL4OJZMFPQZJMIYO522ZSACYWXTZIDEDSS'; - -app.use((req, res, next) => { - const correlationId = req.headers['x-correlation-id'] || crypto.randomUUID(); - req.correlationId = correlationId; - res.setHeader('x-correlation-id', correlationId); - - const activeSpan = trace.getActiveSpan(); - if (activeSpan) { - activeSpan.setAttribute('correlation.id', correlationId); - } - - const start = process.hrtime.bigint(); - - res.on('finish', () => { - const durationMs = Number(process.hrtime.bigint() - start) / 1e6; - log.info( - { - correlationId, - method: req.method, - url: req.originalUrl, - status: res.statusCode, - durationMs, - }, - 'request completed' - ); - }); - - next(); -}); - -app.get('/', (req, res) => { - res.json({ - project: 'Equipchain', - status: 'Monitoring Meters', - contract: contractId, - }); -}); - -if (require.main === module) { - app.listen(3000, () => log.info('Equipchain API running')); -} - -module.exports = app; +require('./src/server'); diff --git a/package.json b/package.json index e9c91e69..2cd42698 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "index.js", "scripts": { "test": "NODE_ENV=test node --test", - "start": "node index.js" + "start": "node index.js", + "start:server": "node src/server.js" }, "dependencies": { "@opentelemetry/api": "^1.9.1", diff --git a/src/app.js b/src/app.js new file mode 100644 index 00000000..5f7fb130 --- /dev/null +++ b/src/app.js @@ -0,0 +1,86 @@ +const crypto = require('crypto'); +const express = require('express'); +const cors = require('cors'); +const { trace } = require('@opentelemetry/api'); +const { childLogger } = require('./config/logger'); +const config = require('./config'); +const routes = require('./routes'); + +const app = express(); +const log = childLogger('http'); + +// Security middleware +app.use(cors()); + +// Body parsing middleware +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); + +// Correlation ID and request logging middleware +app.use((req, res, next) => { + const correlationId = req.headers['x-correlation-id'] || crypto.randomUUID(); + req.correlationId = correlationId; + res.setHeader('x-correlation-id', correlationId); + + const activeSpan = trace.getActiveSpan(); + if (activeSpan) { + activeSpan.setAttribute('correlation.id', correlationId); + } + + const start = process.hrtime.bigint(); + + res.on('finish', () => { + const durationMs = Number(process.hrtime.bigint() - start) / 1e6; + log.info( + { + correlationId, + method: req.method, + url: req.originalUrl, + status: res.statusCode, + durationMs, + }, + 'request completed' + ); + }); + + next(); +}); + +// Mount routes +app.use('/', routes); + +// Root route with project info +app.get('/', (req, res) => { + res.json({ + project: 'Equipchain', + status: 'Monitoring Meters', + contract: config.contractId, + }); +}); + +// 404 handler +app.use((req, res) => { + res.status(404).json({ + error: 'Not Found', + message: `Cannot ${req.method} ${req.originalUrl}`, + }); +}); + +// Error handling middleware +app.use((err, req, res, next) => { + log.error( + { + correlationId: req.correlationId, + error: err.message, + stack: err.stack, + }, + 'request error' + ); + + res.status(err.status || 500).json({ + error: err.name || 'Internal Server Error', + message: config.isProduction ? 'An error occurred' : err.message, + }); +}); + +module.exports = app; diff --git a/src/config/index.js b/src/config/index.js new file mode 100644 index 00000000..36edcea7 --- /dev/null +++ b/src/config/index.js @@ -0,0 +1,33 @@ +require('dotenv').config(); + +const { + NODE_ENV = 'development', + PORT = '3000', + CONTRACT_ID = 'CB7PSJZALNWNX7NLOAM6LOEL4OJZMFPQZJMIYO522ZSACYWXTZIDEDSS', + LOG_LEVEL = 'info', + OTEL_SERVICE_NAME = 'equipchain-api', +} = process.env; + +// Validate required environment variables for production +const isProduction = NODE_ENV === 'production'; +const isTest = NODE_ENV === 'test'; + +if (isProduction) { + // Add any production-specific required variables here + // For example: JWT_SECRET, DATABASE_URL, etc. + // Currently no additional required variables for this project +} + +const config = Object.freeze({ + env: NODE_ENV, + port: parseInt(PORT, 10), + contractId: CONTRACT_ID, + logLevel: LOG_LEVEL, + otel: { + serviceName: OTEL_SERVICE_NAME, + }, + isProduction, + isTest, +}); + +module.exports = config; diff --git a/src/routes/index.js b/src/routes/index.js new file mode 100644 index 00000000..3b3516f9 --- /dev/null +++ b/src/routes/index.js @@ -0,0 +1,19 @@ +const express = require('express'); +const router = express.Router(); + +// Import route modules here as they are created +// const authRoutes = require('./auth'); +// const adminRoutes = require('./admin'); +// const analyticsRoutes = require('./analytics'); + +// Mount routes under their respective prefixes +// router.use('/api/auth', authRoutes); +// router.use('/api/admin', adminRoutes); +// router.use('/api/analytics', analyticsRoutes); + +// Health check route +router.get('/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +module.exports = router; diff --git a/src/server.js b/src/server.js new file mode 100644 index 00000000..299425e0 --- /dev/null +++ b/src/server.js @@ -0,0 +1,115 @@ +require('./config/tracing'); +const http = require('http'); +const { childLogger } = require('./config/logger'); +const config = require('./config'); +const app = require('./app'); +const { initServices, shutdownServices } = require('./services'); + +const log = childLogger('server'); + +let server; +let isShuttingDown = false; + +/** + * Start the HTTP server + */ +async function startServer() { + try { + // Initialize services + await initServices(app); + + // Create HTTP server + server = http.createServer(app); + + // Start listening + server.listen(config.port, () => { + log.info( + { + port: config.port, + env: config.env, + contractId: config.contractId, + }, + 'Equipchain API server started' + ); + }); + + // Handle server errors + server.on('error', (error) => { + if (error.code === 'EADDRINUSE') { + log.error({ port: config.port }, 'Port already in use'); + } else { + log.error({ error }, 'Server error'); + } + process.exit(1); + }); + } catch (error) { + log.error({ error }, 'Failed to start server'); + process.exit(1); + } +} + +/** + * Gracefully shutdown the server + */ +async function gracefulShutdown(signal) { + if (isShuttingDown) { + log.warn('Shutdown already in progress, ignoring signal'); + return; + } + + isShuttingDown = true; + log.info({ signal }, 'Received shutdown signal, starting graceful shutdown'); + + // Stop accepting new connections + if (server) { + server.close(async (err) => { + if (err) { + log.error({ error: err }, 'Error closing server'); + process.exit(1); + } + + log.info('HTTP server closed'); + + try { + // Shutdown services + await shutdownServices(); + log.info('Graceful shutdown complete'); + process.exit(0); + } catch (error) { + log.error({ error }, 'Error during service shutdown'); + process.exit(1); + } + }); + + // Force shutdown after timeout + setTimeout(() => { + log.error('Forced shutdown after timeout'); + process.exit(1); + }, 10000); + } else { + process.exit(0); + } +} + +// Register signal handlers +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.on('SIGINT', () => gracefulShutdown('SIGINT')); + +// Handle uncaught exceptions +process.on('uncaughtException', (error) => { + log.error({ error }, 'Uncaught exception'); + gracefulShutdown('uncaughtException'); +}); + +// Handle unhandled promise rejections +process.on('unhandledRejection', (reason, promise) => { + log.error({ reason, promise }, 'Unhandled promise rejection'); + gracefulShutdown('unhandledRejection'); +}); + +// Start server if this file is run directly +if (require.main === module) { + startServer(); +} + +module.exports = { startServer, gracefulShutdown }; diff --git a/src/services/index.js b/src/services/index.js new file mode 100644 index 00000000..09a748b7 --- /dev/null +++ b/src/services/index.js @@ -0,0 +1,82 @@ +const { childLogger } = require('../config/logger'); +const log = childLogger('services'); + +// Service registry to track initialized services +const services = { + cache: null, + queue: null, + eventListener: null, + websocket: null, +}; + +/** + * Initialize all services in the correct dependency order + * @param {Object} app - Express application instance + */ +async function initServices(app) { + try { + log.info('Initializing services...'); + + // Initialize cache service (if implemented) + // services.cache = await initCache(); + // log.info('Cache service initialized'); + + // Initialize queue service (if implemented) + // services.queue = await initQueue(); + // log.info('Queue service initialized'); + + // Initialize event listener (if implemented) + // services.eventListener = await initEventListener(); + // log.info('Event listener initialized'); + + // Initialize WebSocket (if implemented) + // services.websocket = await initWebSocket(app); + // log.info('WebSocket initialized'); + + log.info('All services initialized successfully'); + } catch (error) { + log.error({ error }, 'Failed to initialize services'); + throw error; + } +} + +/** + * Gracefully shutdown all services + */ +async function shutdownServices() { + try { + log.info('Shutting down services...'); + + // Shutdown services in reverse dependency order + if (services.websocket) { + await services.websocket.close(); + log.info('WebSocket shutdown complete'); + } + + if (services.eventListener) { + await services.eventListener.stop(); + log.info('Event listener shutdown complete'); + } + + if (services.queue) { + await services.queue.close(); + log.info('Queue shutdown complete'); + } + + if (services.cache) { + await services.cache.quit(); + log.info('Cache shutdown complete'); + } + + log.info('All services shutdown complete'); + } catch (error) { + log.error({ error }, 'Error during service shutdown'); + throw error; + } +} + +module.exports = { + initServices, + shutdownServices, + services, +}; diff --git a/test/app.test.js b/test/app.test.js new file mode 100644 index 00000000..972bc555 --- /dev/null +++ b/test/app.test.js @@ -0,0 +1,55 @@ +const { describe, it, after } = require('node:test'); +const assert = require('node:assert'); + +const app = require('../src/app'); +const server = app.listen(0); + +after(() => server.close()); + +describe('app', () => { + it('creates an Express application instance', () => { + assert.strictEqual(typeof app, 'function'); + assert.strictEqual(app.name, 'express'); + }); + + it('responds to root route with project info', async () => { + const res = await fetch(`http://localhost:${server.address().port}/`); + assert.strictEqual(res.status, 200); + + const data = await res.json(); + assert.strictEqual(data.project, 'Equipchain'); + assert.strictEqual(data.status, 'Monitoring Meters'); + assert.ok(data.contract); + }); + + it('responds to health check route', async () => { + const res = await fetch(`http://localhost:${server.address().port}/health`); + assert.strictEqual(res.status, 200); + + const data = await res.json(); + assert.strictEqual(data.status, 'ok'); + assert.ok(data.timestamp); + }); + + it('returns 404 for non-existent routes', async () => { + const res = await fetch(`http://localhost:${server.address().port}/non-existent-route`); + assert.strictEqual(res.status, 404); + + const data = await res.json(); + assert.strictEqual(data.error, 'Not Found'); + }); + + it('includes correlation ID header in response', async () => { + const res = await fetch(`http://localhost:${server.address().port}/`); + assert.ok(res.headers.get('x-correlation-id')); + }); + + it('uses provided correlation ID from header', async () => { + const testCorrelationId = 'test-correlation-id-123'; + const res = await fetch(`http://localhost:${server.address().port}/`, { + headers: { 'x-correlation-id': testCorrelationId }, + }); + + assert.strictEqual(res.headers.get('x-correlation-id'), testCorrelationId); + }); +}); diff --git a/test/server-integration.test.js b/test/server-integration.test.js new file mode 100644 index 00000000..9a7c1bba --- /dev/null +++ b/test/server-integration.test.js @@ -0,0 +1,78 @@ +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert'); +const { spawn } = require('node:child_process'); +const http = require('node:http'); + +describe('server integration', () => { + let serverProcess; + let serverPort = 3001; // Use different port to avoid conflicts + + before(async () => { + // Start server in a separate process + serverProcess = spawn('node', ['src/server.js'], { + env: { ...process.env, PORT: serverPort.toString() }, + stdio: 'pipe', + }); + + // Wait for server to start + await new Promise((resolve, reject) => { + let attempts = 0; + const maxAttempts = 10; + + const checkServer = () => { + attempts++; + const req = http.get(`http://localhost:${serverPort}/`, (res) => { + if (res.statusCode === 200) { + resolve(); + } else { + reject(new Error(`Server responded with status ${res.statusCode}`)); + } + }); + + req.on('error', (err) => { + if (attempts < maxAttempts) { + setTimeout(checkServer, 500); + } else { + reject(new Error('Server failed to start after multiple attempts')); + } + }); + }; + + checkServer(); + }); + }); + + after(() => { + if (serverProcess) { + serverProcess.kill('SIGTERM'); + } + }); + + it('server starts and responds to HTTP requests', async () => { + const response = await fetch(`http://localhost:${serverPort}/`); + assert.strictEqual(response.status, 200); + + const data = await response.json(); + assert.strictEqual(data.project, 'Equipchain'); + assert.strictEqual(data.status, 'Monitoring Meters'); + }); + + it('server handles health check endpoint', async () => { + const response = await fetch(`http://localhost:${serverPort}/health`); + assert.strictEqual(response.status, 200); + + const data = await response.json(); + assert.strictEqual(data.status, 'ok'); + assert.ok(data.timestamp); + }); + + it('server includes correlation ID in responses', async () => { + const response = await fetch(`http://localhost:${serverPort}/`); + assert.ok(response.headers.get('x-correlation-id')); + }); + + it('server returns 404 for non-existent routes', async () => { + const response = await fetch(`http://localhost:${serverPort}/non-existent`); + assert.strictEqual(response.status, 404); + }); +}); diff --git a/test/server.test.js b/test/server.test.js index 6712fb29..32538601 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -1,7 +1,7 @@ const { describe, it, after } = require('node:test'); const assert = require('node:assert'); -const app = require('../index'); +const app = require('../src/app'); const server = app.listen(0); after(() => server.close());