initial commit
This commit is contained in:
58
apps/api/src/app.module.ts
Normal file
58
apps/api/src/app.module.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
|
||||
import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
level: process.env['LOG_LEVEL'] ?? 'info',
|
||||
transport:
|
||||
process.env['NODE_ENV'] === 'development'
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
singleLine: true,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
serializers: {
|
||||
req: (req: { id?: string; method?: string; url?: string }) => ({
|
||||
id: req.id,
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
}),
|
||||
},
|
||||
customProps: (req: { id?: string }) => ({
|
||||
requestId: req.id,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60_000,
|
||||
limit: 100,
|
||||
},
|
||||
]),
|
||||
PrismaModule,
|
||||
HealthModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
consumer.apply(RequestIdMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
102
apps/api/src/common/filters/rfc7807-exception.filter.ts
Normal file
102
apps/api/src/common/filters/rfc7807-exception.filter.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { createProblemDetails, type ProblemDetails } from '@hexahost/contracts';
|
||||
|
||||
@Catch()
|
||||
export class Rfc7807ExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<FastifyReply>();
|
||||
const request = ctx.getRequest<FastifyRequest>();
|
||||
|
||||
const status = this.resolveStatus(exception);
|
||||
const problem = this.toProblemDetails(exception, status, request.url);
|
||||
|
||||
void response
|
||||
.status(status)
|
||||
.header('Content-Type', 'application/problem+json')
|
||||
.send(problem);
|
||||
}
|
||||
|
||||
private resolveStatus(exception: unknown): number {
|
||||
if (exception instanceof HttpException) {
|
||||
return exception.getStatus();
|
||||
}
|
||||
|
||||
return HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
|
||||
private toProblemDetails(
|
||||
exception: unknown,
|
||||
status: number,
|
||||
instance: string,
|
||||
): ProblemDetails {
|
||||
if (exception instanceof HttpException) {
|
||||
const response = exception.getResponse();
|
||||
|
||||
if (typeof response === 'object' && response !== null) {
|
||||
const body = response as Record<string, unknown>;
|
||||
|
||||
if (typeof body['status'] === 'string' && Array.isArray(body['checks'])) {
|
||||
return createProblemDetails({
|
||||
title: 'Service Unavailable',
|
||||
status,
|
||||
detail: 'One or more readiness checks failed',
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof body['message'] === 'string') {
|
||||
return createProblemDetails({
|
||||
title: exception.name,
|
||||
status,
|
||||
detail: body['message'],
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(body['message'])) {
|
||||
return createProblemDetails({
|
||||
title: 'Validation Error',
|
||||
status,
|
||||
detail: 'Request validation failed',
|
||||
instance,
|
||||
errors: body['message'].map((message) => ({
|
||||
message: String(message),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return createProblemDetails({
|
||||
title: exception.name,
|
||||
status,
|
||||
detail: exception.message,
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
if (exception instanceof Error) {
|
||||
return createProblemDetails({
|
||||
title: 'Internal Server Error',
|
||||
status,
|
||||
detail: exception.message,
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
return createProblemDetails({
|
||||
title: 'Internal Server Error',
|
||||
status,
|
||||
detail: 'An unexpected error occurred',
|
||||
instance,
|
||||
});
|
||||
}
|
||||
}
|
||||
20
apps/api/src/common/middleware/request-id.middleware.ts
Normal file
20
apps/api/src/common/middleware/request-id.middleware.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
export class RequestIdMiddleware implements NestMiddleware {
|
||||
use(req: FastifyRequest['raw'], res: FastifyReply['raw'], next: () => void): void {
|
||||
const incoming =
|
||||
(req.headers['x-request-id'] as string | undefined) ??
|
||||
(req.headers['x-correlation-id'] as string | undefined);
|
||||
|
||||
const requestId = incoming && incoming.trim().length > 0 ? incoming : randomUUID();
|
||||
|
||||
req.headers['x-request-id'] = requestId;
|
||||
res.setHeader('x-request-id', requestId);
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
35
apps/api/src/health/health.controller.ts
Normal file
35
apps/api/src/health/health.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Controller, Get, HttpCode, HttpStatus, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import type { HealthResponse } from '@hexahost/contracts';
|
||||
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@ApiTags('health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(private readonly healthService: HealthService) {}
|
||||
|
||||
@Get('live')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Liveness probe' })
|
||||
@ApiResponse({ status: 200, description: 'Service is alive' })
|
||||
getLive(): HealthResponse {
|
||||
return this.healthService.getLive();
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Readiness probe' })
|
||||
@ApiResponse({ status: 200, description: 'Service is ready' })
|
||||
@ApiResponse({ status: 503, description: 'Service is not ready' })
|
||||
async getReady(): Promise<HealthResponse> {
|
||||
const response = await this.healthService.getReady();
|
||||
|
||||
if (response.status === 'error') {
|
||||
throw new ServiceUnavailableException(response);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
10
apps/api/src/health/health.module.ts
Normal file
10
apps/api/src/health/health.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
providers: [HealthService],
|
||||
})
|
||||
export class HealthModule {}
|
||||
51
apps/api/src/health/health.service.ts
Normal file
51
apps/api/src/health/health.service.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { getConfig } from '@hexahost/config';
|
||||
import type { HealthCheck, HealthResponse } from '@hexahost/contracts';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class HealthService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
getLive(): HealthResponse {
|
||||
const config = getConfig();
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
service: config.APP_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getReady(): Promise<HealthResponse> {
|
||||
const config = getConfig();
|
||||
const checks: HealthCheck[] = [];
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
checks.push({
|
||||
name: 'database',
|
||||
status: 'ok',
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
name: 'database',
|
||||
status: 'error',
|
||||
message: error instanceof Error ? error.message : 'Database check failed',
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
|
||||
const hasError = checks.some((check) => check.status === 'error');
|
||||
|
||||
return {
|
||||
status: hasError ? 'error' : 'ok',
|
||||
service: config.APP_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
checks,
|
||||
};
|
||||
}
|
||||
}
|
||||
26
apps/api/src/main.ts
Normal file
26
apps/api/src/main.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import http from "node:http";
|
||||
|
||||
const port = Number(process.env.API_PORT ?? 3001);
|
||||
const appName = process.env.APP_NAME ?? "HexaHost GameCloud";
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === "/healthz" || req.url === "/health") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
status: "ok",
|
||||
service: "api",
|
||||
app: appName,
|
||||
phase: "0",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "not_found" }));
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(JSON.stringify({ level: "info", msg: "api listening", port }));
|
||||
});
|
||||
10
apps/api/src/prisma/prisma.module.ts
Normal file
10
apps/api/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
13
apps/api/src/prisma/prisma.service.ts
Normal file
13
apps/api/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@hexahost/database';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user