Skip to content

feat(): add fast json stringify #47

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
378 changes: 375 additions & 3 deletions .pnp.js

Large diffs are not rendered by default.

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
22 changes: 22 additions & 0 deletions integration/fast-serialization/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import {
JsonSchemaSerializerInterceptor,
ValidationModule,
} from '../../../lib';
import { CatsModule } from './cats/cats.module';

@Module({
imports: [
ValidationModule.forRoot({ fastSerialization: true }),
ValidationModule.forFeature(),
CatsModule,
],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: JsonSchemaSerializerInterceptor,
},
],
})
export class AppModule {}
31 changes: 31 additions & 0 deletions integration/fast-serialization/src/cats/cats.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Controller, Post, Body } from '@nestjs/common';
import { ApiResponse } from '@nestjs/swagger';

@Controller('cats')
export class CatsController {
@Post()
@ApiResponse({
status: 201,
schema: {
type: 'object',
additionalProperties: false,
properties: {
success: {
type: 'boolean',
},
foo: {
type: 'string',
},
hello: {
type: 'string',
},
age: {
type: 'number',
},
},
},
})
public list(@Body() body: unknown) {
return body;
}
}
7 changes: 7 additions & 0 deletions integration/fast-serialization/src/cats/cats.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';

@Module({
controllers: [CatsController],
})
export class CatsModule {}
41 changes: 41 additions & 0 deletions integration/fast-serialization/test/fastify-serialization.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Test } from '@nestjs/testing';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import * as chai from 'chai';
import { AppModule } from '../src/app.module';

const expect = chai.expect;

describe('Fastify serialization', () => {
let app: NestFastifyApplication;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
await app.init();
});
it('should use fast json stringify serialization', async () => {
const testPayload = {
success: true,
foo: 'bar',
hello: 'world',
age: 23,
};
const response = await app.inject({
method: 'POST',
path: '/cats',
headers: {
'content-type': 'application/json',
},
payload: {
...testPayload,
extra: 'toErase',
},
});
expect(response.statusCode).eq(201);
expect(response.json()).deep.eq(testPayload);
});
afterEach(async () => {
await app.close();
});
});
22 changes: 22 additions & 0 deletions integration/fast-serialization/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": false,
"noImplicitAny": false,
"removeComments": true,
"noLib": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es6",
"sourceMap": true,
"allowJs": true,
"outDir": "./dist"
},
"include": [
"src/**/*",
"test/**/*"
],
"exclude": [
"node_modules",
]
}
48 changes: 42 additions & 6 deletions lib/interceptors/json-schema-serializer.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,30 @@ import {
NestInterceptor,
ExecutionContext,
CallHandler,
Inject,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AsyncValidateFunction } from 'ajv';
import type { FastifyReply } from 'fastify';
import type { Response } from '@nestjs/common';
import { AjvValidator } from '../validators';
import { ValidationContextService } from '../services';
import { ValidationException } from '../exceptions';
import { SerializerTypeValidator } from '../interfaces';
import { AsyncValidateFunction } from 'ajv';
import {
SerializerTypeValidator,
ValidationModuleOptions,
} from '../interfaces';
import { VALIDATION_MODULE_OPTIONS } from '../validation.constants';

