From 8c72d297a18063507ee41e5c605e66af026659b4 Mon Sep 17 00:00:00 2001 From: Ville Lindholm Date: Fri, 24 Apr 2026 15:20:35 +0300 Subject: [PATCH] fix: scope AJV instances per lintDocument call to fix concurrent validation The global AJV singleton in `ajv.ts` captured a `resolve` closure from whichever document first created the instance. When consumers like `openapi-typescript` process multiple APIs concurrently via `Promise.all`, the second document's `resolve` was silently discarded, producing spurious "Example validation errored: can't resolve reference" warnings for valid same-document `$ref`s. Replace the global singleton with an `AjvValidator` class instantiated per `lintDocument` call and threaded through WalkContext/UserContext. --- .changeset/fix-ajv-concurrent-lint.md | 5 + .../fixtures/concurrent-lint/common.yaml | 12 + .../fixtures/concurrent-lint/spec-a.yaml | 39 +++ .../fixtures/concurrent-lint/spec-b.yaml | 39 +++ packages/core/src/__tests__/lint.test.ts | 20 ++ packages/core/src/lint.ts | 3 - packages/core/src/rules/__tests__/ajv.test.ts | 27 +- packages/core/src/rules/ajv.ts | 241 +++++++++--------- .../common/no-invalid-parameter-examples.ts | 4 + .../common/no-invalid-schema-examples.ts | 4 + .../oas3/no-invalid-media-type-examples.ts | 4 + packages/core/src/rules/utils.ts | 7 +- 12 files changed, 270 insertions(+), 135 deletions(-) create mode 100644 .changeset/fix-ajv-concurrent-lint.md create mode 100644 packages/core/src/__tests__/fixtures/concurrent-lint/common.yaml create mode 100644 packages/core/src/__tests__/fixtures/concurrent-lint/spec-a.yaml create mode 100644 packages/core/src/__tests__/fixtures/concurrent-lint/spec-b.yaml diff --git a/.changeset/fix-ajv-concurrent-lint.md b/.changeset/fix-ajv-concurrent-lint.md new file mode 100644 index 0000000000..daf90d71f7 --- /dev/null +++ b/.changeset/fix-ajv-concurrent-lint.md @@ -0,0 +1,5 @@ +--- +'@redocly/openapi-core': patch +--- + +Fixed spurious "can't resolve reference" warnings when linting multiple APIs concurrently. diff --git a/packages/core/src/__tests__/fixtures/concurrent-lint/common.yaml b/packages/core/src/__tests__/fixtures/concurrent-lint/common.yaml new file mode 100644 index 0000000000..488a126af9 --- /dev/null +++ b/packages/core/src/__tests__/fixtures/concurrent-lint/common.yaml @@ -0,0 +1,12 @@ +openapi: '3.0.0' +info: + title: Common + version: '1.0.0' +paths: {} +components: + schemas: + Error: + type: object + properties: + message: + type: string diff --git a/packages/core/src/__tests__/fixtures/concurrent-lint/spec-a.yaml b/packages/core/src/__tests__/fixtures/concurrent-lint/spec-a.yaml new file mode 100644 index 0000000000..75d145256a --- /dev/null +++ b/packages/core/src/__tests__/fixtures/concurrent-lint/spec-a.yaml @@ -0,0 +1,39 @@ +openapi: '3.0.0' +info: + title: Service A + version: '1.0.0' +servers: + - url: https://a.example.com +security: + - BearerAuth: [] +paths: + /a: + post: + operationId: create_a + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Foo' + example: + name: 'test' + responses: + '200': + description: OK + '400': + description: Error + content: + application/json: + schema: + $ref: './common.yaml#/components/schemas/Error' +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + schemas: + Foo: + type: object + properties: + name: + type: string diff --git a/packages/core/src/__tests__/fixtures/concurrent-lint/spec-b.yaml b/packages/core/src/__tests__/fixtures/concurrent-lint/spec-b.yaml new file mode 100644 index 0000000000..a14ba1ca54 --- /dev/null +++ b/packages/core/src/__tests__/fixtures/concurrent-lint/spec-b.yaml @@ -0,0 +1,39 @@ +openapi: '3.0.0' +info: + title: Service B + version: '1.0.0' +servers: + - url: https://b.example.com +security: + - BearerAuth: [] +paths: + /b: + post: + operationId: create_b + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Bar' + example: + value: 42 + responses: + '200': + description: OK + '400': + description: Error + content: + application/json: + schema: + $ref: './common.yaml#/components/schemas/Error' +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + schemas: + Bar: + type: object + properties: + value: + type: integer diff --git a/packages/core/src/__tests__/lint.test.ts b/packages/core/src/__tests__/lint.test.ts index 9ce768ebe4..6ecc9e7df5 100644 --- a/packages/core/src/__tests__/lint.test.ts +++ b/packages/core/src/__tests__/lint.test.ts @@ -2003,6 +2003,26 @@ describe('lint', () => { expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); }); + it('should not produce spurious example validation errors when linting concurrently', async () => { + const config = await createConfig({ + rules: { 'no-invalid-media-type-examples': 'error' }, + }); + + const [resultsA, resultsB] = await Promise.all([ + lint({ + ref: path.join(__dirname, 'fixtures/concurrent-lint/spec-a.yaml'), + config, + }), + lint({ + ref: path.join(__dirname, 'fixtures/concurrent-lint/spec-b.yaml'), + config, + }), + ]); + + expect(resultsA).toHaveLength(0); + expect(resultsB).toHaveLength(0); + }); + it('should report no unresolved extends when scorecardClassic extends contains a ref to non existing preset', async () => { const testConfigContent = outdent` scorecardClassic: diff --git a/packages/core/src/lint.ts b/packages/core/src/lint.ts index 9e81fa6f2f..72dfc98381 100755 --- a/packages/core/src/lint.ts +++ b/packages/core/src/lint.ts @@ -5,7 +5,6 @@ import { initRules } from './config/rules.js'; import { detectSpec, getMajorSpecVersion } from './detect-spec.js'; import { getTypes } from './oas-types.js'; import { BaseResolver, resolveDocument, makeDocumentFromString, type Document } from './resolve.js'; -import { releaseAjvInstance } from './rules/ajv.js'; import { NoUnresolvedRefs } from './rules/common/no-unresolved-refs.js'; import { Struct } from './rules/common/struct.js'; import { normalizeTypes, type NodeType } from './types/index.js'; @@ -71,8 +70,6 @@ export async function lintDocument(opts: { customTypes?: Record; externalRefResolver: BaseResolver; }) { - releaseAjvInstance(); // FIXME: preprocessors can modify nodes which are then cached to ajv-instance by absolute path - const { document, customTypes, externalRefResolver, config } = opts; const specVersion = detectSpec(document.parsed); const specMajorVersion = getMajorSpecVersion(specVersion); diff --git a/packages/core/src/rules/__tests__/ajv.test.ts b/packages/core/src/rules/__tests__/ajv.test.ts index 140733d154..50acae1b0e 100644 --- a/packages/core/src/rules/__tests__/ajv.test.ts +++ b/packages/core/src/rules/__tests__/ajv.test.ts @@ -32,14 +32,14 @@ vi.mock('ajv-formats', () => { import { Location } from '../../ref-utils.js'; import type { Source } from '../../resolve.js'; -import { validateJsonSchema, releaseAjvInstance } from '../ajv.js'; +import { AjvValidator } from '../ajv.js'; -describe('ajv configuration', () => { +describe('AjvValidator', () => { const resolve = () => ({ node: undefined, location: undefined }); const baseLocation = createBaseLocation(); beforeEach(() => { - releaseAjvInstance(); + vi.clearAllMocks(); }); describe('dialect selection by specVersion', () => { @@ -47,9 +47,10 @@ describe('ajv configuration', () => { const mockAjvInstance = createMockAjvInstance(); mockAjvDraft4Constructor.mockReturnValue(mockAjvInstance); + const validator = new AjvValidator(); const schema = { type: 'integer' }; - validateJsonSchema(10, schema, { + validator.validate(10, schema, { schemaLoc: baseLocation, instancePath: '/example', resolve, @@ -68,9 +69,10 @@ describe('ajv configuration', () => { const mockAjvInstance = createMockAjvInstance(); mockAjv2020Constructor.mockReturnValue(mockAjvInstance); + const validator = new AjvValidator(); const schema = { type: 'integer' }; - validateJsonSchema(10, schema, { + validator.validate(10, schema, { schemaLoc: baseLocation, instancePath: '/example', resolve, @@ -91,9 +93,10 @@ describe('ajv configuration', () => { const mockAjvInstance = createMockAjvInstance(); mockAjvDraft4Constructor.mockReturnValue(mockAjvInstance); + const validator = new AjvValidator(); const schema = { type: 'string' }; - validateJsonSchema('test', schema, { + validator.validate('test', schema, { schemaLoc: baseLocation, instancePath: '/example', resolve, @@ -111,9 +114,10 @@ describe('ajv configuration', () => { const mockAjvInstance = createMockAjvInstance(); mockAjv2020Constructor.mockReturnValue(mockAjvInstance); + const validator = new AjvValidator(); const schema = { type: 'string' }; - validateJsonSchema('test', schema, { + validator.validate('test', schema, { schemaLoc: baseLocation, instancePath: '/example', resolve, @@ -130,9 +134,10 @@ describe('ajv configuration', () => { const mockAjvInstance = createMockAjvInstance(); mockAjvDraft4Constructor.mockReturnValue(mockAjvInstance); + const validator = new AjvValidator(); const schema = { type: 'string' }; - validateJsonSchema('test', schema, { + validator.validate('test', schema, { schemaLoc: baseLocation, instancePath: '/example', resolve, @@ -157,9 +162,10 @@ describe('ajv configuration', () => { }; mockAjvDraft4Constructor.mockReturnValue(mockAjvInstance); + const validator = new AjvValidator(); const schema = { type: 'integer' }; - validateJsonSchema(10, schema, { + validator.validate(10, schema, { schemaLoc: baseLocation, instancePath: '/example', resolve, @@ -183,9 +189,10 @@ describe('ajv configuration', () => { }; mockAjv2020Constructor.mockReturnValue(mockAjvInstance); + const validator = new AjvValidator(); const schema = { type: 'integer' }; - validateJsonSchema(10, schema, { + validator.validate(10, schema, { schemaLoc: baseLocation, instancePath: '/example', resolve, diff --git a/packages/core/src/rules/ajv.ts b/packages/core/src/rules/ajv.ts index 1f68b61a05..a04d9e3ca7 100644 --- a/packages/core/src/rules/ajv.ts +++ b/packages/core/src/rules/ajv.ts @@ -14,13 +14,6 @@ import type { ResolveFn } from '../walk.js'; type AjvDialect = '2020' | 'draft4'; -const ajvInstances: Partial> = {}; - -export function releaseAjvInstance() { - ajvInstances['2020'] = undefined; - ajvInstances['draft4'] = undefined; -} - function getSchemaIdKey(dialect: AjvDialect) { return dialect === 'draft4' ? 'id' : '$id'; } @@ -30,127 +23,137 @@ function getDialectBySpecVersion(specVersion: SpecVersion): AjvDialect { return '2020'; } -function getAjv(resolve: ResolveFn, dialect: AjvDialect): any { - if (!ajvInstances[dialect]) { - const schemaIdKey = getSchemaIdKey(dialect); - - const options: Options = { - schemaId: schemaIdKey, - meta: true, - allErrors: true, - strictSchema: false, - inlineRefs: false, - validateSchema: false, - discriminator: true, - allowUnionTypes: true, - validateFormats: true, - passContext: true, - loadSchemaSync(base: string, $ref: string, $id: string) { - const decodedBase = decodeURI(base.split('#')[0]); - const resolvedRef = resolve({ $ref }, decodedBase); - if (!resolvedRef || !resolvedRef.location) return false; - - return { - [schemaIdKey]: encodeURI(resolvedRef.location.source.absoluteRef) + '#' + $id, - ...resolvedRef.node, - }; - }, - logger: false, +export class AjvValidator { + private instances: Partial> = {}; + + validate( + data: unknown, + schema: Oas3Schema | Oas3_1Schema, + options: { + schemaLoc: Location; + instancePath: string; + resolve: ResolveFn; + allowAdditionalProperties: boolean; + ajvContext?: AjvContext; + specVersion: SpecVersion; + } + ): { valid: boolean; errors: (ErrorObject & { suggest?: string[] })[] } { + const { schemaLoc, instancePath, resolve, allowAdditionalProperties, ajvContext, specVersion } = + options; + + const dialect = getDialectBySpecVersion(specVersion); + const validate = this.getValidator( + schema, + schemaLoc, + resolve, + allowAdditionalProperties, + dialect + ); + if (!validate) return { valid: true, errors: [] }; // unresolved refs are reported + + const dataCxt = { + instancePath, + parentData: { fake: {} }, + parentDataProperty: 'fake', + rootData: {}, + dynamicAnchors: {}, }; + const valid = validate.call(ajvContext ?? {}, data, dataCxt); - ajvInstances[dialect] = - dialect === '2020' ? new (Ajv2020 as any)(options) : new (AjvDraft4 as any)(options); - - (addFormats as any)(ajvInstances[dialect]); - } - return ajvInstances[dialect]; -} + return { + valid: !!valid, + errors: (validate.errors || []).map(beatifyErrorMessage), + }; -function getAjvValidator( - schema: Oas3Schema | Oas3_1Schema, - loc: Location, - resolve: ResolveFn, - allowAdditionalProperties: boolean, - dialect: AjvDialect -): ValidateFunction | undefined { - const ajv = getAjv(resolve, dialect); - const $id = encodeURI(loc.absolutePointer); - const schemaIdKey = getSchemaIdKey(dialect); - - if (!ajv.getSchema($id)) { - ajv.setDefaultUnevaluatedProperties(allowAdditionalProperties); - ajv.addSchema( - { - [schemaIdKey]: $id, - ...schema, - }, - $id - ); + function beatifyErrorMessage(error: ErrorObject) { + let message = error.message; + const suggest: string[] | undefined = + error.keyword === 'enum' ? error.params.allowedValues : undefined; + if (suggest) { + message += ` ${suggest.map((e) => `"${e}"`).join(', ')}`; + } + + if (error.keyword === 'type') { + message = `type ${message}`; + } + + const relativePath = error.instancePath.substring(instancePath.length + 1); + const propName = relativePath.substring(relativePath.lastIndexOf('/') + 1); + if (propName) { + message = `\`${propName}\` property ${message}`; + } + if (error.keyword === 'additionalProperties' || error.keyword === 'unevaluatedProperties') { + const property = error.params.additionalProperty || error.params.unevaluatedProperty; + message = `${message} \`${property}\``; + error.instancePath += '/' + escapePointerFragment(property); + } + + return { + ...error, + message, + suggest, + }; + } } - return ajv.getSchema($id); -} - -export function validateJsonSchema( - data: unknown, - schema: Oas3Schema | Oas3_1Schema, - options: { - schemaLoc: Location; - instancePath: string; - resolve: ResolveFn; - allowAdditionalProperties: boolean; - ajvContext?: AjvContext; - specVersion: SpecVersion; - } -): { valid: boolean; errors: (ErrorObject & { suggest?: string[] })[] } { - const { schemaLoc, instancePath, resolve, allowAdditionalProperties, ajvContext, specVersion } = - options; - - const dialect = getDialectBySpecVersion(specVersion); - const validate = getAjvValidator(schema, schemaLoc, resolve, allowAdditionalProperties, dialect); - if (!validate) return { valid: true, errors: [] }; // unresolved refs are reported - - const dataCxt = { - instancePath, - parentData: { fake: {} }, - parentDataProperty: 'fake', - rootData: {}, - dynamicAnchors: {}, - }; - const valid = validate.call(ajvContext ?? {}, data, dataCxt); - - return { - valid: !!valid, - errors: (validate.errors || []).map(beatifyErrorMessage), - }; - - function beatifyErrorMessage(error: ErrorObject) { - let message = error.message; - const suggest: string[] | undefined = - error.keyword === 'enum' ? error.params.allowedValues : undefined; - if (suggest) { - message += ` ${suggest.map((e) => `"${e}"`).join(', ')}`; + private getAjv(resolve: ResolveFn, dialect: AjvDialect): any { + if (!this.instances[dialect]) { + const schemaIdKey = getSchemaIdKey(dialect); + + const options: Options = { + schemaId: schemaIdKey, + meta: true, + allErrors: true, + strictSchema: false, + inlineRefs: false, + validateSchema: false, + discriminator: true, + allowUnionTypes: true, + validateFormats: true, + passContext: true, + loadSchemaSync(base: string, $ref: string, $id: string) { + const decodedBase = decodeURI(base.split('#')[0]); + const resolvedRef = resolve({ $ref }, decodedBase); + if (!resolvedRef || !resolvedRef.location) return false; + + return { + [schemaIdKey]: encodeURI(resolvedRef.location.source.absoluteRef) + '#' + $id, + ...resolvedRef.node, + }; + }, + logger: false, + }; + + this.instances[dialect] = + dialect === '2020' ? new (Ajv2020 as any)(options) : new (AjvDraft4 as any)(options); + + (addFormats as any)(this.instances[dialect]); } + return this.instances[dialect]; + } - if (error.keyword === 'type') { - message = `type ${message}`; - } + private getValidator( + schema: Oas3Schema | Oas3_1Schema, + loc: Location, + resolve: ResolveFn, + allowAdditionalProperties: boolean, + dialect: AjvDialect + ): ValidateFunction | undefined { + const ajv = this.getAjv(resolve, dialect); + const $id = encodeURI(loc.absolutePointer); + const schemaIdKey = getSchemaIdKey(dialect); - const relativePath = error.instancePath.substring(instancePath.length + 1); - const propName = relativePath.substring(relativePath.lastIndexOf('/') + 1); - if (propName) { - message = `\`${propName}\` property ${message}`; - } - if (error.keyword === 'additionalProperties' || error.keyword === 'unevaluatedProperties') { - const property = error.params.additionalProperty || error.params.unevaluatedProperty; - message = `${message} \`${property}\``; - error.instancePath += '/' + escapePointerFragment(property); + if (!ajv.getSchema($id)) { + ajv.setDefaultUnevaluatedProperties(allowAdditionalProperties); + ajv.addSchema( + { + [schemaIdKey]: $id, + ...schema, + }, + $id + ); } - return { - ...error, - message, - suggest, - }; + return ajv.getSchema($id); } } diff --git a/packages/core/src/rules/common/no-invalid-parameter-examples.ts b/packages/core/src/rules/common/no-invalid-parameter-examples.ts index 6434618853..ecd4b85d15 100644 --- a/packages/core/src/rules/common/no-invalid-parameter-examples.ts +++ b/packages/core/src/rules/common/no-invalid-parameter-examples.ts @@ -3,9 +3,11 @@ import { isDefined } from '../../utils/is-defined.js'; import { isPlainObject } from '../../utils/is-plain-object.js'; import type { Oas2Rule, Oas3Rule } from '../../visitors.js'; import type { UserContext } from '../../walk.js'; +import { AjvValidator } from '../ajv.js'; import { validateExample } from '../utils.js'; export const NoInvalidParameterExamples: Oas3Rule | Oas2Rule = (opts) => { + const validator = new AjvValidator(); return { Parameter: { leave(parameter: Oas3Parameter, ctx: UserContext) { @@ -13,6 +15,7 @@ export const NoInvalidParameterExamples: Oas3Rule | Oas2Rule = (opts) => { validateExample(parameter.example, parameter.schema!, { location: ctx.location.child('example'), ctx, + validator, allowAdditionalProperties: !!opts.allowAdditionalProperties, ajvContext: { apiContext: 'request' }, }); @@ -24,6 +27,7 @@ export const NoInvalidParameterExamples: Oas3Rule | Oas2Rule = (opts) => { validateExample(example.value, parameter.schema!, { location: ctx.location.child(['examples', key]), ctx, + validator, allowAdditionalProperties: !!opts.allowAdditionalProperties, ajvContext: { apiContext: 'request' }, }); diff --git a/packages/core/src/rules/common/no-invalid-schema-examples.ts b/packages/core/src/rules/common/no-invalid-schema-examples.ts index 6d431c3992..ff920eab45 100644 --- a/packages/core/src/rules/common/no-invalid-schema-examples.ts +++ b/packages/core/src/rules/common/no-invalid-schema-examples.ts @@ -2,9 +2,11 @@ import type { Oas3_1Schema, Oas3Schema } from '../../typings/openapi.js'; import { isDefined } from '../../utils/is-defined.js'; import type { Oas2Rule, Oas3Rule } from '../../visitors.js'; import type { UserContext } from '../../walk.js'; +import { AjvValidator } from '../ajv.js'; import { validateExample } from '../utils.js'; export const NoInvalidSchemaExamples: Oas3Rule | Oas2Rule = (opts) => { + const validator = new AjvValidator(); return { Schema: { leave(schema: Oas3_1Schema | Oas3Schema, ctx: UserContext) { @@ -15,6 +17,7 @@ export const NoInvalidSchemaExamples: Oas3Rule | Oas2Rule = (opts) => { validateExample(example, schema, { location: ctx.location.child(['examples', examples.indexOf(example)]), ctx, + validator, allowAdditionalProperties: !!opts.allowAdditionalProperties, }); } @@ -33,6 +36,7 @@ export const NoInvalidSchemaExamples: Oas3Rule | Oas2Rule = (opts) => { validateExample(schema.example, schema, { location: ctx.location.child('example'), ctx, + validator, allowAdditionalProperties: !!opts.allowAdditionalProperties, }); } diff --git a/packages/core/src/rules/oas3/no-invalid-media-type-examples.ts b/packages/core/src/rules/oas3/no-invalid-media-type-examples.ts index a5695653d5..c7604bb859 100644 --- a/packages/core/src/rules/oas3/no-invalid-media-type-examples.ts +++ b/packages/core/src/rules/oas3/no-invalid-media-type-examples.ts @@ -6,9 +6,12 @@ import { isDefined } from '../../utils/is-defined.js'; import { isPlainObject } from '../../utils/is-plain-object.js'; import type { Oas3Rule } from '../../visitors.js'; import type { UserContext } from '../../walk.js'; +import { AjvValidator } from '../ajv.js'; import { validateExample } from '../utils.js'; export const ValidContentExamples: Oas3Rule = (opts) => { + const validator = new AjvValidator(); + const skip = (mediaType: Oas3MediaType) => { return mediaType.schema === undefined; }; @@ -45,6 +48,7 @@ export const ValidContentExamples: Oas3Rule = (opts) => { validateExample(isMultiple ? example.value : example, mediaType.schema!, { location, ctx, + validator, allowAdditionalProperties: !!opts.allowAdditionalProperties, ajvContext: context, }); diff --git a/packages/core/src/rules/utils.ts b/packages/core/src/rules/utils.ts index b2b8fad771..4497ceedde 100644 --- a/packages/core/src/rules/utils.ts +++ b/packages/core/src/rules/utils.ts @@ -12,7 +12,7 @@ import type { import type { Oas2Tag } from '../typings/swagger.js'; import { isPlainObject } from '../utils/is-plain-object.js'; import type { NonUndefined, UserContext } from '../walk.js'; -import { validateJsonSchema } from './ajv.js'; +import type { AjvValidator } from './ajv.js'; export const resolveSchema = ( schemaOrRef: Referenced | undefined, @@ -154,14 +154,15 @@ export function validateExample( options: { location: Location; ctx: UserContext; + validator: AjvValidator; allowAdditionalProperties: boolean; ajvContext?: AjvContext; } ) { - const { location, ctx, allowAdditionalProperties, ajvContext } = options; + const { location, ctx, validator, allowAdditionalProperties, ajvContext } = options; const { resolve, location: parentLocation, report, specVersion } = ctx; try { - const { valid, errors } = validateJsonSchema(example, schema, { + const { valid, errors } = validator.validate(example, schema, { schemaLoc: parentLocation.child('schema'), instancePath: location.pointer, resolve,