Skip to content

Commit 30702b3

Browse files
committed
chore: merge feat/common-api-utils into main
2 parents 23b3196 + f3fe96a commit 30702b3

7 files changed

Lines changed: 465 additions & 0 deletions

File tree

src/common/decorators/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { ParseBody, ParseQuery, ParseParams, allowUnknown } from './parse.decorators.js'
2+
export { ApiZodBody, ApiZodResponse } from './swagger.decorators.js'
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import {
2+
createParamDecorator,
3+
type ExecutionContext,
4+
BadRequestException,
5+
} from '@nestjs/common'
6+
import type { z } from 'zod'
7+
import { ZodError } from 'zod'
8+
import type { Request } from 'express'
9+
import { ApiBody, ApiQuery, ApiParam } from '@nestjs/swagger'
10+
import { zodToOpenAPI } from './swagger.decorators.js'
11+
12+
const SKIP_STRICT = Symbol('SKIP_STRICT')
13+
type SchemaWithMetadata = z.ZodType & { [SKIP_STRICT]?: boolean }
14+
15+
// Cache strict schemas to avoid recreating them on every request
16+
const strictSchemaCache = new WeakMap<z.ZodType, z.ZodType>()
17+
18+
export function allowUnknown<T extends z.ZodType>(schema: T): T {
19+
;(schema as SchemaWithMetadata)[SKIP_STRICT] = true
20+
return schema
21+
}
22+
23+
function getStrictSchema(schema: z.ZodType): z.ZodType {
24+
const schemaWithMeta = schema as SchemaWithMetadata
25+
26+
if (schemaWithMeta[SKIP_STRICT] === true) {
27+
return schema
28+
}
29+
30+
if (!('strict' in schema && typeof schema.strict === 'function')) {
31+
return schema
32+
}
33+
34+
let cached = strictSchemaCache.get(schema)
35+
if (!cached) {
36+
cached = (schema.strict as () => z.ZodType)()
37+
strictSchemaCache.set(schema, cached)
38+
}
39+
return cached
40+
}
41+
42+
/** Validates `request.body` with the given Zod schema and injects Swagger metadata. */
43+
export function ParseBody(schema: z.ZodType) {
44+
const paramDecorator = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
45+
const request = ctx.switchToHttp().getRequest<Request>()
46+
try {
47+
return getStrictSchema(schema).parse(request.body)
48+
} catch (error) {
49+
if (error instanceof ZodError) throw error
50+
throw new BadRequestException('Validation failed')
51+
}
52+
})()
53+
54+
return (target: object, propertyKey: string | symbol, parameterIndex: number) => {
55+
paramDecorator(target, propertyKey, parameterIndex)
56+
57+
const openApiSchema = zodToOpenAPI(schema)
58+
const descriptor = Object.getOwnPropertyDescriptor(target, propertyKey)
59+
if (descriptor && openApiSchema) {
60+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
61+
ApiBody({ schema: openApiSchema as any })(target, propertyKey, descriptor)
62+
}
63+
}
64+
}
65+
66+
/** Validates `request.query` with the given Zod schema and injects Swagger metadata. */
67+
export function ParseQuery(schema: z.ZodType) {
68+
const paramDecorator = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
69+
const request = ctx.switchToHttp().getRequest<Request>()
70+
try {
71+
return schema.parse(request.query)
72+
} catch (error) {
73+
if (error instanceof ZodError) throw error
74+
throw new BadRequestException('Validation failed')
75+
}
76+
})()
77+
78+
return (target: object, propertyKey: string | symbol, parameterIndex: number) => {
79+
paramDecorator(target, propertyKey, parameterIndex)
80+
81+
const openApiSchema = zodToOpenAPI(schema)
82+
const descriptor = Object.getOwnPropertyDescriptor(target, propertyKey)
83+
84+
if (
85+
descriptor &&
86+
openApiSchema &&
87+
'type' in openApiSchema &&
88+
openApiSchema['type'] === 'object' &&
89+
'properties' in openApiSchema
90+
) {
91+
const properties = openApiSchema['properties'] as Record<string, unknown>
92+
const requiredFields = (openApiSchema['required'] as string[] | undefined) ?? []
93+
94+
for (const [key, propSchema] of Object.entries(properties)) {
95+
const isRequired = requiredFields.includes(key)
96+
const desc = (propSchema as { description?: string }).description
97+
98+
ApiQuery({
99+
name: key,
100+
required: isRequired,
101+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
102+
schema: propSchema as any,
103+
...(desc ? { description: desc } : {}),
104+
})(target, propertyKey, descriptor)
105+
}
106+
}
107+
}
108+
}
109+
110+
/** Validates `request.params` with the given Zod schema and injects Swagger metadata. */
111+
export function ParseParams(schema: z.ZodType) {
112+
const paramDecorator = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
113+
const request = ctx.switchToHttp().getRequest<Request>()
114+
try {
115+
return schema.parse(request.params)
116+
} catch (error) {
117+
if (error instanceof ZodError) throw error
118+
throw new BadRequestException('Validation failed')
119+
}
120+
})()
121+
122+
return (target: object, propertyKey: string | symbol, parameterIndex: number) => {
123+
paramDecorator(target, propertyKey, parameterIndex)
124+
125+
const openApiSchema = zodToOpenAPI(schema)
126+
const descriptor = Object.getOwnPropertyDescriptor(target, propertyKey)
127+
128+
if (
129+
descriptor &&
130+
openApiSchema &&
131+
'type' in openApiSchema &&
132+
openApiSchema['type'] === 'object' &&
133+
'properties' in openApiSchema
134+
) {
135+
const properties = openApiSchema['properties'] as Record<string, unknown>
136+
for (const [key, propSchema] of Object.entries(properties)) {
137+
const desc = (propSchema as { description?: string }).description
138+
139+
ApiParam({
140+
name: key,
141+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
142+
schema: propSchema as any,
143+
...(desc ? { description: desc } : {}),
144+
})(target, propertyKey, descriptor)
145+
}
146+
}
147+
}
148+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { applyDecorators } from '@nestjs/common'
2+
import { ApiBody, ApiResponse } from '@nestjs/swagger'
3+
import {
4+
extendZodWithOpenApi,
5+
OpenAPIRegistry,
6+
OpenApiGeneratorV3,
7+
} from '@asteasolutions/zod-to-openapi'
8+
import { z } from 'zod'
9+
10+
extendZodWithOpenApi(z)
11+
12+
/** Converts a Zod schema into an OpenAPI V3 SchemaObject. */
13+
export function zodToOpenAPI(schema: z.ZodType): Record<string, unknown> | undefined {
14+
const registry = new OpenAPIRegistry()
15+
registry.register('Temp', schema)
16+
const generator = new OpenApiGeneratorV3(registry.definitions)
17+
const doc = generator.generateComponents()
18+
return doc.components?.schemas?.['Temp'] as unknown as
19+
Record<string, unknown> | undefined
20+
}
21+
22+
/** Creates an ApiBody decorator from a Zod schema. */
23+
export function ApiZodBody(schema: z.ZodType, description?: string) {
24+
const openApiSchema = zodToOpenAPI(schema)
25+
if (!openApiSchema) return applyDecorators()
26+
27+
return applyDecorators(
28+
ApiBody({
29+
...(description ? { description } : {}),
30+
// SchemaObject typings between zod-to-openapi and nestjs/swagger can have slight mismatches
31+
32+
schema: openApiSchema,
33+
}),
34+
)
35+
}
36+
37+
/** Creates an ApiResponse decorator from a Zod schema. */
38+
export function ApiZodResponse(
39+
status: number | 'default',
40+
schema: z.ZodType,
41+
description?: string,
42+
) {
43+
const openApiSchema = zodToOpenAPI(schema)
44+
if (!openApiSchema) return applyDecorators()
45+
46+
return applyDecorators(
47+
ApiResponse({
48+
status,
49+
...(description ? { description } : {}),
50+
51+
schema: openApiSchema,
52+
}),
53+
)
54+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import {
2+
type ArgumentsHost,
3+
Catch,
4+
type ExceptionFilter,
5+
HttpException,
6+
HttpStatus,
7+
Logger,
8+
} from '@nestjs/common'
9+
import type { Request, Response } from 'express'
10+
11+
// ---------------------------------------------------------------------------
12+
// Response shape types
13+
// ---------------------------------------------------------------------------
14+
15+
interface ValidationIssue {
16+
field: string
17+
message: string
18+
code: string
19+
/** Present when the issue is an invalid_union - shows per-branch errors. */
20+
unionErrors?: ValidationIssue[][]
21+
}
22+
23+
interface ErrorBody {
24+
statusCode: number
25+
error: string
26+
message: string
27+
path: string
28+
timestamp: string
29+
/** Present only for 400 Bad Request validation responses. */
30+
issues?: ValidationIssue[]
31+
}
32+
33+
// ---------------------------------------------------------------------------
34+
// Raw Zod issue shape (subset we actually use)
35+
// ---------------------------------------------------------------------------
36+
37+
interface RawZodIssue {
38+
code: string
39+
path: (string | number)[]
40+
message: string
41+
errors?: RawZodIssue[][]
42+
}
43+
44+
interface ZodErrorLike extends Error {
45+
issues: RawZodIssue[]
46+
}
47+
48+
/**
49+
* Catches every unhandled exception across the entire application.
50+
* Normalises the response shape and logs 5xx errors as errors, 4xx as warnings.
51+
* ZodError is parsed into a structured `issues` array with nested union errors.
52+
* Local exception filters in individual controllers take precedence.
53+
*/
54+
@Catch()
55+
export class ZodExceptionFilter implements ExceptionFilter {
56+
private readonly logger = new Logger(ZodExceptionFilter.name)
57+
58+
catch(exception: unknown, host: ArgumentsHost): void {
59+
const ctx = host.switchToHttp()
60+
const request = ctx.getRequest<Request>()
61+
const response = ctx.getResponse<Response>()
62+
63+
const resolved = this.resolveException(exception)
64+
const body: ErrorBody = {
65+
statusCode: resolved.status,
66+
error: this.statusToText(resolved.status),
67+
message: resolved.message,
68+
path: request.url,
69+
timestamp: new Date().toISOString(),
70+
...(resolved.issues !== undefined && { issues: resolved.issues }),
71+
}
72+
73+
if (resolved.status >= 500) {
74+
this.logger.error(
75+
`${request.method} ${request.url}${String(resolved.status)}: ${resolved.message}`,
76+
exception instanceof Error ? exception.stack : String(exception),
77+
)
78+
} else {
79+
this.logger.warn(
80+
`${request.method} ${request.url}${String(resolved.status)}: ${resolved.message}`,
81+
)
82+
}
83+
84+
response.status(resolved.status).json(body)
85+
}
86+
87+
// ---------------------------------------------------------------------------
88+
89+
private statusToText(status: number): string {
90+
const map: Record<number, string> = {
91+
400: 'Bad Request',
92+
401: 'Unauthorized',
93+
403: 'Forbidden',
94+
404: 'Not Found',
95+
409: 'Conflict',
96+
422: 'Unprocessable Entity',
97+
429: 'Too Many Requests',
98+
500: 'Internal Server Error',
99+
502: 'Bad Gateway',
100+
503: 'Service Unavailable',
101+
}
102+
return map[status] ?? `HTTP ${String(status)}`
103+
}
104+
105+
private resolveException(exception: unknown): {
106+
status: number
107+
message: string
108+
issues?: ValidationIssue[]
109+
} {
110+
if (exception instanceof HttpException) {
111+
const res = exception.getResponse()
112+
const resObj = typeof res === 'object' ? (res as Record<string, unknown>) : null
113+
const message =
114+
resObj !== null && 'message' in resObj
115+
? String(resObj['message'])
116+
: exception.message
117+
return { status: exception.getStatus(), message }
118+
}
119+
120+
// ZodError thrown by @ParseBody / @ParseQuery decorators - invalid client input
121+
if (exception instanceof Error && exception.constructor.name === 'ZodError') {
122+
const zod = exception as ZodErrorLike
123+
return {
124+
status: HttpStatus.BAD_REQUEST,
125+
message: 'Validation failed',
126+
issues: zod.issues.map((issue) => this.formatIssue(issue)),
127+
}
128+
}
129+
130+
return { status: HttpStatus.INTERNAL_SERVER_ERROR, message: 'Internal server error' }
131+
}
132+
133+
private formatIssue(issue: RawZodIssue): ValidationIssue {
134+
const field = issue.path.length > 0 ? issue.path.join('.') : '(root)'
135+
const base: ValidationIssue = { field, message: issue.message, code: issue.code }
136+
137+
if (issue.code === 'invalid_union' && Array.isArray(issue.errors)) {
138+
const branches = issue.errors.map((branch) =>
139+
branch.map((i) => this.formatIssue(i)),
140+
)
141+
// Deduplicate across branches: identical field+code+message pairs collapse into one
142+
const seen = new Set<string>()
143+
base.unionErrors = branches
144+
.map((branch) =>
145+
branch.filter((i) => {
146+
const key = `${i.field}|${i.code}|${i.message}`
147+
if (seen.has(key)) return false
148+
seen.add(key)
149+
return true
150+
}),
151+
)
152+
.filter((branch) => branch.length > 0)
153+
}
154+
155+
return base
156+
}
157+
}

src/common/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './decorators/index.js'
2+
export { ZodExceptionFilter } from './exceptions/zod-exception.filter.js'
3+
export { RequestIdMiddleware } from './middleware/request-id.middleware.js'
4+
export { HttpLoggerMiddleware } from './middleware/http-logger.middleware.js'

0 commit comments

Comments
 (0)