Skip to content

Commit 4de6cb2

Browse files
committedMay 2, 2023
feat: zod serializer interceptor
1 parent 6af7e68 commit 4de6cb2

File tree

2 files changed

+109
-0
lines changed

2 files changed

+109
-0
lines changed
 

‎src/serializer.test.ts

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { createMock } from '@golevelup/ts-jest'
2+
import { CallHandler, ExecutionContext } from '@nestjs/common'
3+
import { Reflector } from '@nestjs/core'
4+
import { lastValueFrom, of } from 'rxjs'
5+
import { z } from 'zod'
6+
import { createZodDto } from './dto'
7+
import { ZodSerializerInterceptor } from './serializer'
8+
9+
describe('ZodSerializerInterceptor', () => {
10+
const UserSchema = z.object({
11+
username: z.string(),
12+
})
13+
14+
class UserDto extends createZodDto(UserSchema) {}
15+
16+
const testUser = {
17+
username: 'test',
18+
password: 'test',
19+
}
20+
21+
const context = createMock<ExecutionContext>()
22+
const handler = createMock<CallHandler>({
23+
handle: () => of(testUser),
24+
})
25+
26+
test('interceptor should strip out password', async () => {
27+
const reflector = createMock<Reflector>({
28+
getAllAndOverride: () => UserDto,
29+
})
30+
31+
const interceptor = new ZodSerializerInterceptor(reflector)
32+
33+
const userObservable = interceptor.intercept(context, handler)
34+
const user: typeof testUser = await lastValueFrom(userObservable)
35+
36+
expect(user.password).toBe(undefined)
37+
expect(user.username).toBe('test')
38+
})
39+
40+
test('interceptor should not strip out password if no UserDto is defined', async () => {
41+
const context = createMock<ExecutionContext>()
42+
const handler = createMock<CallHandler>({
43+
handle: () => of(testUser),
44+
})
45+
const reflector = createMock<Reflector>({
46+
getAllAndOverride: jest.fn(),
47+
})
48+
49+
const interceptor = new ZodSerializerInterceptor(reflector)
50+
51+
const userObservable = interceptor.intercept(context, handler)
52+
const user: typeof testUser = await lastValueFrom(userObservable)
53+
54+
expect(user.password).toBe('test')
55+
expect(user.username).toBe('test')
56+
})
57+
})

‎src/serializer.ts

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import {
2+
CallHandler,
3+
ExecutionContext,
4+
Inject,
5+
Injectable,
6+
NestInterceptor,
7+
SetMetadata,
8+
StreamableFile,
9+
} from '@nestjs/common'
10+
import { map, Observable } from 'rxjs'
11+
import { ZodSchema } from 'zod'
12+
import { ZodDto } from './dto'
13+
import { validate } from './validate'
14+
15+
// NOTE (external)
16+
// We need to deduplicate them here due to the circular dependency
17+
// between core and common packages
18+
const REFLECTOR = 'Reflector'
19+
20+
export const ZodSerializerDtoOptions = 'ZOD_SERIALIZER_DTO_OPTIONS' as const
21+
22+
export const ZodSerializerDto = (dto: ZodDto | ZodSchema) =>
23+
SetMetadata(ZodSerializerDtoOptions, dto)
24+
25+
@Injectable()
26+
export class ZodSerializerInterceptor implements NestInterceptor {
27+
constructor(@Inject(REFLECTOR) protected readonly reflector: any) {}
28+
29+
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
30+
const responseSchema = this.getContextResponseSchema(context)
31+
32+
return next.handle().pipe(
33+
map((res: object | object[]) => {
34+
if (!responseSchema) return res
35+
if (typeof res !== 'object' || res instanceof StreamableFile) return res
36+
37+
return Array.isArray(res)
38+
? res.map((item) => validate(item, responseSchema))
39+
: validate(res, responseSchema)
40+
})
41+
)
42+
}
43+
44+
protected getContextResponseSchema(
45+
context: ExecutionContext
46+
): ZodDto | ZodSchema | undefined {
47+
return this.reflector.getAllAndOverride(ZodSerializerDtoOptions, [
48+
context.getHandler(),
49+
context.getClass(),
50+
])
51+
}
52+
}

0 commit comments

Comments
 (0)
Please sign in to comment.