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
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@drift/api",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"start": "node dist/main.js",
"test": "vitest run --config vitest.config.ts --no-file-parallelism",
"test:e2e": "vitest run --config vitest.config.ts src/health/health.e2e-spec.ts --no-file-parallelism",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@drift/contracts": "workspace:*",
"@nestjs/common": "^11.1.6",
"@nestjs/core": "^11.1.6",
"@nestjs/platform-express": "^11.1.6",
"@prisma/client": "6.19.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"helmet": "^8.1.0",
"redis": "^5.8.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@nestjs/testing": "^11.1.6",
"@types/express": "^5.0.3",
"@types/supertest": "^6.0.3",
"supertest": "^7.1.4",
"typescript": "^5.6.3",
"vitest": "^4.1.11"
}
}
+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"; import { PrismaClient } from "@prisma/client";
/** Lightweight database lifecycle wrapper; Nest hooks can call these methods later. */ @Injectable()
export class PrismaService extends PrismaClient { export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
await this.$connect(); 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();
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*spec.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "vitest.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
esbuild: {
tsconfigRaw: {
compilerOptions: {
experimentalDecorators: true,
},
},
},
test: {
environment: "node",
include: ["src/**/*spec.ts"],
},
});
+2 -1
View File
@@ -20,7 +20,8 @@
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"deepmerge-ts@<8.0.0": "8.0.0", "deepmerge-ts@<8.0.0": "8.0.0",
"effect@<3.20.0": "3.20.0" "effect@<3.20.0": "3.20.0",
"multer@<2.3.0": "2.3.0"
} }
}, },
"devDependencies": { "devDependencies": {
+4
View File
@@ -22,6 +22,10 @@ describe("contracts", () => {
it("defines the complete error-code contract", () => { it("defines the complete error-code contract", () => {
expect(Object.values(ErrorCode)).toEqual([ expect(Object.values(ErrorCode)).toEqual([
"OK", "OK",
"VALIDATION_ERROR",
"NOT_FOUND",
"SERVICE_UNAVAILABLE",
"INTERNAL_ERROR",
"AUTH_TOKEN_EXPIRED", "AUTH_TOKEN_EXPIRED",
"AUTH_REFRESH_REUSED", "AUTH_REFRESH_REUSED",
"BOTTLE_DAILY_LIMIT", "BOTTLE_DAILY_LIMIT",
+11
View File
@@ -1,5 +1,9 @@
export enum ErrorCode { export enum ErrorCode {
OK = "OK", OK = "OK",
VALIDATION_ERROR = "VALIDATION_ERROR",
NOT_FOUND = "NOT_FOUND",
SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE",
INTERNAL_ERROR = "INTERNAL_ERROR",
AUTH_TOKEN_EXPIRED = "AUTH_TOKEN_EXPIRED", AUTH_TOKEN_EXPIRED = "AUTH_TOKEN_EXPIRED",
AUTH_REFRESH_REUSED = "AUTH_REFRESH_REUSED", AUTH_REFRESH_REUSED = "AUTH_REFRESH_REUSED",
BOTTLE_DAILY_LIMIT = "BOTTLE_DAILY_LIMIT", BOTTLE_DAILY_LIMIT = "BOTTLE_DAILY_LIMIT",
@@ -18,6 +22,13 @@ export interface SuccessResponse<T> {
requestId: string; requestId: string;
} }
export interface ErrorResponse {
code: Exclude<ErrorCode, ErrorCode.OK>;
message: string;
data: null;
requestId: string;
}
export const ok = <T>(data: T, requestId: string): SuccessResponse<T> => ({ export const ok = <T>(data: T, requestId: string): SuccessResponse<T> => ({
code: ErrorCode.OK, code: ErrorCode.OK,
message: "success", message: "success",
+1674
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,2 +1,3 @@
packages: packages:
- packages/* - "packages/*"
- "apps/*"
+1 -1
View File
@@ -29,5 +29,5 @@ describe("seed", () => {
prisma.bottle.count({ where: { poolStatus: "IN_POOL" } }), prisma.bottle.count({ where: { poolStatus: "IN_POOL" } }),
]), ]),
).resolves.toEqual([2, 2, 1]); ).resolves.toEqual([2, 2, 1]);
}); }, 30_000);
}); });
+2
View File
@@ -3,6 +3,8 @@
"target": "ES2022", "target": "ES2022",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strict": true, "strict": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true, "exactOptionalPropertyTypes": true,