Add NestJS API skeleton with config, logging, and /healthz
apps/api boots a NestJS application with: Zod-validated environment configuration (schema in @dorfteich/shared, fails fast listing every invalid variable), structured pino request logging via nestjs-pino (pretty in development, JSON otherwise, auth headers redacted), a global exception filter producing the uniform ApiErrorBody shape, and GET /api/v1/healthz. Vitest runs Nest through SWC for decorator metadata; supertest covers healthz and the 404 error shape. Closes #2 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b16d23297e
commit
c12acbdb2c
14
apps/api/.swcrc
Normal file
14
apps/api/.swcrc
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://swc.rs/schema.json",
|
||||
"jsc": {
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"decorators": true
|
||||
},
|
||||
"transform": {
|
||||
"legacyDecorator": true,
|
||||
"decoratorMetadata": true
|
||||
},
|
||||
"target": "es2022"
|
||||
}
|
||||
}
|
||||
9
apps/api/nest-cli.json
Normal file
9
apps/api/nest-cli.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"tsConfigPath": "tsconfig.build.json",
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@ -5,14 +5,32 @@
|
||||
"description": "Dorfteich REST API server",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"build": "nest build",
|
||||
"start": "node dist/main.js",
|
||||
"start:dev": "nest start --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dorfteich/shared": "workspace:*"
|
||||
"@dorfteich/shared": "workspace:*",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"nestjs-pino": "^4.3.0",
|
||||
"pino": "^9.6.0",
|
||||
"pino-http": "^10.4.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.0",
|
||||
"@swc/core": "^1.10.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"supertest": "^7.0.0",
|
||||
"unplugin-swc": "^1.5.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
30
apps/api/src/app.module.ts
Normal file
30
apps/api/src/app.module.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_FILTER } from '@nestjs/core';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
|
||||
import { ApiExceptionFilter } from './common/api-exception.filter';
|
||||
import { AppConfig } from './config/app-config.service';
|
||||
import { ConfigModule } from './config/config.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
LoggerModule.forRootAsync({
|
||||
inject: [AppConfig],
|
||||
useFactory: (config: AppConfig) => ({
|
||||
pinoHttp: {
|
||||
level: config.env.LOG_LEVEL,
|
||||
// Human-readable logs in local development, JSON everywhere else.
|
||||
transport: config.env.NODE_ENV === 'development' ? { target: 'pino-pretty' } : undefined,
|
||||
autoLogging: config.env.NODE_ENV !== 'test',
|
||||
// Request bodies are never logged (operations.md logging rules).
|
||||
redact: { paths: ['req.headers.authorization', 'req.headers.cookie'], remove: true },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
HealthModule,
|
||||
],
|
||||
providers: [{ provide: APP_FILTER, useClass: ApiExceptionFilter }],
|
||||
})
|
||||
export class AppModule {}
|
||||
45
apps/api/src/common/api-exception.filter.ts
Normal file
45
apps/api/src/common/api-exception.filter.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { apiError } from '@dorfteich/shared';
|
||||
import type { Response } from 'express';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
/**
|
||||
* Maps every thrown error to the uniform ApiErrorBody shape. HttpExceptions
|
||||
* keep their status and get a stable `code`; everything else becomes an
|
||||
* opaque 500 so internals never leak to clients.
|
||||
*/
|
||||
@Catch()
|
||||
export class ApiExceptionFilter implements ExceptionFilter {
|
||||
constructor(private readonly logger: PinoLogger) {
|
||||
this.logger.setContext(ApiExceptionFilter.name);
|
||||
}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const response = host.switchToHttp().getResponse<Response>();
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
const status = exception.getStatus();
|
||||
response.status(status).json(apiError(codeForStatus(status), exception.message));
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.error({ err: exception }, 'unhandled exception');
|
||||
response
|
||||
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.json(apiError('internal_error', 'Internal server error'));
|
||||
}
|
||||
}
|
||||
|
||||
function codeForStatus(status: number): string {
|
||||
const codes: Record<number, string> = {
|
||||
400: 'bad_request',
|
||||
401: 'unauthorized',
|
||||
403: 'forbidden',
|
||||
404: 'not_found',
|
||||
409: 'conflict',
|
||||
410: 'gone',
|
||||
413: 'payload_too_large',
|
||||
429: 'rate_limited',
|
||||
};
|
||||
return codes[status] ?? `http_${status}`;
|
||||
}
|
||||
11
apps/api/src/config/app-config.service.ts
Normal file
11
apps/api/src/config/app-config.service.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ApiEnv, apiEnvSchema, parseEnv } from '@dorfteich/shared';
|
||||
|
||||
@Injectable()
|
||||
export class AppConfig {
|
||||
readonly env: ApiEnv;
|
||||
|
||||
constructor() {
|
||||
this.env = parseEnv(apiEnvSchema, process.env);
|
||||
}
|
||||
}
|
||||
15
apps/api/src/config/config.module.ts
Normal file
15
apps/api/src/config/config.module.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { AppConfig } from './app-config.service';
|
||||
|
||||
/**
|
||||
* Global so every module can inject AppConfig without importing this module.
|
||||
* The environment is parsed exactly once, at first injection — startup fails
|
||||
* fast on configuration errors.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AppConfig],
|
||||
exports: [AppConfig],
|
||||
})
|
||||
export class ConfigModule {}
|
||||
15
apps/api/src/health/health.controller.ts
Normal file
15
apps/api/src/health/health.controller.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { HealthResponse, healthResponse } from '@dorfteich/shared';
|
||||
|
||||
import { AppConfig } from '../config/app-config.service';
|
||||
|
||||
@Controller('healthz')
|
||||
export class HealthController {
|
||||
constructor(private readonly config: AppConfig) {}
|
||||
|
||||
/** Liveness only — readiness (`/readyz`) arrives with the database (issue #3). */
|
||||
@Get()
|
||||
healthz(): HealthResponse {
|
||||
return healthResponse('api', this.config.env.APP_VERSION);
|
||||
}
|
||||
}
|
||||
35
apps/api/src/health/health.e2e.test.ts
Normal file
35
apps/api/src/health/health.e2e.test.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { AppModule } from '../app.module';
|
||||
|
||||
describe('GET /api/v1/healthz (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.setGlobalPrefix('api/v1');
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('responds with the liveness payload', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/api/v1/healthz').expect(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
expect(res.body.service).toBe('api');
|
||||
expect(typeof res.body.version).toBe('string');
|
||||
});
|
||||
|
||||
it('returns the uniform error shape for unknown routes', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/api/v1/does-not-exist').expect(404);
|
||||
expect(res.body.code).toBe('not_found');
|
||||
expect(typeof res.body.message).toBe('string');
|
||||
});
|
||||
});
|
||||
8
apps/api/src/health/health.module.ts
Normal file
8
apps/api/src/health/health.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@ -1,9 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { apiHealth } from './index';
|
||||
|
||||
describe('workspace linking', () => {
|
||||
it('api consumes @dorfteich/shared', () => {
|
||||
expect(apiHealth().service).toBe('api');
|
||||
});
|
||||
});
|
||||
@ -1,7 +0,0 @@
|
||||
// Placeholder entry point; replaced by the NestJS bootstrap in issue #2.
|
||||
// It already imports from @dorfteich/shared to prove workspace linking.
|
||||
import { healthResponse } from '@dorfteich/shared';
|
||||
|
||||
export function apiHealth(): ReturnType<typeof healthResponse> {
|
||||
return healthResponse('api', '0.0.0');
|
||||
}
|
||||
19
apps/api/src/main.ts
Normal file
19
apps/api/src/main.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
|
||||
import { AppModule } from './app.module';
|
||||
import { AppConfig } from './config/app-config.service';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
// bufferLogs holds early log lines until the pino logger is attached,
|
||||
// so even bootstrap errors come out as structured JSON.
|
||||
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||
app.useLogger(app.get(Logger));
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.enableShutdownHooks();
|
||||
|
||||
const config = app.get(AppConfig);
|
||||
await app.listen(config.env.PORT);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
4
apps/api/tsconfig.build.json
Normal file
4
apps/api/tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["src/**/*.test.ts", "dist"]
|
||||
}
|
||||
@ -3,8 +3,10 @@
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"outDir": "dist"
|
||||
"outDir": "dist",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strictPropertyInitialization": false
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
11
apps/api/vitest.config.ts
Normal file
11
apps/api/vitest.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import swc from 'unplugin-swc';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// NestJS relies on decorator metadata, which esbuild (Vitest's default
|
||||
// transformer) cannot emit — SWC (configured via .swcrc) can.
|
||||
export default defineConfig({
|
||||
plugins: [swc.vite({ module: { type: 'es6' } })],
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@ -4,14 +4,14 @@
|
||||
"private": true,
|
||||
"description": "Types, schemas, and logic shared between web, api, and collab",
|
||||
"license": "MIT",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
||||
19
packages/shared/src/api-error.ts
Normal file
19
packages/shared/src/api-error.ts
Normal file
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Uniform error body returned by every non-2xx api response. `code` is a
|
||||
* stable, machine-readable identifier that doubles as the i18n key suffix
|
||||
* (`errors.<code>`); `message` is an English fallback for clients without
|
||||
* translations. Optional `details` carries field-level validation issues.
|
||||
*/
|
||||
export interface ApiErrorBody {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export function apiError(
|
||||
code: string,
|
||||
message: string,
|
||||
details?: ApiErrorBody['details'],
|
||||
): ApiErrorBody {
|
||||
return details ? { code, message, details } : { code, message };
|
||||
}
|
||||
17
packages/shared/src/env.test.ts
Normal file
17
packages/shared/src/env.test.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { apiEnvSchema, parseEnv } from './env';
|
||||
|
||||
describe('parseEnv', () => {
|
||||
it('applies defaults for a minimal environment', () => {
|
||||
const env = parseEnv(apiEnvSchema, {});
|
||||
expect(env.PORT).toBe(3000);
|
||||
expect(env.NODE_ENV).toBe('development');
|
||||
});
|
||||
|
||||
it('fails with a message naming every invalid variable', () => {
|
||||
expect(() => parseEnv(apiEnvSchema, { PORT: 'not-a-port', LOG_LEVEL: 'loud' })).toThrowError(
|
||||
/PORT.*\n.*LOG_LEVEL/s,
|
||||
);
|
||||
});
|
||||
});
|
||||
31
packages/shared/src/env.ts
Normal file
31
packages/shared/src/env.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Environment schemas live here so api, collab, and tooling validate their
|
||||
* configuration the same way. Every service calls `parseEnv` once at startup
|
||||
* and crashes with a readable list of problems instead of failing later at
|
||||
* first use.
|
||||
*/
|
||||
export const apiEnvSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||
/** Version shown in health responses; injected at image build time. */
|
||||
APP_VERSION: z.string().default('0.0.0-dev'),
|
||||
});
|
||||
|
||||
export type ApiEnv = z.infer<typeof apiEnvSchema>;
|
||||
|
||||
export function parseEnv<Schema extends z.ZodTypeAny>(
|
||||
schema: Schema,
|
||||
env: Record<string, string | undefined>,
|
||||
): z.infer<Schema> {
|
||||
const result = schema.safeParse(env);
|
||||
if (!result.success) {
|
||||
const problems = result.error.issues
|
||||
.map((issue) => ` - ${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
||||
.join('\n');
|
||||
throw new Error(`Invalid environment configuration:\n${problems}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@ -1 +1,3 @@
|
||||
export * from './api-error';
|
||||
export * from './env';
|
||||
export * from './health';
|
||||
|
||||
3117
pnpm-lock.yaml
generated
3117
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -3,4 +3,5 @@ packages:
|
||||
- packages/*
|
||||
# Postinstall scripts are opt-in with pnpm; esbuild needs its binary install.
|
||||
allowBuilds:
|
||||
'@swc/core': true
|
||||
esbuild: true
|
||||
|
||||
Loading…
Reference in New Issue
Block a user