From 8771ee7a048fe47f23eb7607945a4ae4644ce098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Thu, 17 Jul 2025 15:43:39 +0200 Subject: [PATCH 1/4] Add failing test --- src/tests/compileValueSchema.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/tests/compileValueSchema.test.ts b/src/tests/compileValueSchema.test.ts index a86bef1..fcdc2b6 100644 --- a/src/tests/compileValueSchema.test.ts +++ b/src/tests/compileValueSchema.test.ts @@ -59,6 +59,15 @@ describe('Integer', () => { }); expect(compiler.compile()).toMatchSnapshot(); }); + + test('negative', () => { + const compiler = new Compiler(); + compileValueSchema(compiler, { + type: 'integer', + enum: [-1, 0, 1], + }); + expect(compiler.compile()).toMatchSnapshot(); + }); }); describe('Nullable', () => { From c577c85c125c9df02f740c2f047a8e164b6d1a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Thu, 17 Jul 2025 15:49:59 +0200 Subject: [PATCH 2/4] Fix enum with negative values --- src/compileValueSchema.ts | 6 ++- .../compileValueSchema.test.ts.snap | 41 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/compileValueSchema.ts b/src/compileValueSchema.ts index 6b39d20..e71c62e 100644 --- a/src/compileValueSchema.ts +++ b/src/compileValueSchema.ts @@ -987,7 +987,11 @@ function compileEnumableCheck( builders.ifStatement( schema.enum.reduce( (acc, val) => { - const test = builders.binaryExpression('!==', value, builders.literal(val)); + // Handle negative numbers by creating a unary expression instead of a negative literal + const literalValue = typeof val === 'number' && val < 0 + ? builders.unaryExpression('-', builders.literal(-val)) + : builders.literal(val); + const test = builders.binaryExpression('!==', value, literalValue); if (!acc) { return test; diff --git a/src/tests/__snapshots__/compileValueSchema.test.ts.snap b/src/tests/__snapshots__/compileValueSchema.test.ts.snap index f44bae8..e9b98d0 100644 --- a/src/tests/__snapshots__/compileValueSchema.test.ts.snap +++ b/src/tests/__snapshots__/compileValueSchema.test.ts.snap @@ -1254,3 +1254,44 @@ function obj0(path, value, context) { return result; }" `; + +exports[`Integer negative 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 = {}; +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 (value !== -1 && value !== 0 && value !== 1) { + return new ValidationError(path, 'expected one of the enum value'); + } + return value; +}" +`; From a1b791ea48dc932723564b2e2d52cf7340bb2a56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Thu, 17 Jul 2025 15:58:13 +0200 Subject: [PATCH 3/4] Improve it everywhere --- src/compileValueSchema.ts | 20 +- .../compileValueSchema.test.ts.snap | 376 ++++++++++++++++++ src/tests/compileValueSchema.test.ts | 76 ++++ 3 files changed, 466 insertions(+), 6 deletions(-) diff --git a/src/compileValueSchema.ts b/src/compileValueSchema.ts index e71c62e..ab8cf0c 100644 --- a/src/compileValueSchema.ts +++ b/src/compileValueSchema.ts @@ -17,6 +17,17 @@ import { } from './types'; import { ValidationErrorIdentifier } from './error'; +/** + * Creates a literal value that handles negative numbers properly for escodegen. + * For negative numbers, creates a unary expression instead of a negative literal. + */ +function createSafeLiteral(value: string | number | boolean): namedTypes.Literal | namedTypes.UnaryExpression { + if (typeof value === 'number' && value < 0) { + return builders.unaryExpression('-', builders.literal(-value)); + } + return builders.literal(value); +} + /** * Compile a JSON schema into a validation function. */ @@ -727,7 +738,7 @@ function compileNumberSchema( builders.binaryExpression( schema.exclusiveMaximum ? '>=' : '>', value, - builders.literal(schema.maximum), + createSafeLiteral(schema.maximum), ), builders.blockStatement([ builders.returnStatement(error('value greater than maximum')), @@ -742,7 +753,7 @@ function compileNumberSchema( builders.binaryExpression( schema.exclusiveMinimum ? '<=' : '<', value, - builders.literal(schema.minimum), + createSafeLiteral(schema.minimum), ), builders.blockStatement([ builders.returnStatement(error('value less than minimum')), @@ -987,10 +998,7 @@ function compileEnumableCheck( builders.ifStatement( schema.enum.reduce( (acc, val) => { - // Handle negative numbers by creating a unary expression instead of a negative literal - const literalValue = typeof val === 'number' && val < 0 - ? builders.unaryExpression('-', builders.literal(-val)) - : builders.literal(val); + const literalValue = createSafeLiteral(val); const test = builders.binaryExpression('!==', value, literalValue); if (!acc) { diff --git a/src/tests/__snapshots__/compileValueSchema.test.ts.snap b/src/tests/__snapshots__/compileValueSchema.test.ts.snap index e9b98d0..ce1391d 100644 --- a/src/tests/__snapshots__/compileValueSchema.test.ts.snap +++ b/src/tests/__snapshots__/compileValueSchema.test.ts.snap @@ -1295,3 +1295,379 @@ function obj0(path, value, context) { return value; }" `; + +exports[`Number negative maximum 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 = {}; +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') { + value = Number(value); + } + if (typeof value !== 'number' || Number.isNaN(value)) { + return new ValidationError(path, 'expected a number'); + } + if (value > -5) { + return new ValidationError(path, 'value greater than maximum'); + } + return value; +}" +`; + +exports[`Number negative minimum 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 = {}; +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') { + value = Number(value); + } + if (typeof value !== 'number' || Number.isNaN(value)) { + return new ValidationError(path, 'expected a number'); + } + if (value < -10) { + return new ValidationError(path, 'value less than minimum'); + } + return value; +}" +`; + +exports[`Number negative maximum exclusiveMaximum 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 = {}; +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') { + value = Number(value); + } + if (typeof value !== 'number' || Number.isNaN(value)) { + return new ValidationError(path, 'expected a number'); + } + if (value >= -5) { + return new ValidationError(path, 'value greater than maximum'); + } + return value; +}" +`; + +exports[`Number negative minimum exclusiveMinimum 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 = {}; +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') { + value = Number(value); + } + if (typeof value !== 'number' || Number.isNaN(value)) { + return new ValidationError(path, 'expected a number'); + } + if (value <= -10) { + return new ValidationError(path, 'value less than minimum'); + } + return value; +}" +`; + +exports[`Integer negative maximum 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 = {}; +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') { + value = Number(value); + } + if (typeof value !== 'number' || Number.isNaN(value)) { + return new ValidationError(path, 'expected a number'); + } + if (value > -5) { + return new ValidationError(path, 'value greater than maximum'); + } + return value; +}" +`; + +exports[`Integer negative minimum 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 = {}; +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') { + value = Number(value); + } + if (typeof value !== 'number' || Number.isNaN(value)) { + return new ValidationError(path, 'expected a number'); + } + if (value < -10) { + return new ValidationError(path, 'value less than minimum'); + } + return value; +}" +`; + +exports[`Integer negative maximum exclusiveMaximum 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 = {}; +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') { + value = Number(value); + } + if (typeof value !== 'number' || Number.isNaN(value)) { + return new ValidationError(path, 'expected a number'); + } + if (value >= -5) { + return new ValidationError(path, 'value greater than maximum'); + } + return value; +}" +`; + +exports[`Integer negative minimum exclusiveMinimum 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 = {}; +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') { + value = Number(value); + } + if (typeof value !== 'number' || Number.isNaN(value)) { + return new ValidationError(path, 'expected a number'); + } + if (value <= -10) { + return new ValidationError(path, 'value less than minimum'); + } + return value; +}" +`; diff --git a/src/tests/compileValueSchema.test.ts b/src/tests/compileValueSchema.test.ts index fcdc2b6..e88abec 100644 --- a/src/tests/compileValueSchema.test.ts +++ b/src/tests/compileValueSchema.test.ts @@ -48,6 +48,44 @@ describe('Number', () => { }); expect(compiler.compile()).toMatchSnapshot(); }); + + test('negative maximum', () => { + const compiler = new Compiler(); + compileValueSchema(compiler, { + type: 'number', + maximum: -5, + }); + expect(compiler.compile()).toMatchSnapshot(); + }); + + test('negative minimum', () => { + const compiler = new Compiler(); + compileValueSchema(compiler, { + type: 'number', + minimum: -10, + }); + expect(compiler.compile()).toMatchSnapshot(); + }); + + test('negative maximum exclusiveMaximum', () => { + const compiler = new Compiler(); + compileValueSchema(compiler, { + type: 'number', + maximum: -5, + exclusiveMaximum: true, + }); + expect(compiler.compile()).toMatchSnapshot(); + }); + + test('negative minimum exclusiveMinimum', () => { + const compiler = new Compiler(); + compileValueSchema(compiler, { + type: 'number', + minimum: -10, + exclusiveMinimum: true, + }); + expect(compiler.compile()).toMatchSnapshot(); + }); }); describe('Integer', () => { @@ -68,6 +106,44 @@ describe('Integer', () => { }); expect(compiler.compile()).toMatchSnapshot(); }); + + test('negative maximum', () => { + const compiler = new Compiler(); + compileValueSchema(compiler, { + type: 'integer', + maximum: -5, + }); + expect(compiler.compile()).toMatchSnapshot(); + }); + + test('negative minimum', () => { + const compiler = new Compiler(); + compileValueSchema(compiler, { + type: 'integer', + minimum: -10, + }); + expect(compiler.compile()).toMatchSnapshot(); + }); + + test('negative maximum exclusiveMaximum', () => { + const compiler = new Compiler(); + compileValueSchema(compiler, { + type: 'integer', + maximum: -5, + exclusiveMaximum: true, + }); + expect(compiler.compile()).toMatchSnapshot(); + }); + + test('negative minimum exclusiveMinimum', () => { + const compiler = new Compiler(); + compileValueSchema(compiler, { + type: 'integer', + minimum: -10, + exclusiveMinimum: true, + }); + expect(compiler.compile()).toMatchSnapshot(); + }); }); describe('Nullable', () => { From 5a30ee8d070838a4a3e0555de8545a341673d203 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Thu, 17 Jul 2025 15:58:56 +0200 Subject: [PATCH 4/4] Format --- src/compileValueSchema.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/compileValueSchema.ts b/src/compileValueSchema.ts index ab8cf0c..517c0e7 100644 --- a/src/compileValueSchema.ts +++ b/src/compileValueSchema.ts @@ -21,7 +21,9 @@ import { ValidationErrorIdentifier } from './error'; * Creates a literal value that handles negative numbers properly for escodegen. * For negative numbers, creates a unary expression instead of a negative literal. */ -function createSafeLiteral(value: string | number | boolean): namedTypes.Literal | namedTypes.UnaryExpression { +function createSafeLiteral( + value: string | number | boolean, +): namedTypes.Literal | namedTypes.UnaryExpression { if (typeof value === 'number' && value < 0) { return builders.unaryExpression('-', builders.literal(-value)); }