Skip to content

Commit 6d20294

Browse files
committed
feat: implement Express server with configuration, middleware, and routing
- Set up the main entry point for the application with Express - Add application configuration management - Implement error handling middleware with custom error classes - Introduce security features using Helmet and CORS - Create basic routing with health check and API versioning endpoints - Add structured logging using Winston - Include integration tests for server setup and error handling
1 parent 4562e16 commit 6d20294

File tree

6 files changed

+366
-2
lines changed

6 files changed

+366
-2
lines changed

src/config/app.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Application configuration
3+
*/
4+
5+
export interface AppConfig {
6+
port: number;
7+
env: string;
8+
apiVersion: string;
9+
corsOrigin: string | string[];
10+
}
11+
12+
/**
13+
* Get application configuration from environment variables
14+
*/
15+
export function getAppConfig(): AppConfig {
16+
return {
17+
port: parseInt(process.env.PORT || '3000', 10),
18+
env: process.env.NODE_ENV || 'development',
19+
apiVersion: process.env.API_VERSION || 'v1',
20+
corsOrigin: process.env.CORS_ORIGIN || '*',
21+
};
22+
}

src/index.ts

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,128 @@
33
* Main entry point for the application
44
*/
55

6-
// This file will be implemented in later tasks
7-
// Placeholder to ensure TypeScript compilation works
6+
import express, { Express, Request, Response, NextFunction } from 'express';
7+
import cors from 'cors';
8+
import helmet from 'helmet';
9+
import { getAppConfig } from './config/app';
10+
import { errorHandler, notFoundHandler, ApiError } from './middleware/errorHandler';
11+
import { logger } from './utils/logger';
12+
import routes from './routes';
13+
14+
/**
15+
* Create and configure Express application
16+
*/
17+
function createApp(): Express {
18+
const app = express();
19+
const config = getAppConfig();
20+
21+
// Trust proxy (for rate limiting behind reverse proxy)
22+
app.set('trust proxy', 1);
23+
24+
// Security middleware
25+
app.use(
26+
helmet({
27+
contentSecurityPolicy: {
28+
directives: {
29+
defaultSrc: ["'self'"],
30+
styleSrc: ["'self'", "'unsafe-inline'"],
31+
scriptSrc: ["'self'"],
32+
imgSrc: ["'self'", 'data:', 'https:'],
33+
},
34+
},
35+
})
36+
);
37+
38+
// CORS configuration
39+
app.use(
40+
cors({
41+
origin: config.corsOrigin,
42+
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
43+
allowedHeaders: ['Content-Type', 'Authorization', 'User-Agent'],
44+
exposedHeaders: ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset'],
45+
})
46+
);
47+
48+
// Body parsing middleware
49+
app.use(express.json({ limit: '10mb' }));
50+
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
51+
52+
// Handle JSON parsing errors
53+
app.use(
54+
(
55+
err: Error & { status?: number; type?: string },
56+
req: Request,
57+
res: Response,
58+
next: NextFunction
59+
) => {
60+
if (err instanceof SyntaxError && err.type === 'entity.parse.failed') {
61+
const apiError = new ApiError(400, 'Invalid JSON in request body');
62+
return errorHandler(apiError, req, res, next);
63+
}
64+
next(err);
65+
}
66+
);
67+
68+
// Request logging middleware
69+
app.use((req: Request, _res: Response, next: NextFunction) => {
70+
logger.info('Incoming request', {
71+
method: req.method,
72+
path: req.path,
73+
ip: req.ip,
74+
userAgent: req.get('User-Agent'),
75+
});
76+
next();
77+
});
78+
79+
// Routes
80+
app.use('/', routes);
81+
82+
// 404 handler (must be after all routes)
83+
app.use(notFoundHandler);
84+
85+
// Error handler (must be last)
86+
app.use(errorHandler);
87+
88+
return app;
89+
}
90+
91+
/**
92+
* Start the server
93+
*/
94+
function startServer(): void {
95+
const app = createApp();
96+
const config = getAppConfig();
97+
98+
const server = app.listen(config.port, () => {
99+
logger.info('Server started', {
100+
port: config.port,
101+
env: config.env,
102+
apiVersion: config.apiVersion,
103+
});
104+
});
105+
106+
// Graceful shutdown
107+
process.on('SIGTERM', () => {
108+
logger.info('SIGTERM received, shutting down gracefully');
109+
server.close(() => {
110+
logger.info('Server closed');
111+
process.exit(0);
112+
});
113+
});
114+
115+
process.on('SIGINT', () => {
116+
logger.info('SIGINT received, shutting down gracefully');
117+
server.close(() => {
118+
logger.info('Server closed');
119+
process.exit(0);
120+
});
121+
});
122+
}
123+
124+
// Start server if this file is run directly
125+
if (require.main === module) {
126+
startServer();
127+
}
128+
129+
// Export app factory for testing
130+
export default createApp;

