-
Notifications
You must be signed in to change notification settings - Fork 66
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #44 from austinwoon/chore/response-serializer
feat: zod serializer interceptor
- Loading branch information
Showing
2 changed files
with
128 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
import { createMock } from '@golevelup/ts-jest' | ||
import { CallHandler, ExecutionContext } from '@nestjs/common' | ||
import { Reflector } from '@nestjs/core' | ||
import { lastValueFrom, of } from 'rxjs' | ||
import { z } from 'zod' | ||
import { createZodDto } from './dto' | ||
import { ZodValidationException } from './exception' | ||
import { ZodSerializerInterceptor } from './serializer' | ||
|
||
describe('ZodSerializerInterceptor', () => { | ||
const UserSchema = z.object({ | ||
username: z.string(), | ||
}) | ||
|
||
class UserDto extends createZodDto(UserSchema) {} | ||
|
||
const testUser = { | ||
username: 'test', | ||
password: 'test', | ||
} | ||
|
||
const context = createMock<ExecutionContext>() | ||
|
||
test('interceptor should strip out password', async () => { | ||
const handler = createMock<CallHandler>({ | ||
handle: () => of(testUser), | ||
}) | ||
|
||
const reflector = createMock<Reflector>({ | ||
getAllAndOverride: () => UserDto, | ||
}) | ||
|
||
const interceptor = new ZodSerializerInterceptor(reflector) | ||
|
||
const userObservable = interceptor.intercept(context, handler) | ||
const user: typeof testUser = await lastValueFrom(userObservable) | ||
|
||
expect(user.password).toBe(undefined) | ||
expect(user.username).toBe('test') | ||
}) | ||
|
||
test('wrong response shape should throw ZodValidationException', async () => { | ||
const handler = createMock<CallHandler>({ | ||
handle: () => of({ user: 'test' }), | ||
}) | ||
|
||
const reflector = createMock<Reflector>({ | ||
getAllAndOverride: () => UserDto, | ||
}) | ||
|
||
const interceptor = new ZodSerializerInterceptor(reflector) | ||
|
||
const userObservable = interceptor.intercept(context, handler) | ||
expect(lastValueFrom(userObservable)).rejects.toBeInstanceOf( | ||
ZodValidationException | ||
) | ||
}) | ||
|
||
test('interceptor should not strip out password if no UserDto is defined', async () => { | ||
const handler = createMock<CallHandler>({ | ||
handle: () => of(testUser), | ||
}) | ||
|
||
const reflector = createMock<Reflector>({ | ||
getAllAndOverride: jest.fn(), | ||
}) | ||
|
||
const interceptor = new ZodSerializerInterceptor(reflector) | ||
|
||
const userObservable = interceptor.intercept(context, handler) | ||
const user: typeof testUser = await lastValueFrom(userObservable) | ||
|
||
expect(user.password).toBe('test') | ||
expect(user.username).toBe('test') | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
import { | ||
CallHandler, | ||
ExecutionContext, | ||
Inject, | ||
Injectable, | ||
NestInterceptor, | ||
SetMetadata, | ||
StreamableFile, | ||
} from '@nestjs/common' | ||
import { map, Observable } from 'rxjs' | ||
import { ZodSchema } from 'zod' | ||
import { ZodDto } from './dto' | ||
import { validate } from './validate' | ||
|
||
// NOTE (external) | ||
// We need to deduplicate them here due to the circular dependency | ||
// between core and common packages | ||
const REFLECTOR = 'Reflector' | ||
|
||
export const ZodSerializerDtoOptions = 'ZOD_SERIALIZER_DTO_OPTIONS' as const | ||
|
||
export const ZodSerializerDto = (dto: ZodDto | ZodSchema) => | ||
SetMetadata(ZodSerializerDtoOptions, dto) | ||
|
||
@Injectable() | ||
export class ZodSerializerInterceptor implements NestInterceptor { | ||
constructor(@Inject(REFLECTOR) protected readonly reflector: any) {} | ||
|
||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> { | ||
const responseSchema = this.getContextResponseSchema(context) | ||
|
||
return next.handle().pipe( | ||
map((res: object | object[]) => { | ||
if (!responseSchema) return res | ||
if (typeof res !== 'object' || res instanceof StreamableFile) return res | ||
|
||
return Array.isArray(res) | ||
? res.map((item) => validate(item, responseSchema)) | ||
: validate(res, responseSchema) | ||
}) | ||
) | ||
} | ||
|
||
protected getContextResponseSchema( | ||
context: ExecutionContext | ||
): ZodDto | ZodSchema | undefined { | ||
return this.reflector.getAllAndOverride(ZodSerializerDtoOptions, [ | ||
context.getHandler(), | ||
context.getClass(), | ||
]) | ||
} | ||
} |