feat: 添加 NestJS API 基础设施

This commit is contained in:
root
2026-09-14 12:32:46 +08:00
parent c37101e783
commit f3fb16dbc0
22 changed files with 2213 additions and 5 deletions
+10
View File
@@ -0,0 +1,10 @@
import { MiddlewareConsumer, Module, type NestModule } from "@nestjs/common";
import { RequestIdMiddleware } from "./common/request-id.middleware.js";
import { HealthModule } from "./health/health.module.js";
@Module({ imports: [HealthModule] })
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(RequestIdMiddleware).forRoutes("{*path}");
}
}
@@ -0,0 +1,63 @@
import { ErrorCode, type ErrorResponse } from "@drift/contracts";
import {
ArgumentsHost,
Catch,
HttpException,
HttpStatus,
type ExceptionFilter,
} from "@nestjs/common";
import type { Response } from "express";
import { DomainException } from "./domain.exception.js";
import type { RequestWithId } from "./request-id.middleware.js";
@Catch()
export class DomainExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const http = host.switchToHttp();
const request = http.getRequest<RequestWithId>();
const response = http.getResponse<Response>();
const normalized = this.normalize(exception);
const body: ErrorResponse = {
code: normalized.code,
message: normalized.message,
data: null,
requestId: request.requestId,
};
response.status(normalized.status).json(body);
}
private normalize(exception: unknown): {
status: number;
code: Exclude<ErrorCode, ErrorCode.OK>;
message: string;
} {
if (exception instanceof DomainException) {
return {
status: exception.status,
code: exception.code,
message: exception.message,
};
}
if (exception instanceof HttpException) {
const status = exception.getStatus();
if (status === 404)
return { status, code: ErrorCode.NOT_FOUND, message: "Not Found" };
if (status === 400)
return {
status,
code: ErrorCode.VALIDATION_ERROR,
message: "Validation failed",
};
return {
status,
code: ErrorCode.INTERNAL_ERROR,
message: "Request failed",
};
}
return {
status: HttpStatus.INTERNAL_SERVER_ERROR,
code: ErrorCode.INTERNAL_ERROR,
message: "Internal server error",
};
}
}
+12
View File
@@ -0,0 +1,12 @@
import type { ErrorCode } from "@drift/contracts";
export class DomainException extends Error {
constructor(
readonly code: Exclude<ErrorCode, ErrorCode.OK>,
message: string,
readonly status: number,
) {
super(message);
this.name = "DomainException";
}
}
@@ -0,0 +1,34 @@
import { ok, type SuccessResponse } from "@drift/contracts";
import {
Injectable,
type CallHandler,
type ExecutionContext,
type NestInterceptor,
} from "@nestjs/common";
import type { Observable } from "rxjs";
import { map } from "rxjs/operators";
import type { RequestWithId } from "./request-id.middleware.js";
const isEnvelope = (value: unknown): value is SuccessResponse<unknown> =>
typeof value === "object" &&
value !== null &&
"code" in value &&
"requestId" in value;
@Injectable()
export class HttpResponseInterceptor<T> implements NestInterceptor<
T,
T | SuccessResponse<T>
> {
intercept(
context: ExecutionContext,
next: CallHandler<T>,
): Observable<T | SuccessResponse<T>> {
const request = context.switchToHttp().getRequest<RequestWithId>();
return next
.handle()
.pipe(
map((data) => (isEnvelope(data) ? data : ok(data, request.requestId))),
);
}
}
@@ -0,0 +1,22 @@
import { Injectable, type NestMiddleware } from "@nestjs/common";
import { randomUUID } from "node:crypto";
import type { NextFunction, Request, Response } from "express";
export const REQUEST_ID_HEADER = "x-request-id";
const SAFE_REQUEST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
export interface RequestWithId extends Request {
requestId: string;
}
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(request: Request, response: Response, next: NextFunction): void {
const supplied = request.header(REQUEST_ID_HEADER);
const requestId =
supplied && SAFE_REQUEST_ID.test(supplied) ? supplied : randomUUID();
(request as RequestWithId).requestId = requestId;
response.setHeader(REQUEST_ID_HEADER, requestId);
next();
}
}
+10 -2
View File
@@ -1,7 +1,15 @@
import {
Injectable,
type OnModuleDestroy,
type OnModuleInit,
} from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
/** Lightweight database lifecycle wrapper; Nest hooks can call these methods later. */
export class PrismaService extends PrismaClient {
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit(): Promise<void> {
await this.$connect();
}
+19
View File
@@ -0,0 +1,19 @@
import { Controller, Get, Inject } from "@nestjs/common";
import { HealthService } from "./health.service.js";
@Controller("health")
export class HealthController {
constructor(
@Inject(HealthService) private readonly healthService: HealthService,
) {}
@Get()
check(): Promise<{ status: "ok"; postgres: "up"; redis: "up" }> {
return this.healthService.check();
}
@Get("live")
live(): { status: "ok" } {
return { status: "ok" };
}
}
+187
View File
@@ -0,0 +1,187 @@
import "reflect-metadata";
/* eslint-disable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access */
import { Body, Controller, Get, HttpStatus, Post } from "@nestjs/common";
import { Test } from "@nestjs/testing";
import { IsString } from "class-validator";
import request from "supertest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { INestApplication } from "@nestjs/common";
import { ErrorCode } from "@drift/contracts";
import { AppModule } from "../app.module";
import { DomainException } from "../common/domain.exception";
import { configureApp } from "../main";
class ProbeDto {
@IsString()
name!: string;
}
@Controller("probe")
class ProbeController {
@Post()
echo(@Body() dto: ProbeDto): ProbeDto {
return dto;
}
@Get("domain-error")
domainError(): never {
throw new DomainException(
ErrorCode.CONTENT_REJECTED,
"content rejected",
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
@Get("internal-error")
internalError(): never {
throw new Error(`sensitive ${process.env.DATABASE_URL}`);
}
}
Reflect.defineMetadata(
"design:paramtypes",
[ProbeDto],
ProbeController.prototype,
"echo",
);
describe("API infrastructure", () => {
let app: INestApplication;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
controllers: [ProbeController],
}).compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
});
afterAll(async () => {
await app?.close();
});
it("reports real dependency readiness in the uniform success envelope", async () => {
const response = await request(app.getHttpServer())
.get("/api/v1/health")
.expect(200);
expect(response.body).toMatchObject({
code: "OK",
message: "success",
data: { status: "ok", postgres: "up", redis: "up" },
});
expect(response.body.requestId).toEqual(expect.any(String));
expect(response.headers["x-request-id"]).toBe(response.body.requestId);
});
it("passes through a safe caller request id", async () => {
const response = await request(app.getHttpServer())
.get("/api/v1/health/live")
.set("x-request-id", "client_req-123")
.expect(200);
expect(response.body.requestId).toBe("client_req-123");
expect(response.headers["x-request-id"]).toBe("client_req-123");
});
it.each(["x".repeat(129), "bad value"])(
"replaces unsafe request id %s",
async (unsafeId) => {
const response = await request(app.getHttpServer())
.get("/api/v1/health/live")
.set("x-request-id", unsafeId)
.expect(200);
expect(response.body.requestId).toMatch(/^[0-9a-f-]{36}$/);
expect(response.body.requestId).not.toBe(unsafeId);
},
);
it("returns a uniform 404 envelope", async () => {
const response = await request(app.getHttpServer())
.get("/api/v1/missing")
.expect(404);
expect(response.body).toMatchObject({
code: "NOT_FOUND",
message: "Not Found",
data: null,
});
expect(response.body.requestId).toBe(response.headers["x-request-id"]);
});
it("returns a domain exception envelope and status", async () => {
const response = await request(app.getHttpServer())
.get("/api/v1/probe/domain-error")
.expect(422);
expect(response.body).toMatchObject({
code: "CONTENT_REJECTED",
message: "content rejected",
data: null,
});
});
it("does not leak internal errors", async () => {
const response = await request(app.getHttpServer())
.get("/api/v1/probe/internal-error")
.expect(500);
expect(response.body).toMatchObject({
code: "INTERNAL_ERROR",
message: "Internal server error",
data: null,
});
expect(JSON.stringify(response.body)).not.toContain("postgresql://");
expect(response.body).not.toHaveProperty("stack");
});
it("rejects non-whitelisted DTO properties in the uniform envelope", async () => {
const response = await request(app.getHttpServer())
.post("/api/v1/probe")
.send({ name: "ok", extra: true })
.expect(400);
expect(response.body).toMatchObject({
code: "VALIDATION_ERROR",
data: null,
});
});
it("applies helmet and same-origin credentialed CORS", async () => {
const allowedOrigin = process.env.WEB_ORIGIN ?? "http://localhost:3000";
const allowed = await request(app.getHttpServer())
.get("/api/v1/health/live")
.set("Origin", allowedOrigin)
.expect(200);
expect(allowed.headers["access-control-allow-origin"]).toBe(allowedOrigin);
expect(allowed.headers["access-control-allow-credentials"]).toBe("true");
expect(allowed.headers["x-content-type-options"]).toBe("nosniff");
const denied = await request(app.getHttpServer())
.get("/api/v1/health/live")
.set("Origin", "https://evil.example")
.expect(200);
expect(denied.headers).not.toHaveProperty("access-control-allow-origin");
});
it("returns a sanitized 503 when a real dependency is unavailable", async () => {
const originalRedisUrl = process.env.REDIS_URL;
process.env.REDIS_URL = "redis://127.0.0.1:1";
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
const unavailableApp = module.createNestApplication();
configureApp(unavailableApp);
await unavailableApp.init();
try {
const response = await request(unavailableApp.getHttpServer())
.get("/api/v1/health")
.expect(503);
expect(response.body).toMatchObject({
code: "SERVICE_UNAVAILABLE",
message: "Dependencies unavailable",
data: null,
});
expect(JSON.stringify(response.body)).not.toContain("redis://");
} finally {
await unavailableApp.close();
process.env.REDIS_URL = originalRedisUrl;
}
});
});
+10
View File
@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { PrismaService } from "../database/prisma.service.js";
import { HealthController } from "./health.controller.js";
import { HealthService } from "./health.service.js";
@Module({
controllers: [HealthController],
providers: [HealthService, PrismaService],
})
export class HealthModule {}
+37
View File
@@ -0,0 +1,37 @@
import { Inject, Injectable, type OnModuleDestroy } from "@nestjs/common";
import { createClient, type RedisClientType } from "redis";
import { PrismaService } from "../database/prisma.service.js";
import { DomainException } from "../common/domain.exception.js";
import { ErrorCode } from "@drift/contracts";
import { HttpStatus } from "@nestjs/common";
@Injectable()
export class HealthService implements OnModuleDestroy {
private readonly redis: RedisClientType;
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {
this.redis = createClient({
url: process.env.REDIS_URL ?? "redis://127.0.0.1:6379",
socket: { connectTimeout: 500, reconnectStrategy: false },
});
this.redis.on("error", () => undefined);
}
async check(): Promise<{ status: "ok"; postgres: "up"; redis: "up" }> {
try {
if (!this.redis.isOpen) await this.redis.connect();
await Promise.all([this.prisma.$queryRaw`SELECT 1`, this.redis.ping()]);
return { status: "ok", postgres: "up", redis: "up" };
} catch {
throw new DomainException(
ErrorCode.SERVICE_UNAVAILABLE,
"Dependencies unavailable",
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
async onModuleDestroy(): Promise<void> {
if (this.redis.isOpen) await this.redis.quit();
}
}
+40
View File
@@ -0,0 +1,40 @@
import "reflect-metadata";
import { ValidationPipe, type INestApplication } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import helmet from "helmet";
import { AppModule } from "./app.module.js";
import { HttpResponseInterceptor } from "./common/http-response.interceptor.js";
import { DomainExceptionFilter } from "./common/domain-exception.filter.js";
export function configureApp(app: INestApplication): void {
const allowedOrigin = process.env.WEB_ORIGIN ?? "http://localhost:3000";
app.setGlobalPrefix("api/v1");
app.use(helmet());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.useGlobalInterceptors(new HttpResponseInterceptor());
app.useGlobalFilters(new DomainExceptionFilter());
app.enableCors({
origin(
origin: string | undefined,
callback: (error: Error | null, allow?: boolean) => void,
) {
callback(null, origin === undefined || origin === allowedOrigin);
},
credentials: true,
});
}
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
configureApp(app);
app.enableShutdownHooks();
await app.listen(Number(process.env.PORT ?? 3001), "0.0.0.0");
}
if (process.env.NODE_ENV !== "test") void bootstrap();