src/middleware/errorHandler.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* Error handling middleware
3+
*/
4+
5+
import { Request, Response, NextFunction } from 'express';
6+
import { logger } from '../utils/logger';
7+
8+
/**
9+
* Custom error class for API errors
10+
*/
11+
export class ApiError extends Error {
12+
statusCode: number;
13+
isOperational: boolean;
14+
15+
constructor(statusCode: number, message: string, isOperational = true) {
16+
super(message);
17+
this.statusCode = statusCode;
18+
this.isOperational = isOperational;
19+
Error.captureStackTrace(this, this.constructor);
20+
}
21+
}
22+
23+
/**
24+
* Error handler middleware
25+
* Handles all errors and sends appropriate responses
26+
*/
27+
export function errorHandler(
28+
err: Error | ApiError,
29+
req: Request,
30+
res: Response,
31+
_next: NextFunction
32+
): void {
33+
// Log error
34+
logger.error('Error occurred', {
35+
error: err.message,
36+
stack: err.stack,
37+
path: req.path,
38+
method: req.method,
39+
ip: req.ip,
40+
});
41+
42+
// Handle known API errors
43+
if (err instanceof ApiError && err.isOperational) {
44+
res.status(err.statusCode).json({
45+
error: getErrorName(err.statusCode),
46+
message: err.message,
47+
statusCode: err.statusCode,
48+
});
49+
return;
50+
}
51+
52+
// Handle unknown errors
53+
const statusCode = (err as ApiError).statusCode || 500;
54+
const message = process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message;
55+
56+
res.status(statusCode).json({
57+
error: getErrorName(statusCode),
58+
message,
59+
statusCode,
60+
...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
61+
});
62+
}
63+
64+
/**
65+
* 404 Not Found handler
66+
*/
67+
export function notFoundHandler(req: Request, _res: Response, next: NextFunction): void {
68+
const error = new ApiError(404, `Route ${req.method} ${req.path} not found`);
69+
next(error);
70+
}
71+
72+
/**
73+
* Get error name from status code
74+
*/
75+
function getErrorName(statusCode: number): string {
76+
const errorNames: Record<number, string> = {
77+
400: 'Bad Request',
78+
401: 'Unauthorized',
79+
403: 'Forbidden',
80+
404: 'Not Found',
81+
429: 'Too Many Requests',
82+
500: 'Internal Server Error',
83+
502: 'Bad Gateway',
84+
503: 'Service Unavailable',
85+
};
86+
87+
return errorNames[statusCode] || 'Error';
88+
}

src/routes/index.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* Main routes index
3+
*/
4+
5+
import { Router } from 'express';
6+
import { getAppConfig } from '../config/app';
7+
8+
const router = Router();
9+
const { apiVersion } = getAppConfig();
10+
11+
/**
12+
* API version info endpoint
13+
*/
14+
router.get(`/api/${apiVersion}`, (_req, res) => {
15+
res.json({
16+
name: 'OSM Notes API',
17+
version: process.env.npm_package_version || '0.1.0',
18+
apiVersion,
19+
status: 'operational',
20+
});
21+
});
22+
23+
/**
24+
* Health check route (will be moved to separate file later)
25+
*/
26+
router.get('/health', (_req, res) => {
27+
res.json({
28+
status: 'ok',
29+
timestamp: new Date().toISOString(),
30+
});
31+
});
32+
33+
export default router;

src/utils/logger.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Structured logging utility using Winston
3+
* Placeholder implementation - will be fully implemented in Task 2.2
4+
*/
5+
6+
import winston from 'winston';
7+
8+
/**
9+
* Logger instance
10+
* Full configuration will be added in Task 2.2
11+
*/
12+
export const logger = winston.createLogger({
13+
level: process.env.LOG_LEVEL || 'info',
14+
format: winston.format.combine(
15+
winston.format.timestamp(),
16+
winston.format.errors({ stack: true }),
17+
winston.format.json()
18+
),
19+
defaultMeta: { service: 'osm-notes-api' },
20+
transports: [
21+
new winston.transports.Console({
22+
format: winston.format.combine(winston.format.colorize(), winston.format.simple()),
23+
}),
24+
],
25+
});

tests/integration/server.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Integration tests for Express server setup
3+
*/
4+
5+
import request from 'supertest';
6+
import { Express } from 'express';
7+
8+
describe('Express Server Setup', () => {
9+
let app: Express;
10+
11+
beforeAll(async () => {
12+
// Import app after all modules are loaded
13+
const { default: createApp } = await import('../../src/index');
14+
app = createApp();
15+
});
16+
17+
describe('Basic Server Configuration', () => {
18+
it('should start server and respond to requests', async () => {
19+
const response = await request(app).get('/health');
20+
expect(response.status).toBeDefined();
21+
});
22+
23+
it('should have CORS enabled', async () => {
24+
const response = await request(app).options('/health').set('Origin', 'http://example.com');
25+
26+
expect(response.headers['access-control-allow-origin']).toBeDefined();
27+
});
28+
29+
it('should have security headers (Helmet)', async () => {
30+
const response = await request(app).get('/health');
31+
32+
expect(response.headers['x-content-type-options']).toBe('nosniff');
33+
expect(response.headers['x-frame-options']).toBeDefined();
34+
});
35+
36+
it('should parse JSON body', async () => {
37+
// Test JSON parsing by checking that invalid JSON returns 400
38+
const response = await request(app)
39+
.post('/api/v1')
40+
.send('invalid json')
41+
.set('Content-Type', 'application/json');
42+
43+
// Should handle invalid JSON gracefully (either 400 or 404)
44+
expect([400, 404]).toContain(response.status);
45+
});
46+
});
47+
48+
describe('Error Handling', () => {
49+
it('should handle 404 errors', async () => {
50+
const response = await request(app).get('/nonexistent');
51+
52+
expect(response.status).toBe(404);
53+
expect(response.body).toHaveProperty('error');
54+
});
55+
56+
it('should handle errors with proper format', async () => {
57+
const response = await request(app).get('/nonexistent');
58+
59+
expect(response.body).toHaveProperty('error');
60+
expect(response.body).toHaveProperty('message');
61+
});
62+
});
63+
64+
describe('API Versioning', () => {
65+
it('should have /api/v1 prefix', async () => {
66+
// This will be implemented when routes are added
67+
const response = await request(app).get('/api/v1');
68+
69+
// Should not be 404 if versioning is set up correctly
70+
expect(response.status).toBeDefined();
71+
});
72+
});
73+
});

0 commit comments

Comments
 (0)