@Injectable()
export class JsonSchemaSerializerInterceptor implements NestInterceptor {
constructor(private readonly _validationContext: ValidationContextService) {}
constructor(
private readonly _validationContext: ValidationContextService,
private readonly _ajv: AjvValidator,
@Inject(VALIDATION_MODULE_OPTIONS)
private readonly _validationModuleOptions: ValidationModuleOptions,
) {}
public async intercept(
context: ExecutionContext,
next: CallHandler,
Expand Down Expand Up @@ -54,7 +67,10 @@ export class JsonSchemaSerializerInterceptor implements NestInterceptor {
.map(({ type, validator }) => validator(req[type])),
);
} catch (err) {
throw new ValidationException(err.errors);
throw new ValidationException(
err.errors,
this._ajv.ajv.errorsText(err.errors),
);
}
}
private async onResponse<T = unknown>(
Expand All @@ -70,9 +86,16 @@ export class JsonSchemaSerializerInterceptor implements NestInterceptor {
return data;
}
try {
await validator(data);
const maybeJsonString = await validator(data);
if (this._validationModuleOptions.fastSerialization) {
this.setApplicationJsonType(context);
return maybeJsonString;
}
} catch (err) {
throw new ValidationException(err.errors);
throw new ValidationException(
err.errors,
this._ajv.ajv.errorsText(err.errors),
);
}
return data;
}
Expand All @@ -88,4 +111,17 @@ export class JsonSchemaSerializerInterceptor implements NestInterceptor {
): serializerValidator is SerializerTypeValidator<AsyncValidateFunction> =>
!!serializerValidator.validator;
}
private setApplicationJsonType(context: ExecutionContext): void {
const res = context.switchToHttp().getResponse<FastifyReply | Response>();
if (this.isFastifyResponse(res)) {
res.type('application/json');
return;
}
throw new Error(
`The application is using Express adapter which currently is not supported`,
);
}
private isFastifyResponse(res: FastifyReply | Response): res is FastifyReply {
return !!(res as FastifyReply).raw;
}
}
4 changes: 3 additions & 1 deletion lib/interfaces/paths-schemas.interface.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { JSONSchema } from 'fluent-json-schema';
import { AsyncValidateFunction } from 'ajv';

export type FastJsonStringifier = (_doc: any) => any;

export type PathsSchemas = Record<string, OperationSchemas>;

