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
+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();
}
}