diff --git a/index.js b/index.js index 33b6f662..6a325ae6 100644 --- a/index.js +++ b/index.js @@ -91,4 +91,4 @@ if (require.main === module) { app.listen(3000, () => log.info('Equipchain API running')); } -module.exports = app; \ No newline at end of file +module.exports = app; 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 61ba433f..54103e5e 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());