From 02ec68f95a39c5560549f620cc2d480102677cc0 Mon Sep 17 00:00:00 2001 From: cyphercodes Date: Fri, 14 Aug 2026 18:29:02 +0300 Subject: [PATCH] fix(http): honor OAS 3.1 schema dialect defaults --- .../validators/__tests__/utils.spec.ts | 71 +++++++++++++++++-- .../http/src/validator/validators/utils.ts | 42 ++++++++--- 2 files changed, 98 insertions(+), 15 deletions(-) diff --git a/packages/http/src/validator/validators/__tests__/utils.spec.ts b/packages/http/src/validator/validators/__tests__/utils.spec.ts index e91616fe6..b728dacd1 100644 --- a/packages/http/src/validator/validators/__tests__/utils.spec.ts +++ b/packages/http/src/validator/validators/__tests__/utils.spec.ts @@ -51,17 +51,19 @@ describe('convertAjvErrors()', () => { it('converts properly', () => { expect( convertAjvErrors( - [Object.assign({}, errorObjectFixture, { - params: { unevaluatedProperty: 'd' }, - keyword: 'unevaluatedProperties', - message: 'must NOT have unevaluated propertes', - })], + [ + Object.assign({}, errorObjectFixture, { + params: { unevaluatedProperty: 'd' }, + keyword: 'unevaluatedProperties', + message: 'must NOT have unevaluated propertes', + }), + ], DiagnosticSeverity.Error, ValidationContext.Input )[0] ).toHaveProperty('message', "Request parameter a.b must NOT have unevaluated propertes: 'd'"); }); - }); + }); }); describe('validateAgainstSchema()', () => { @@ -224,6 +226,63 @@ describe('validateAgainstSchema()', () => { }); }); + describe('uses OpenAPI 3.1 JSON Schema dialect defaults', () => { + const schemaWithUnevaluatedProperties = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + unevaluatedProperties: false, + } as JSONSchema7; + + it('uses the document-level jsonSchemaDialect when a schema has no $schema URI', () => { + assertSome( + validateAgainstSchema( + { name: 'Ada', extra: true }, + schemaWithUnevaluatedProperties, + false, + ValidationContext.Output, + 'body', + { openapi: '3.1.0', jsonSchemaDialect: 'https://json-schema.org/draft/2020-12/schema' } + ), + error => + expect(error).toContainEqual( + expect.objectContaining({ + code: 'unevaluatedProperties', + message: "Response body must NOT have unevaluated properties: 'extra'", + path: ['body'], + }) + ) + ); + }); + + it('defaults OpenAPI 3.1 documents to JSON Schema 2020-12', () => { + assertSome( + validateAgainstSchema( + { name: 'Ada', extra: true }, + schemaWithUnevaluatedProperties, + false, + ValidationContext.Output, + 'body', + { openapi: '3.1.0' } + ), + error => expect(error).toContainEqual(expect.objectContaining({ code: 'unevaluatedProperties' })) + ); + }); + + it('keeps using draft-07 when neither $schema nor OpenAPI 3.1 dialect information is available', () => { + assertNone( + validateAgainstSchema( + { name: 'Ada', extra: true }, + schemaWithUnevaluatedProperties, + false, + ValidationContext.Output, + 'body' + ) + ); + }); + }); + describe('with coercing', () => { it('will not return error for convertible values', () => { assertNone( diff --git a/packages/http/src/validator/validators/utils.ts b/packages/http/src/validator/validators/utils.ts index 4c8cbc2f2..afa12fa4a 100644 --- a/packages/http/src/validator/validators/utils.ts +++ b/packages/http/src/validator/validators/utils.ts @@ -57,18 +57,42 @@ const ajvInstances = { const JSON_SCHEMA_DRAFT_2019_09 = /^https?:\/\/json-schema.org\/draft\/2019-09\/schema#?$/; const JSON_SCHEMA_DRAFT_2020_12 = /^https?:\/\/json-schema.org\/draft\/2020-12\/schema#?$/; +const OAS_3_1 = /^3\.1(?:\.|$)/; -function assignAjvInstance($schema: string, coerce: boolean): AjvCore { - const member = coerce ? 'coerce' : 'noCoerce'; - let draft: keyof typeof ajvInstances = 'default'; +type JsonSchemaDraft = keyof typeof ajvInstances; + +function selectJsonSchemaDraft($schema?: string, bundle?: unknown): JsonSchemaDraft { + if ($schema) { + if (JSON_SCHEMA_DRAFT_2019_09.test($schema)) { + return 'draft2019_09'; + } + + if (JSON_SCHEMA_DRAFT_2020_12.test($schema)) { + return 'draft2020_12'; + } - if (JSON_SCHEMA_DRAFT_2019_09.test($schema)) { - draft = 'draft2019_09'; - } else if (JSON_SCHEMA_DRAFT_2020_12.test($schema)) { - draft = 'draft2020_12'; + return 'default'; } - return ajvInstances[draft][member]; + if (typeof bundle === 'object' && bundle !== null) { + const { jsonSchemaDialect, openapi } = bundle as { jsonSchemaDialect?: unknown; openapi?: unknown }; + + if (typeof jsonSchemaDialect === 'string') { + return selectJsonSchemaDraft(jsonSchemaDialect); + } + + if (typeof openapi === 'string' && OAS_3_1.test(openapi)) { + return 'draft2020_12'; + } + } + + return 'default'; +} + +function assignAjvInstance($schema: string | undefined, coerce: boolean, bundle?: unknown): AjvCore { + const member = coerce ? 'coerce' : 'noCoerce'; + + return ajvInstances[selectJsonSchemaDraft($schema, bundle)][member]; } export const convertAjvErrors = ( @@ -139,7 +163,7 @@ export const validateAgainstSchema = ( bundle?: unknown ): O.Option> => pipe( - O.tryCatch(() => getValidationFunction(assignAjvInstance(String(schema.$schema), coerce), schema, bundle)), + O.tryCatch(() => getValidationFunction(assignAjvInstance(schema.$schema, coerce, bundle), 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, context, prefix))