export interface ResponsesSchemas<T> {
Expand All @@ -19,7 +21,7 @@ export interface OperationValidators extends Record<string, any> {
query: AsyncValidateFunction | null;
body: AsyncValidateFunction | null;
headers: AsyncValidateFunction | null;
responses: ResponsesSchemas<AsyncValidateFunction>;
responses: ResponsesSchemas<AsyncValidateFunction | FastJsonStringifier>;
}

export interface DefaultObjectSchema {
Expand Down
6 changes: 5 additions & 1 deletion lib/interfaces/validation-module.interface.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { ModuleMetadata, Type } from '@nestjs/common';
import { Options as AjvOptions } from 'ajv';

export type ValidationModuleOptions = AjvOptions;
export type ValidationModuleOptions = AjvOptions & LoadSchemasOptions;

export interface LoadSchemasOptions {
fastSerialization?: boolean;
}

export interface ValidationModuleOptionsFactory {
createValidationOptions():
Expand Down
39 changes: 30 additions & 9 deletions lib/schemas-repository.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import { Injectable } from '@nestjs/common';
import { JSONSchema, BaseSchema } from 'fluent-json-schema';
import { AsyncValidateFunction } from 'ajv';
import * as fastJson from 'fast-json-stringify';
import {
OperationSchemas,
OperationValidators,
PathsSchemas,
ResponsesSchemas,
LoadSchemasOptions,
FastJsonStringifier,
} from './interfaces';
import { AjvValidator } from './validators';

@Injectable()
export class SchemasRepository {
private _schemas: Record<string, OperationValidators> = {};
constructor(private readonly _ajv: AjvValidator) {}
public loadSchemas(pathsSchemas: PathsSchemas): void {
public loadSchemas(
pathsSchemas: PathsSchemas,
{ fastSerialization }: LoadSchemasOptions = {},
): void {
this.iterateThroughEachMethod(
pathsSchemas,
(operationId, type, jsonSchemaOrResponses) => {
Expand All @@ -21,10 +28,9 @@ export class SchemasRepository {
}
this._schemas[operationId] = this._schemas[operationId] ?? {};
if (this.isJsonSchema(jsonSchemaOrResponses)) {
this._schemas[operationId][type] = this._ajv.ajv.compile({
$async: true,
...jsonSchemaOrResponses.valueOf(),
});
this._schemas[operationId][type] = this.compileJsonSchema(
jsonSchemaOrResponses,
);
} else if (this.isResponsesSchemas(type, jsonSchemaOrResponses)) {
this._schemas[operationId].responses =
this._schemas[operationId].responses ?? {};
Expand All @@ -35,10 +41,10 @@ export class SchemasRepository {
}
this._schemas[operationId].responses[
statusCode
] = this._ajv.ajv.compile({
$async: true,
...jsonSchema.valueOf(),
});
] = this.getAjvSerializationValidator(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
] = this.getAjvSerializationValidator(
] = this.getSerializatorOrValidator(

jsonSchema,
fastSerialization,
);
},
);
} else {
Expand Down Expand Up @@ -68,6 +74,21 @@ export class SchemasRepository {
),
);
}
private getAjvSerializationValidator(
jsonSchema: JSONSchema,
fastSerialization?: boolean,
): AsyncValidateFunction | FastJsonStringifier {
if (fastSerialization) {
return fastJson(jsonSchema.valueOf());
}
return this.compileJsonSchema(jsonSchema);
}
private compileJsonSchema(jsonSchema: JSONSchema): AsyncValidateFunction {
return this._ajv.ajv.compile({
$async: true,
...jsonSchema.valueOf(),
});
}
private isJsonSchema(
maybeJsonSchema: unknown,
): maybeJsonSchema is JSONSchema {
Expand Down
4 changes: 2 additions & 2 deletions lib/services/validation-context.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AsyncValidateFunction } from 'ajv';
import { OperationSchemas } from '../interfaces';
import { OperationSchemas, FastJsonStringifier } from '../interfaces';
import { SchemasRepository } from '../schemas-repository';

@Injectable()
Expand All @@ -22,7 +22,7 @@ export class ValidationContextService {
public getResponseValidatorByStatusCode(
statusCode: number,
context: ExecutionContext,
): AsyncValidateFunction | null {
): AsyncValidateFunction | FastJsonStringifier | null {
const operationId = this.getOperationId(context);
const operationValidators = this._schemasRepository.getOperationValidators(
operationId,
Expand Down
21 changes: 18 additions & 3 deletions lib/validation-core.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Provider,
Module,
Global,
Inject,
} from '@nestjs/common';
import {
ValidationModuleOptions,
Expand All @@ -19,16 +20,25 @@ import { SchemasRepository } from './schemas-repository';
@Global()
@Module({})
export class ValidationCoreModule implements OnApplicationBootstrap {
static forRoot(options?: ValidationModuleOptions): DynamicModule {
static forRoot(options: ValidationModuleOptions = {}): DynamicModule {
const validationModuleOptions = {
provide: VALIDATION_MODULE_OPTIONS,
useValue: options,
};
const validationProviders = this.createValidationProviders(options);
return {
module: ValidationCoreModule,
providers: [
...validationProviders,
SchemasRepository,
SwaggerExplorerServices,
validationModuleOptions,
],
exports: [
...validationProviders,
SchemasRepository,
validationModuleOptions,
],
exports: [...validationProviders, SchemasRepository],
};
}
static forRootAsync(options: ValidationModuleAsyncOptions): DynamicModule {
Expand Down Expand Up @@ -100,9 +110,14 @@ export class ValidationCoreModule implements OnApplicationBootstrap {
constructor(
private readonly _swaggerExplorer: SwaggerExplorerServices,
private readonly _schemasRepository: SchemasRepository,
@Inject(VALIDATION_MODULE_OPTIONS)
private readonly _validationModuleOptions: ValidationModuleOptions,
) {}
onApplicationBootstrap(): void {
const paths = this._swaggerExplorer.explore();
this._schemasRepository.loadSchemas(paths);
this._schemasRepository.loadSchemas(paths, {
fastSerialization:
this._validationModuleOptions.fastSerialization ?? false,
});
}
}
Loading