feat: 添加 NestJS API 基础设施
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+1674
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,2 +1,3 @@
|
|||||||
packages:
|
packages:
|
||||||
- packages/*
|
- "packages/*"
|
||||||
|
- "apps/*"
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user