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",
|
"description": "Dorfteich REST API server",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "nest build",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": {
|
"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"
|
"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": {
|
"compilerOptions": {
|
||||||
"module": "CommonJS",
|
"module": "CommonJS",
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "Node",
|
||||||
"outDir": "dist"
|
"outDir": "dist",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"strictPropertyInitialization": false
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"]
|
||||||
"exclude": ["src/**/*.test.ts"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
"private": true,
|
||||||
"description": "Types, schemas, and logic shared between web, api, and collab",
|
"description": "Types, schemas, and logic shared between web, api, and collab",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"main": "./dist/index.cjs",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.js",
|
"module": "./dist/index.mjs",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.js",
|
"import": "./dist/index.mjs",
|
||||||
"require": "./dist/index.cjs"
|
"require": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"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';
|
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/*
|
- packages/*
|
||||||
# Postinstall scripts are opt-in with pnpm; esbuild needs its binary install.
|
# Postinstall scripts are opt-in with pnpm; esbuild needs its binary install.
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
|
'@swc/core': true
|
||||||
esbuild: true
|
esbuild: true
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user