feat: establish backend foundation

This commit is contained in:
2026-08-12 23:04:21 +03:00
parent f8dcfb71c1
commit ab21426478
24 changed files with 2313 additions and 382 deletions
+37
View File
@@ -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!');
+6
View File
@@ -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
View File
@@ -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,
});
}
}
+39
View File
@@ -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');
},
);
});
+46
View File
@@ -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,
};
}
+3
View File
@@ -0,0 +1,3 @@
export function shouldExposeSwagger(nodeEnv: string | undefined): boolean {
return nodeEnv === 'development';
}
+7
View File
@@ -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
View File
@@ -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();
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+31
View File
@@ -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();
}
}