feat(F-001): scaffold modular monolith skeleton with boundary checker
- TypeScript + Fastify skeleton under project/ (src/modules, shared, infrastructure, app) - scripts/check-module-boundaries.mjs enforcing module public-API rules (tested with fixtures) - GET /health endpoint, error envelope without stack leakage - specs/F-001-scaffold (SPEC/DESIGN/TASKS/TESTS), spec/tech.md dependency justification - 30-ticket MercadoDeVida roadmap in backlog/features.json, spec/roadmap.md - All gates approved: reviewer, security, qa; verify.sh green
This commit is contained in:
31
project/src/app/build-app.ts
Normal file
31
project/src/app/build-app.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { registerHealthRoutes } from '../modules/health/index.js';
|
||||
import { errorEnvelope } from '../shared/errors.js';
|
||||
|
||||
function notFoundHandler(_request: FastifyRequest, reply: FastifyReply): void {
|
||||
void reply.code(404).send(errorEnvelope(404, 'Not Found'));
|
||||
}
|
||||
|
||||
function errorHandler(error: FastifyError, _request: FastifyRequest, reply: FastifyReply): void {
|
||||
const statusCode =
|
||||
error.statusCode !== undefined && error.statusCode >= 400 ? error.statusCode : 500;
|
||||
const message = statusCode >= 500 ? 'Internal Server Error' : error.message;
|
||||
void reply.code(statusCode).send(errorEnvelope(statusCode, message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Composition root. The only place allowed to wire modules together.
|
||||
*/
|
||||
export async function buildApp(): Promise<FastifyInstance> {
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
app.setErrorHandler(errorHandler);
|
||||
app.setNotFoundHandler(notFoundHandler);
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerHealthRoutes(instance);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
25
project/src/app/tests/build-app.test.ts
Normal file
25
project/src/app/tests/build-app.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildApp } from '../build-app.js';
|
||||
|
||||
describe('composition root', () => {
|
||||
it('exposes GET /health through the wired app', async () => {
|
||||
const app = await buildApp();
|
||||
const response = await app.inject({ method: 'GET', url: '/health' });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ status: 'ok' });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns 404 JSON envelope for unknown routes without leaking internals', async () => {
|
||||
const app = await buildApp();
|
||||
const response = await app.inject({ method: 'GET', url: '/does-not-exist' });
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
const body = response.json() as { error: { statusCode: number; message: string } };
|
||||
expect(body.error.statusCode).toBe(404);
|
||||
expect(body.error.message).toBe('Not Found');
|
||||
expect(response.body).not.toContain('stack');
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
12
project/src/infrastructure/http/server.ts
Normal file
12
project/src/infrastructure/http/server.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { buildApp } from '../../app/build-app.js';
|
||||
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
const host = process.env.HOST ?? '0.0.0.0';
|
||||
|
||||
try {
|
||||
const app = await buildApp();
|
||||
await app.listen({ port, host });
|
||||
} catch (error) {
|
||||
console.error('Failed to start HTTP server', error);
|
||||
process.exit(1);
|
||||
}
|
||||
11
project/src/modules/health/api/health.routes.ts
Normal file
11
project/src/modules/health/api/health.routes.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
interface HealthResponse {
|
||||
status: 'ok';
|
||||
}
|
||||
|
||||
export async function registerHealthRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get('/health', async (): Promise<HealthResponse> => {
|
||||
return { status: 'ok' };
|
||||
});
|
||||
}
|
||||
5
project/src/modules/health/index.ts
Normal file
5
project/src/modules/health/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Public API of the health module. Everything a module exposes to the
|
||||
* outside world goes through this file.
|
||||
*/
|
||||
export { registerHealthRoutes } from './api/health.routes.js';
|
||||
18
project/src/modules/health/tests/health.test.ts
Normal file
18
project/src/modules/health/tests/health.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import Fastify from 'fastify';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { registerHealthRoutes } from '../index.js';
|
||||
|
||||
describe('health module API', () => {
|
||||
it('GET /health returns 200 and status ok', async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(async (instance) => {
|
||||
await registerHealthRoutes(instance);
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: 'GET', url: '/health' });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ status: 'ok' });
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
15
project/src/shared/errors.ts
Normal file
15
project/src/shared/errors.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Shared error envelope. Single shape for every API error.
|
||||
* Never leak stack traces or internal details to the client.
|
||||
*/
|
||||
|
||||
export interface ErrorEnvelope {
|
||||
error: {
|
||||
statusCode: number;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function errorEnvelope(statusCode: number, message: string): ErrorEnvelope {
|
||||
return { error: { statusCode, message } };
|
||||
}
|
||||
Reference in New Issue
Block a user