feat: establish backend foundation
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@@ -14,6 +16,41 @@ describe('AppController', () => {
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
it('documents the safe internal error response', async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
const app: INestApplication = module.createNestApplication();
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
const document: OpenAPIObject = SwaggerModule.createDocument(
|
||||
app,
|
||||
new DocumentBuilder().build(),
|
||||
);
|
||||
|
||||
expect(document.paths['/api/v1']?.get?.responses['500']).toEqual({
|
||||
description: 'Internal server error',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/SafeErrorResponseDto' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(document.paths['/api/v1']?.get?.responses['200']).toEqual(
|
||||
expect.objectContaining({
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'string', example: 'Hello World!' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiOkResponse } from '@nestjs/swagger';
|
||||
import { ApiSafeErrorResponse } from './common/decorators/api-safe-error-response.decorator';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
@@ -6,6 +8,10 @@ export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponse({
|
||||
schema: { type: 'string', example: 'Hello World!' },
|
||||
})
|
||||
@ApiSafeErrorResponse()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
|
||||
+10
-1
@@ -1,9 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { validateEnv } from './config/env.validation';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validate: validateEnv,
|
||||
}),
|
||||
PrismaModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ApiInternalServerErrorResponse, ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class SafeErrorResponseDto {
|
||||
@ApiProperty({ example: 500 })
|
||||
statusCode: number;
|
||||
|
||||
@ApiProperty({ example: 'Internal server error' })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ example: '/api/v1' })
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function ApiSafeErrorResponse(): MethodDecorator & ClassDecorator {
|
||||
return ApiInternalServerErrorResponse({
|
||||
description: 'Internal server error',
|
||||
type: SafeErrorResponseDto,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ArgumentsHost } from '@nestjs/common';
|
||||
import { HttpExceptionFilter } from './http-exception.filter';
|
||||
|
||||
describe('HttpExceptionFilter', () => {
|
||||
it('does not expose internal error details', () => {
|
||||
const status = jest.fn().mockReturnThis();
|
||||
const json = jest.fn();
|
||||
const filter = new HttpExceptionFilter();
|
||||
const host = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
path: '/api/v1/health',
|
||||
url: '/api/v1/health?access_token=secret',
|
||||
}),
|
||||
getResponse: () => ({ status, json }),
|
||||
}),
|
||||
} as unknown as ArgumentsHost;
|
||||
|
||||
filter.catch(new Error('database password: secret'), host);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(500);
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
statusCode: 500,
|
||||
message: 'Internal server error',
|
||||
path: '/api/v1/health',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const context = host.switchToHttp();
|
||||
const response = context.getResponse<Response>();
|
||||
const request = context.getRequest<Request>();
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
message: status === 500 ? 'Internal server error' : 'Request failed',
|
||||
path: request.path,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { validateEnv } from './env.validation';
|
||||
|
||||
describe('validateEnv', () => {
|
||||
it('normalizes FRONTEND_ORIGIN to its canonical origin', () => {
|
||||
expect(
|
||||
validateEnv({
|
||||
DATABASE_URL: 'file:./data.db',
|
||||
FRONTEND_ORIGIN: 'http://localhost:5173/',
|
||||
}),
|
||||
).toEqual({
|
||||
DATABASE_URL: 'file:./data.db',
|
||||
FRONTEND_ORIGIN: 'http://localhost:5173',
|
||||
PORT: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails fast when required configuration is missing', () => {
|
||||
expect(() =>
|
||||
validateEnv({ FRONTEND_ORIGIN: 'http://localhost:5173' }),
|
||||
).toThrow('DATABASE_URL must be a SQLite file URL');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://frontend.example/app',
|
||||
'https://frontend.example?token=secret',
|
||||
'https://frontend.example#fragment',
|
||||
'ftp://frontend.example',
|
||||
])(
|
||||
'rejects a frontend origin that is not a canonical HTTP origin: %s',
|
||||
(origin) => {
|
||||
expect(() =>
|
||||
validateEnv({
|
||||
DATABASE_URL: 'file:./data.db',
|
||||
FRONTEND_ORIGIN: origin,
|
||||
}),
|
||||
).toThrow('FRONTEND_ORIGIN must be a valid HTTP origin');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
export interface Environment {
|
||||
DATABASE_URL: string;
|
||||
FRONTEND_ORIGIN: string;
|
||||
PORT: number;
|
||||
}
|
||||
|
||||
export function validateEnv(config: Record<string, unknown>): Environment {
|
||||
const databaseUrl = config['DATABASE_URL'];
|
||||
const frontendOrigin = config['FRONTEND_ORIGIN'];
|
||||
const port = config['PORT'] ?? 3000;
|
||||
|
||||
if (typeof databaseUrl !== 'string' || !databaseUrl.startsWith('file:')) {
|
||||
throw new Error('DATABASE_URL must be a SQLite file URL');
|
||||
}
|
||||
|
||||
if (typeof frontendOrigin !== 'string') {
|
||||
throw new Error('FRONTEND_ORIGIN must be a valid HTTP origin');
|
||||
}
|
||||
|
||||
let parsedOrigin: URL;
|
||||
try {
|
||||
parsedOrigin = new URL(frontendOrigin);
|
||||
} catch {
|
||||
throw new Error('FRONTEND_ORIGIN must be a valid HTTP origin');
|
||||
}
|
||||
|
||||
if (
|
||||
(parsedOrigin.protocol !== 'http:' && parsedOrigin.protocol !== 'https:') ||
|
||||
parsedOrigin.pathname !== '/' ||
|
||||
parsedOrigin.search ||
|
||||
parsedOrigin.hash
|
||||
) {
|
||||
throw new Error('FRONTEND_ORIGIN must be a valid HTTP origin');
|
||||
}
|
||||
|
||||
const parsedPort = Number(port);
|
||||
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
|
||||
throw new Error('PORT must be a valid port number');
|
||||
}
|
||||
|
||||
return {
|
||||
DATABASE_URL: databaseUrl,
|
||||
FRONTEND_ORIGIN: parsedOrigin.origin,
|
||||
PORT: parsedPort,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function shouldExposeSwagger(nodeEnv: string | undefined): boolean {
|
||||
return nodeEnv === 'development';
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { shouldExposeSwagger } from './config/swagger.config';
|
||||
|
||||
describe('shouldExposeSwagger', () => {
|
||||
it('does not expose Swagger outside development', () => {
|
||||
expect(shouldExposeSwagger('test')).toBe(false);
|
||||
});
|
||||
});
|
||||
+25
-2
@@ -1,8 +1,31 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { Environment } from './config/env.validation';
|
||||
import { shouldExposeSwagger } from './config/swagger.config';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
const config = app.get<ConfigService<Environment, true>>(ConfigService);
|
||||
const frontendOrigin = config.getOrThrow<string>('FRONTEND_ORIGIN');
|
||||
|
||||
app.enableCors({ origin: frontendOrigin });
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
|
||||
if (shouldExposeSwagger(process.env['NODE_ENV'])) {
|
||||
const document = SwaggerModule.createDocument(
|
||||
app,
|
||||
new DocumentBuilder()
|
||||
.setTitle('WealthLedger API')
|
||||
.setVersion('v1')
|
||||
.build(),
|
||||
);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
}
|
||||
|
||||
await app.listen(config.getOrThrow('PORT'));
|
||||
}
|
||||
bootstrap();
|
||||
void bootstrap();
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3';
|
||||
import { PrismaClient } from '../../generated/prisma/client';
|
||||
import { Environment } from '../config/env.validation';
|
||||
|
||||
function getDatabaseUrl(config: ConfigService<Environment, true>): string {
|
||||
return config.getOrThrow<string>('DATABASE_URL');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
constructor(config: ConfigService<Environment, true>) {
|
||||
super({
|
||||
adapter: new PrismaBetterSqlite3({
|
||||
url: getDatabaseUrl(config),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
onModuleInit(): Promise<void> {
|
||||
return this.$connect();
|
||||
}
|
||||
|
||||
onModuleDestroy(): Promise<void> {
|
||||
return this.$disconnect();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user