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:
Claude Fable 5 2026-07-04 19:10:07 +02:00
parent b16d23297e
commit c12acbdb2c
23 changed files with 3417 additions and 40 deletions

14
apps/api/.swcrc Normal file
View 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
View File

@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"tsConfigPath": "tsconfig.build.json",
"deleteOutDir": true
}
}

View File

@ -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"
}
}

View 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 {}

View 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}`;
}

View 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);
}
}

View 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 {}

View 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);
}
}

View 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');
});
});

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View File

@ -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');
});
});

View File

@ -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
View 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();

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["src/**/*.test.ts", "dist"]
}

View File

@ -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
View 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',
},
});

View File

@ -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": [

View 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 };
}

View 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,
);
});
});

View 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;
}

View File

@ -1 +1,3 @@
export * from './api-error';
export * from './env';
export * from './health';

3117
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -3,4 +3,5 @@ packages:
- packages/*
# Postinstall scripts are opt-in with pnpm; esbuild needs its binary install.
allowBuilds:
'@swc/core': true
esbuild: true