diff --git a/package.json b/package.json index 6a7010012..df2156e68 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "lint.fix": "yarn lint --fix", "build": "ttsc --build ./packages/tsconfig.build.json", "posttest": "yarn lint", - "test": "jest", + "test": "node --expose-gc ./node_modules/.bin/jest", "release": "lerna version", "prebuild.binary": "yarn build", "build.binary": "npx pkg --output ./cli-binaries/prism-cli --options max_old_space_size=4096 ./packages/cli/", diff --git a/packages/http/src/__tests__/memory-leak-prevention.spec.ts b/packages/http/src/__tests__/memory-leak-prevention.spec.ts new file mode 100644 index 000000000..fcb102b99 --- /dev/null +++ b/packages/http/src/__tests__/memory-leak-prevention.spec.ts @@ -0,0 +1,43 @@ +import { createClientFromOperations, PrismHttp } from '../client'; +import { httpOperations } from './fixtures'; +import { createTestLogger } from './test-helpers'; + +describe('Checks if memory leaks', () => { + function round(client: PrismHttp) { + return client.post( + '/todos?overwrite=yes', + { + name: 'some name', + completed: false, + }, + { headers: { 'x-todos-publish': '2021-09-21T09:48:48.108Z' } } + ); + } + + it('when handling 5k of requests', () => { + const logger = createTestLogger(); + const client = createClientFromOperations(httpOperations, { + validateRequest: true, + validateResponse: true, + checkSecurity: true, + errors: true, + mock: { + dynamic: false, + }, + logger, + }); + + round(client); + const baseMemoryUsage = process.memoryUsage().heapUsed; + + for (let i = 0; i < 5000; i++) { + round(client); + if (i % 100 === 0) { + global.gc(); + } + } + + global.gc(); + expect(process.memoryUsage().heapUsed).toBeLessThanOrEqual(baseMemoryUsage * 1.02); + }); +}); diff --git a/packages/http/src/__tests__/test-helpers.ts b/packages/http/src/__tests__/test-helpers.ts new file mode 100644 index 000000000..ad59ef6ed --- /dev/null +++ b/packages/http/src/__tests__/test-helpers.ts @@ -0,0 +1,14 @@ +import * as pino from 'pino'; + +/** + * Creates special instance of pino logger that prevent collecting or logging anything + * Unfortunately disabled logger didn't work + */ +export function createTestLogger() { + const logger = pino({ + enabled: false, + }); + + logger.success = logger.info; + return logger; +} diff --git a/packages/http/src/client.ts b/packages/http/src/client.ts index 88e2de96f..18181ffe0 100644 --- a/packages/http/src/client.ts +++ b/packages/http/src/client.ts @@ -15,10 +15,12 @@ logger.success = logger.info; type IClientConfig = IHttpConfig & { baseUrl?: string; + logger?: pino.Logger; }; export function createClientFromOperations(resources: IHttpOperation[], defaultConfig: IClientConfig): PrismHttp { - const obj = createInstance(defaultConfig, { logger }); + const finalLogger = defaultConfig.logger ?? logger; + const obj = createInstance(defaultConfig, { logger: finalLogger }); type headersFromRequest = Required>; @@ -141,6 +143,7 @@ interface IRequestFunctionWithMethod { input: Required>, config?: Partial ): Promise; + (this: PrismHttp, url: string, config?: Partial): Promise; } @@ -152,6 +155,7 @@ interface IRequestFunctionWithMethodWithBody { input: Required>, config?: Partial ): Promise; + (this: PrismHttp, url: string, body: unknown, config?: Partial): Promise; } diff --git a/packages/http/src/validator/validators/body.ts b/packages/http/src/validator/validators/body.ts index eb2e84a72..808afc207 100644 --- a/packages/http/src/validator/validators/body.ts +++ b/packages/http/src/validator/validators/body.ts @@ -13,6 +13,7 @@ import { validateAgainstSchema } from './utils'; import { ValidationContext, validateFn } from './types'; import { stripReadOnlyProperties, stripWriteOnlyProperties } from '../../utils/filterRequiredProperties'; +import { JSONSchema7 } from 'json-schema'; export function deserializeFormBody( schema: JSONSchema, @@ -84,9 +85,23 @@ function deserializeAndValidate(content: IMediaTypeContent, schema: JSONSchema, ); } -const normalizeSchemaProcessorMap: Record O.Option> = { - [ValidationContext.Input]: stripReadOnlyProperties, - [ValidationContext.Output]: stripWriteOnlyProperties, +function memoizeSchemaNormalizer(normalizer: SchemaNormalizer): SchemaNormalizer { + const cache = new WeakMap>(); + return (schema: JSONSchema7) => { + const cached = cache.get(schema); + if (!cached) { + const newSchema = normalizer(schema); + cache.set(schema, newSchema); + return newSchema; + } + return cached; + }; +} + +type SchemaNormalizer = (schema: JSONSchema) => O.Option; +const normalizeSchemaProcessorMap: Record = { + [ValidationContext.Input]: memoizeSchemaNormalizer(stripReadOnlyProperties), + [ValidationContext.Output]: memoizeSchemaNormalizer(stripWriteOnlyProperties), }; export const validate: validateFn = (target, specs, context, mediaType, bundle) => { diff --git a/packages/http/src/validator/validators/params.ts b/packages/http/src/validator/validators/params.ts index 3699dfeed..c53219a63 100644 --- a/packages/http/src/validator/validators/params.ts +++ b/packages/http/src/validator/validators/params.ts @@ -17,6 +17,8 @@ export type Deps = { defaultStyle: HttpParamStyles; }; +const schemaCache = new WeakMap(); + export const validateParams = ( target: Target, specs: IHttpParam[], @@ -38,7 +40,11 @@ export const validateParams = ( return pipe( NEA.fromArray(specs), O.map(specs => { - const schema = createJsonSchemaFromParams(specs); + const schema = schemaCache.get(specs) ?? createJsonSchemaFromParams(specs); + if (!schemaCache.has(specs)) { + schemaCache.set(specs, schema); + } + const parameterValues = pickBy( mapValues( keyBy(specs, s => s.name.toLowerCase()), diff --git a/packages/http/src/validator/validators/utils.ts b/packages/http/src/validator/validators/utils.ts index a13dc0f73..6740f7bec 100644 --- a/packages/http/src/validator/validators/utils.ts +++ b/packages/http/src/validator/validators/utils.ts @@ -3,7 +3,7 @@ import { DiagnosticSeverity } from '@stoplight/types'; import * as O from 'fp-ts/Option'; import { pipe } from 'fp-ts/function'; import { NonEmptyArray, fromArray, map } from 'fp-ts/NonEmptyArray'; -import Ajv, { ErrorObject, Logger, Options } from 'ajv'; +import Ajv, { ErrorObject, Logger, Options, ValidateFunction } from 'ajv'; import type AjvCore from 'ajv/dist/core'; import Ajv2019 from 'ajv/dist/2019'; import Ajv2020 from 'ajv/dist/2020'; @@ -89,6 +89,32 @@ export const convertAjvErrors = (errors: NonEmptyArray, severity: D }) ); +const validationsFunctionsCache = new WeakMap>(); +const EMPTY_BUNDLE = { _emptyBundle: true }; + +function getValidationFunction(ajvInstance: AjvCore, schema: JSONSchema, bundle?: unknown): ValidateFunction { + const bundledFunctionsCache = validationsFunctionsCache.get(schema); + const bundleKey = typeof bundle === 'object' && bundle !== null ? bundle : EMPTY_BUNDLE; + if (bundledFunctionsCache) { + const validationFunction = bundledFunctionsCache.get(bundleKey); + if (validationFunction) { + return validationFunction; + } + } + + const validationFunction = ajvInstance.compile({ + ...schema, + __bundled__: bundle, + }); + + if (!bundledFunctionsCache) { + validationsFunctionsCache.set(schema, new WeakMap()); + } + + validationsFunctionsCache.get(schema)!.set(bundleKey, validationFunction); + return validationFunction; +} + export const validateAgainstSchema = ( value: unknown, schema: JSONSchema, @@ -97,12 +123,7 @@ export const validateAgainstSchema = ( bundle?: unknown ): O.Option> => pipe( - O.tryCatch(() => - assignAjvInstance(String(schema.$schema), coerce).compile({ - ...schema, - __bundled__: bundle, - }) - ), + O.tryCatch(() => getValidationFunction(assignAjvInstance(String(schema.$schema), coerce), schema, bundle)), O.chainFirst(validateFn => O.tryCatch(() => validateFn(value))), O.chain(validateFn => pipe(O.fromNullable(validateFn.errors), O.chain(fromArray))), O.map(errors => convertAjvErrors(errors, DiagnosticSeverity.Error, prefix)) diff --git a/packages/tsconfig.test.json b/packages/tsconfig.test.json index b7108d928..d968519f4 100644 --- a/packages/tsconfig.test.json +++ b/packages/tsconfig.test.json @@ -4,6 +4,7 @@ "noEmit": true, "paths": { "@stoplight/prism-core": ["./core/src"], + "@stoplight/prism-cli": ["./cli/src"], "@stoplight/prism-http": ["./http/src"], "@stoplight/prism-http-server": ["./http-server/src"] }