From 98b06056bda79be6083e0c121a34aa6ee224db3e Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 24 Mar 2026 14:28:48 +0100 Subject: [PATCH 1/5] Support $ref with sibling keywords (OpenAPI 3.1) In OpenAPI 3.1, $ref can have sibling keywords like maxLength, minLength, pattern, etc. Merge sibling constraints into the resolved schema so they are compiled into the generated validator. --- src/compileValueSchema.ts | 19 +++- .../compileValueSchema.test.ts.snap | 104 ++++++++++++++++++ src/tests/compileValueSchema.test.ts | 60 ++++++++++ 3 files changed, 182 insertions(+), 1 deletion(-) diff --git a/src/compileValueSchema.ts b/src/compileValueSchema.ts index 7d41084..3db6bd2 100644 --- a/src/compileValueSchema.ts +++ b/src/compileValueSchema.ts @@ -40,7 +40,24 @@ export function compileValueSchema( schema: OpenAPIValueSchema, ): namedTypes.Identifier { if ('$ref' in schema) { - return compileValueSchema(compiler, compiler.resolveRef(schema)); + const resolved = compiler.resolveRef(schema); + const { $ref, ...constraints } = schema; + if (Object.keys(constraints).length > 0) { + // Apply the constraints to the additionalProperties schema if it exists + if ( + resolved.type === 'object' && + typeof resolved.additionalProperties === 'object' + ) { + return compileValueSchema(compiler, { + ...resolved, + additionalProperties: { ...resolved.additionalProperties, ...constraints }, + }); + } + // Otherwise merge directly into the resolved schema + return compileValueSchema(compiler, { ...resolved, ...constraints }); + } + + return compileValueSchema(compiler, resolved); } if ('anyOf' in schema) { diff --git a/src/tests/__snapshots__/compileValueSchema.test.ts.snap b/src/tests/__snapshots__/compileValueSchema.test.ts.snap index 7b8644b..46dfda9 100644 --- a/src/tests/__snapshots__/compileValueSchema.test.ts.snap +++ b/src/tests/__snapshots__/compileValueSchema.test.ts.snap @@ -2007,3 +2007,107 @@ function obj0(path, value, context) { return value; }" `; + +exports[`OpenAPI 3.1 $ref with sibling keywords $ref with maxLength 1`] = ` +"/** +Validate a request against the OpenAPI spec +@param {{ method: string; path: string; body?: any; query: Record; headers: Record; }} request - Input request to validate +@param {{ stringFormats?: { [format: string]: (value: string, path: string[]) => ValidationError | string | null } }} [context] - Context object to pass to validation functions +@returns {{ operationId?: string; params: Record; query: Record; body?: any; headers: Record; }} +*/ +export function validateRequest(request, context) { + return new RequestError(404, 'no operation match path'); +} +/** +Map of all components defined in the spec to their validation functions. +{Object.(path: string[], value: T, context: any) => (T | ValidationError)>} +*/ +export const componentSchemas = { 'MyString': obj1 }; +export class RequestError extends Error { + /** @param {number} code HTTP code for the error +@param {string} message The error message*/ + constructor(code, message) { + super(message); + /** @type {number} HTTP code for the error*/ + this.code = code; + } +} +export class ValidationError extends RequestError { + /** @param {string[]} path The path that failed validation +@param {string} message The error message*/ + constructor(path, message) { + super(409, message); + /** @type {string[]} The path that failed validation*/ + this.path = path; + } +} +function obj0(path, value, context) { + if (typeof value !== 'string') { + return new ValidationError(path, 'expected a string'); + } + if (value.length > 64) { + return new ValidationError(path, 'expected at most 64 characters'); + } + return value; +} +function obj1(path, value, context) { + if (typeof value !== 'string') { + return new ValidationError(path, 'expected a string'); + } + return value; +}" +`; + +exports[`OpenAPI 3.1 $ref with sibling keywords $ref with minLength and pattern 1`] = ` +"/** +Validate a request against the OpenAPI spec +@param {{ method: string; path: string; body?: any; query: Record; headers: Record; }} request - Input request to validate +@param {{ stringFormats?: { [format: string]: (value: string, path: string[]) => ValidationError | string | null } }} [context] - Context object to pass to validation functions +@returns {{ operationId?: string; params: Record; query: Record; body?: any; headers: Record; }} +*/ +export function validateRequest(request, context) { + return new RequestError(404, 'no operation match path'); +} +/** +Map of all components defined in the spec to their validation functions. +{Object.(path: string[], value: T, context: any) => (T | ValidationError)>} +*/ +export const componentSchemas = { 'MyString': obj2 }; +export class RequestError extends Error { + /** @param {number} code HTTP code for the error +@param {string} message The error message*/ + constructor(code, message) { + super(message); + /** @type {number} HTTP code for the error*/ + this.code = code; + } +} +export class ValidationError extends RequestError { + /** @param {string[]} path The path that failed validation +@param {string} message The error message*/ + constructor(path, message) { + super(409, message); + /** @type {string[]} The path that failed validation*/ + this.path = path; + } +} +const obj1 = new RegExp('^[a-z]+$'); +function obj0(path, value, context) { + if (typeof value !== 'string') { + return new ValidationError(path, 'expected a string'); + } + if (value.length < 1) { + return new ValidationError(path, 'expected at least 1 characters'); + } + if (!obj1.test(value)) { + return new ValidationError(path, 'expected to match the pattern "^[a-z]+$"'); + } + return value; +} +function obj2(path, value, context) { + if (typeof value !== 'string') { + return new ValidationError(path, 'expected a string'); + } + return value; +}" +`; diff --git a/src/tests/compileValueSchema.test.ts b/src/tests/compileValueSchema.test.ts index 2a000ad..944ed4f 100644 --- a/src/tests/compileValueSchema.test.ts +++ b/src/tests/compileValueSchema.test.ts @@ -419,4 +419,64 @@ describe('OpenAPI 3.1', () => { expect(compiler.compile()).toMatchSnapshot(); }); }); + + describe('$ref with sibling keywords', () => { + test('$ref with maxLength', () => { + const compiler = new Compiler({ + components: { + schemas: { + MyString: { type: 'string' }, + }, + }, + }); + compileValueSchema(compiler, { + $ref: '#/components/schemas/MyString', + maxLength: 64, + } as any); + expect(compiler.compile()).toMatchSnapshot(); + }); + + test('$ref with minLength and pattern', () => { + const compiler = new Compiler({ + components: { + schemas: { + MyString: { type: 'string' }, + }, + }, + }); + compileValueSchema(compiler, { + $ref: '#/components/schemas/MyString', + minLength: 1, + pattern: '^[a-z]+$', + } as any); + expect(compiler.compile()).toMatchSnapshot(); + }); + + test('$ref with only description does not change output', () => { + const compiler = new Compiler({ + components: { + schemas: { + MyString: { type: 'string' }, + }, + }, + }); + compileValueSchema(compiler, { + $ref: '#/components/schemas/MyString', + description: 'A localized title', + } as any); + + const compilerPlain = new Compiler({ + components: { + schemas: { + MyString: { type: 'string' }, + }, + }, + }); + compileValueSchema(compilerPlain, { + $ref: '#/components/schemas/MyString', + }); + + expect(compiler.compile()).toEqual(compilerPlain.compile()); + }); + }); }); From 0db87c8f9d9b72c60e488349b226de6172cc4888 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 24 Mar 2026 15:11:51 +0100 Subject: [PATCH 2/5] fix format --- src/compileValueSchema.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/compileValueSchema.ts b/src/compileValueSchema.ts index 3db6bd2..6e0e4cb 100644 --- a/src/compileValueSchema.ts +++ b/src/compileValueSchema.ts @@ -44,10 +44,7 @@ export function compileValueSchema( const { $ref, ...constraints } = schema; if (Object.keys(constraints).length > 0) { // Apply the constraints to the additionalProperties schema if it exists - if ( - resolved.type === 'object' && - typeof resolved.additionalProperties === 'object' - ) { + if (resolved.type === 'object' && typeof resolved.additionalProperties === 'object') { return compileValueSchema(compiler, { ...resolved, additionalProperties: { ...resolved.additionalProperties, ...constraints }, From b0c826586b40e0d0d419fd27abd74ec5776da3a3 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 24 Mar 2026 15:32:05 +0100 Subject: [PATCH 3/5] Apply constraint to items --- src/compileValueSchema.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/compileValueSchema.ts b/src/compileValueSchema.ts index 6e0e4cb..cf01c48 100644 --- a/src/compileValueSchema.ts +++ b/src/compileValueSchema.ts @@ -50,6 +50,15 @@ export function compileValueSchema( additionalProperties: { ...resolved.additionalProperties, ...constraints }, }); } + + // Apply the constraints to the items schema if it exists + if (resolved.type === 'array' && typeof resolved.items === 'object') { + return compileValueSchema(compiler, { + ...resolved, + items: { ...resolved.items, ...constraints }, + }); + } + // Otherwise merge directly into the resolved schema return compileValueSchema(compiler, { ...resolved, ...constraints }); } From 824445a711ee16d951542e4cf2e5b6dd6107a3c7 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 24 Mar 2026 15:32:28 +0100 Subject: [PATCH 4/5] constraints -> siblings --- src/compileValueSchema.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/compileValueSchema.ts b/src/compileValueSchema.ts index cf01c48..3dd2dd7 100644 --- a/src/compileValueSchema.ts +++ b/src/compileValueSchema.ts @@ -41,26 +41,26 @@ export function compileValueSchema( ): namedTypes.Identifier { if ('$ref' in schema) { const resolved = compiler.resolveRef(schema); - const { $ref, ...constraints } = schema; - if (Object.keys(constraints).length > 0) { - // Apply the constraints to the additionalProperties schema if it exists + const { $ref, ...siblings } = schema; + if (Object.keys(siblings).length > 0) { + // Apply the siblings to the additionalProperties schema if it exists if (resolved.type === 'object' && typeof resolved.additionalProperties === 'object') { return compileValueSchema(compiler, { ...resolved, - additionalProperties: { ...resolved.additionalProperties, ...constraints }, + additionalProperties: { ...resolved.additionalProperties, ...siblings }, }); } - // Apply the constraints to the items schema if it exists + // Apply the siblings to the items schema if it exists if (resolved.type === 'array' && typeof resolved.items === 'object') { return compileValueSchema(compiler, { ...resolved, - items: { ...resolved.items, ...constraints }, + items: { ...resolved.items, ...siblings }, }); } // Otherwise merge directly into the resolved schema - return compileValueSchema(compiler, { ...resolved, ...constraints }); + return compileValueSchema(compiler, { ...resolved, ...siblings }); } return compileValueSchema(compiler, resolved); From 87ba5bd1bbceb1b650ac69df4d6add19e9c992a4 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 24 Mar 2026 15:34:14 +0100 Subject: [PATCH 5/5] update tests --- .../compileValueSchema.test.ts.snap | 343 ++++++++++++++++++ src/tests/compileValueSchema.test.ts | 75 ++++ 2 files changed, 418 insertions(+) diff --git a/src/tests/__snapshots__/compileValueSchema.test.ts.snap b/src/tests/__snapshots__/compileValueSchema.test.ts.snap index 46dfda9..256f5f4 100644 --- a/src/tests/__snapshots__/compileValueSchema.test.ts.snap +++ b/src/tests/__snapshots__/compileValueSchema.test.ts.snap @@ -2111,3 +2111,346 @@ function obj2(path, value, context) { return value; }" `; + +exports[`OpenAPI 3.1 $ref with sibling keywords $ref with additionalProperties merges constraints into additionalProperties 1`] = ` +"/** +Validate a request against the OpenAPI spec +@param {{ method: string; path: string; body?: any; query: Record; headers: Record; }} request - Input request to validate +@param {{ stringFormats?: { [format: string]: (value: string, path: string[]) => ValidationError | string | null } }} [context] - Context object to pass to validation functions +@returns {{ operationId?: string; params: Record; query: Record; body?: any; headers: Record; }} +*/ +export function validateRequest(request, context) { + return new RequestError(404, 'no operation match path'); +} +/** +Map of all components defined in the spec to their validation functions. +{Object.(path: string[], value: T, context: any) => (T | ValidationError)>} +*/ +export const componentSchemas = { 'StringMap': obj2 }; +export class RequestError extends Error { + /** @param {number} code HTTP code for the error +@param {string} message The error message*/ + constructor(code, message) { + super(message); + /** @type {number} HTTP code for the error*/ + this.code = code; + } +} +export class ValidationError extends RequestError { + /** @param {string[]} path The path that failed validation +@param {string} message The error message*/ + constructor(path, message) { + super(409, message); + /** @type {string[]} The path that failed validation*/ + this.path = path; + } +} +function obj1(path, value, context) { + if (typeof value !== 'string') { + return new ValidationError(path, 'expected a string'); + } + if (value.length > 64) { + return new ValidationError(path, 'expected at most 64 characters'); + } + return value; +} +function obj0(path, value, context) { + if (typeof value !== 'object' || value === null) { + return new ValidationError(path, 'expected an object'); + } + const keys = new Set(Object.keys(value)); + for (const key of keys) { + const result = obj1([ + ...path, + key + ], value[key], context); + if (result instanceof ValidationError) { + return result; + } + value[key] = result; + } + return value; +} +function obj3(path, value, context) { + if (typeof value !== 'string') { + return new ValidationError(path, 'expected a string'); + } + return value; +} +function obj2(path, value, context) { + if (typeof value !== 'object' || value === null) { + return new ValidationError(path, 'expected an object'); + } + const keys = new Set(Object.keys(value)); + for (const key of keys) { + const result = obj3([ + ...path, + key + ], value[key], context); + if (result instanceof ValidationError) { + return result; + } + value[key] = result; + } + return value; +}" +`; + +exports[`OpenAPI 3.1 $ref with sibling keywords $ref with additionalProperties merges multiple constraints 1`] = ` +"/** +Validate a request against the OpenAPI spec +@param {{ method: string; path: string; body?: any; query: Record; headers: Record; }} request - Input request to validate +@param {{ stringFormats?: { [format: string]: (value: string, path: string[]) => ValidationError | string | null } }} [context] - Context object to pass to validation functions +@returns {{ operationId?: string; params: Record; query: Record; body?: any; headers: Record; }} +*/ +export function validateRequest(request, context) { + return new RequestError(404, 'no operation match path'); +} +/** +Map of all components defined in the spec to their validation functions. +{Object.(path: string[], value: T, context: any) => (T | ValidationError)>} +*/ +export const componentSchemas = { 'StringMap': obj3 }; +export class RequestError extends Error { + /** @param {number} code HTTP code for the error +@param {string} message The error message*/ + constructor(code, message) { + super(message); + /** @type {number} HTTP code for the error*/ + this.code = code; + } +} +export class ValidationError extends RequestError { + /** @param {string[]} path The path that failed validation +@param {string} message The error message*/ + constructor(path, message) { + super(409, message); + /** @type {string[]} The path that failed validation*/ + this.path = path; + } +} +const obj2 = new RegExp('^[a-z]+$'); +function obj1(path, value, context) { + if (typeof value !== 'string') { + return new ValidationError(path, 'expected a string'); + } + if (value.length < 1) { + return new ValidationError(path, 'expected at least 1 characters'); + } + if (!obj2.test(value)) { + return new ValidationError(path, 'expected to match the pattern "^[a-z]+$"'); + } + return value; +} +function obj0(path, value, context) { + if (typeof value !== 'object' || value === null) { + return new ValidationError(path, 'expected an object'); + } + const keys = new Set(Object.keys(value)); + for (const key of keys) { + const result = obj1([ + ...path, + key + ], value[key], context); + if (result instanceof ValidationError) { + return result; + } + value[key] = result; + } + return value; +} +function obj4(path, value, context) { + if (typeof value !== 'string') { + return new ValidationError(path, 'expected a string'); + } + return value; +} +function obj3(path, value, context) { + if (typeof value !== 'object' || value === null) { + return new ValidationError(path, 'expected an object'); + } + const keys = new Set(Object.keys(value)); + for (const key of keys) { + const result = obj4([ + ...path, + key + ], value[key], context); + if (result instanceof ValidationError) { + return result; + } + value[key] = result; + } + return value; +}" +`; + +exports[`OpenAPI 3.1 $ref with sibling keywords $ref with items merges constraints into items 1`] = ` +"/** +Validate a request against the OpenAPI spec +@param {{ method: string; path: string; body?: any; query: Record; headers: Record; }} request - Input request to validate +@param {{ stringFormats?: { [format: string]: (value: string, path: string[]) => ValidationError | string | null } }} [context] - Context object to pass to validation functions +@returns {{ operationId?: string; params: Record; query: Record; body?: any; headers: Record; }} +*/ +export function validateRequest(request, context) { + return new RequestError(404, 'no operation match path'); +} +/** +Map of all components defined in the spec to their validation functions. +{Object.(path: string[], value: T, context: any) => (T | ValidationError)>} +*/ +export const componentSchemas = { 'StringArray': obj2 }; +export class RequestError extends Error { + /** @param {number} code HTTP code for the error +@param {string} message The error message*/ + constructor(code, message) { + super(message); + /** @type {number} HTTP code for the error*/ + this.code = code; + } +} +export class ValidationError extends RequestError { + /** @param {string[]} path The path that failed validation +@param {string} message The error message*/ + constructor(path, message) { + super(409, message); + /** @type {string[]} The path that failed validation*/ + this.path = path; + } +} +function obj1(path, value, context) { + if (typeof value !== 'string') { + return new ValidationError(path, 'expected a string'); + } + if (value.length < 1) { + return new ValidationError(path, 'expected at least 1 characters'); + } + return value; +} +function obj0(path, value, context) { + if (!Array.isArray(value)) { + return new ValidationError(path, 'expected an array'); + } + for (let i = 0; i < value.length; i++) { + const itemResult = obj1([ + ...path, + i + ], value[i], context); + if (itemResult instanceof ValidationError) { + return itemResult; + } + value[i] = itemResult; + } + return value; +} +function obj3(path, value, context) { + if (typeof value !== 'string') { + return new ValidationError(path, 'expected a string'); + } + return value; +} +function obj2(path, value, context) { + if (!Array.isArray(value)) { + return new ValidationError(path, 'expected an array'); + } + for (let i = 0; i < value.length; i++) { + const itemResult = obj3([ + ...path, + i + ], value[i], context); + if (itemResult instanceof ValidationError) { + return itemResult; + } + value[i] = itemResult; + } + return value; +}" +`; + +exports[`OpenAPI 3.1 $ref with sibling keywords $ref with items merges multiple constraints 1`] = ` +"/** +Validate a request against the OpenAPI spec +@param {{ method: string; path: string; body?: any; query: Record; headers: Record; }} request - Input request to validate +@param {{ stringFormats?: { [format: string]: (value: string, path: string[]) => ValidationError | string | null } }} [context] - Context object to pass to validation functions +@returns {{ operationId?: string; params: Record; query: Record; body?: any; headers: Record; }} +*/ +export function validateRequest(request, context) { + return new RequestError(404, 'no operation match path'); +} +/** +Map of all components defined in the spec to their validation functions. +{Object.(path: string[], value: T, context: any) => (T | ValidationError)>} +*/ +export const componentSchemas = { 'StringArray': obj3 }; +export class RequestError extends Error { + /** @param {number} code HTTP code for the error +@param {string} message The error message*/ + constructor(code, message) { + super(message); + /** @type {number} HTTP code for the error*/ + this.code = code; + } +} +export class ValidationError extends RequestError { + /** @param {string[]} path The path that failed validation +@param {string} message The error message*/ + constructor(path, message) { + super(409, message); + /** @type {string[]} The path that failed validation*/ + this.path = path; + } +} +const obj2 = new RegExp('^[a-z]+$'); +function obj1(path, value, context) { + if (typeof value !== 'string') { + return new ValidationError(path, 'expected a string'); + } + if (value.length < 1) { + return new ValidationError(path, 'expected at least 1 characters'); + } + if (value.length > 100) { + return new ValidationError(path, 'expected at most 100 characters'); + } + if (!obj2.test(value)) { + return new ValidationError(path, 'expected to match the pattern "^[a-z]+$"'); + } + return value; +} +function obj0(path, value, context) { + if (!Array.isArray(value)) { + return new ValidationError(path, 'expected an array'); + } + for (let i = 0; i < value.length; i++) { + const itemResult = obj1([ + ...path, + i + ], value[i], context); + if (itemResult instanceof ValidationError) { + return itemResult; + } + value[i] = itemResult; + } + return value; +} +function obj4(path, value, context) { + if (typeof value !== 'string') { + return new ValidationError(path, 'expected a string'); + } + return value; +} +function obj3(path, value, context) { + if (!Array.isArray(value)) { + return new ValidationError(path, 'expected an array'); + } + for (let i = 0; i < value.length; i++) { + const itemResult = obj4([ + ...path, + i + ], value[i], context); + if (itemResult instanceof ValidationError) { + return itemResult; + } + value[i] = itemResult; + } + return value; +}" +`; diff --git a/src/tests/compileValueSchema.test.ts b/src/tests/compileValueSchema.test.ts index 944ed4f..c483317 100644 --- a/src/tests/compileValueSchema.test.ts +++ b/src/tests/compileValueSchema.test.ts @@ -452,6 +452,81 @@ describe('OpenAPI 3.1', () => { expect(compiler.compile()).toMatchSnapshot(); }); + test('$ref with additionalProperties merges constraints into additionalProperties', () => { + const compiler = new Compiler({ + components: { + schemas: { + StringMap: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + }, + }, + }); + compileValueSchema(compiler, { + $ref: '#/components/schemas/StringMap', + maxLength: 64, + } as any); + expect(compiler.compile()).toMatchSnapshot(); + }); + + test('$ref with additionalProperties merges multiple constraints', () => { + const compiler = new Compiler({ + components: { + schemas: { + StringMap: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + }, + }, + }); + compileValueSchema(compiler, { + $ref: '#/components/schemas/StringMap', + minLength: 1, + pattern: '^[a-z]+$', + } as any); + expect(compiler.compile()).toMatchSnapshot(); + }); + + test('$ref with items merges constraints into items', () => { + const compiler = new Compiler({ + components: { + schemas: { + StringArray: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + }); + compileValueSchema(compiler, { + $ref: '#/components/schemas/StringArray', + minLength: 1, + } as any); + expect(compiler.compile()).toMatchSnapshot(); + }); + + test('$ref with items merges multiple constraints', () => { + const compiler = new Compiler({ + components: { + schemas: { + StringArray: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + }); + compileValueSchema(compiler, { + $ref: '#/components/schemas/StringArray', + minLength: 1, + maxLength: 100, + pattern: '^[a-z]+$', + } as any); + expect(compiler.compile()).toMatchSnapshot(); + }); + test('$ref with only description does not change output', () => { const compiler = new Compiler({ components: {