From ef8f772c7db76a3d8592b521849c935cd47dbcde Mon Sep 17 00:00:00 2001 From: Hafez Date: Mon, 13 Apr 2026 06:10:36 +0200 Subject: [PATCH 01/39] test(json-schema): add tests for bugs, missing conversions, dependencies, and preferences re #3108 --- test/json-schema.js | 646 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 645 insertions(+), 1 deletion(-) diff --git a/test/json-schema.js b/test/json-schema.js index b09db475..8ede4993 100644 --- a/test/json-schema.js +++ b/test/json-schema.js @@ -486,6 +486,24 @@ describe('jsonSchema', () => { Helper.validateJsonSchema(Joi.any().valid('a', 1, {}), { type: ['string', 'number'], enum: ['a', 1, {}] }); }); + + it('represents invalid values as not enum', () => { + + Helper.validateJsonSchema(Joi.string().invalid('foo', 'bar'), { + type: 'string', + minLength: 1, + not: { enum: ['foo', 'bar'] } + }); + + Helper.validateJsonSchema(Joi.number().invalid(0), { + type: 'number', + not: { enum: [0] } + }); + + Helper.validateJsonSchema(Joi.number().invalid(null), { + type: 'number' + }); + }); }); describe('alternatives', () => { @@ -667,6 +685,38 @@ describe('jsonSchema', () => { Helper.validateJsonSchema(Joi.alternatives().try(Joi.string(), Joi.number()).match('one'), { oneOf: [{ type: 'string', minLength: 1 }, { type: 'number' }] }); }); + it('represents match all as allOf', () => { + + Helper.validateJsonSchema( + Joi.alternatives().try( + Joi.object({ a: Joi.string() }).unknown(true), + Joi.object({ b: Joi.number() }).unknown(true) + ).match('all'), + { + allOf: [ + { type: 'object', properties: { a: { type: 'string', minLength: 1 } } }, + { type: 'object', properties: { b: { type: 'number' } } } + ] + } + ); + }); + + it('represents match all as allOf with strict objects', () => { + + Helper.validateJsonSchema( + Joi.alternatives().try( + Joi.object({ a: Joi.string() }), + Joi.object({ b: Joi.number() }) + ).match('all'), + { + allOf: [ + { type: 'object', properties: { a: { type: 'string', minLength: 1 } }, additionalProperties: false }, + { type: 'object', properties: { b: { type: 'number' } }, additionalProperties: false } + ] + } + ); + }); + it('represents empty schema when no alternatives provided', () => { Helper.validateJsonSchema(Joi.alternatives(), {}); @@ -881,6 +931,21 @@ describe('jsonSchema', () => { Helper.validateJsonSchema(Joi.array().min(1).max(10).length(5).unique(), { type: 'array', minItems: 5, maxItems: 5, uniqueItems: true }); }); + it('skips array constraints with ref arguments', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.number(), + b: Joi.array().min(Joi.ref('a')) + }), { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'array' } + }, + additionalProperties: false + }); + }); + it('omits items: false when unevaluatedItems is used', () => { Helper.validateJsonSchema(Joi.array().ordered(Joi.string()), { @@ -1028,6 +1093,33 @@ describe('jsonSchema', () => { enum: [new Date(1741708800000)] }); }); + + it('represents javascript timestamp', () => { + + Helper.validateJsonSchema(Joi.date().timestamp('javascript'), { + type: 'number', + minimum: -100e6 * 24 * 60 * 60 * 1000, // 100 million days in ms (ECMA-262 §21.4.1.1) + maximum: 100e6 * 24 * 60 * 60 * 1000 + }); + }); + + it('represents unix timestamp', () => { + + Helper.validateJsonSchema(Joi.date().timestamp('unix'), { + type: 'number', + minimum: -100e6 * 24 * 60 * 60, // 100 million days in seconds + maximum: 100e6 * 24 * 60 * 60 + }); + }); + + it('represents timestamp() default as javascript', () => { + + Helper.validateJsonSchema(Joi.date().timestamp(), { + type: 'number', + minimum: -100e6 * 24 * 60 * 60 * 1000, // 100 million days in ms (ECMA-262 §21.4.1.1) + maximum: 100e6 * 24 * 60 * 60 * 1000 + }); + }); }); describe('number', () => { @@ -1105,6 +1197,64 @@ describe('jsonSchema', () => { }); }); + it('represents number.precision() as multipleOf', () => { + + Helper.validateJsonSchema(Joi.number().precision(2), { type: 'number', multipleOf: 0.01 }); + Helper.validateJsonSchema(Joi.number().precision(0), { type: 'number', multipleOf: 1 }); + Helper.validateJsonSchema(Joi.number().precision(3), { type: 'number', multipleOf: 0.001 }); + }); + + it('skips number constraints with ref arguments', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.number(), + b: Joi.number().min(Joi.ref('a')) + }), { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'number' } + }, + additionalProperties: false + }); + + Helper.validateJsonSchema(Joi.object({ + a: Joi.number(), + b: Joi.number().max(Joi.ref('a')) + }), { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'number' } + }, + additionalProperties: false + }); + + Helper.validateJsonSchema(Joi.object({ + a: Joi.number(), + b: Joi.number().greater(Joi.ref('a')) + }), { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'number' } + }, + additionalProperties: false + }); + + Helper.validateJsonSchema(Joi.object({ + a: Joi.number(), + b: Joi.number().less(Joi.ref('a')) + }), { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'number' } + }, + additionalProperties: false + }); + }); + it('represents number with valids', () => { Helper.validateJsonSchema(Joi.number().valid(1, 2, 3), { @@ -1279,6 +1429,278 @@ describe('jsonSchema', () => { }); }); + it('excludes forbidden keys from properties', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string().required(), + secret: Joi.any().forbidden() + }), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 } + }, + required: ['a'], + additionalProperties: false + }); + }); + + it('represents with() dependency as dependentRequired', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string() + }).with('a', 'b'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + dependentRequired: { a: ['b'] } + }); + }); + + it('represents with() dependency with multiple peers', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string(), + c: Joi.string() + }).with('a', ['b', 'c']), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 }, + c: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + dependentRequired: { a: ['b', 'c'] } + }); + }); + + it('represents multiple with() dependencies', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string(), + c: Joi.string() + }).with('a', 'b').with('b', 'c'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 }, + c: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + dependentRequired: { a: ['b'], b: ['c'] } + }); + }); + + it('represents and() dependency as bidirectional dependentRequired', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string() + }).and('a', 'b'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + dependentRequired: { a: ['b'], b: ['a'] } + }); + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string(), + c: Joi.string() + }).and('a', 'b', 'c'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 }, + c: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + dependentRequired: { a: ['b', 'c'], b: ['a', 'c'], c: ['a', 'b'] } + }); + }); + + it('represents nand() dependency', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string() + }).nand('a', 'b'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + not: { properties: { a: true, b: true }, required: ['a', 'b'] } + }); + }); + + it('represents or() dependency', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string() + }).or('a', 'b'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + anyOf: [ + { properties: { a: true }, required: ['a'] }, + { properties: { b: true }, required: ['b'] } + ] + }); + }); + + it('represents xor() dependency', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string() + }).xor('a', 'b'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + oneOf: [ + { properties: { a: true }, required: ['a'] }, + { properties: { b: true }, required: ['b'] } + ] + }); + }); + + it('represents oxor() dependency', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string() + }).oxor('a', 'b'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + oneOf: [ + { not: { anyOf: [{ properties: { a: true }, required: ['a'] }, { properties: { b: true }, required: ['b'] }] } }, + { properties: { a: true }, required: ['a'] }, + { properties: { b: true }, required: ['b'] } + ] + }); + }); + + it('represents with() and and() dependencies together', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string(), + c: Joi.string() + }).with('a', 'b').and('b', 'c'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 }, + c: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + dependentRequired: { a: ['b'], b: ['c'], c: ['b'] } + }); + }); + + it('represents with() and without() dependencies together', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string(), + c: Joi.string() + }).with('a', 'b').without('a', 'c'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 }, + c: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + dependentRequired: { a: ['b'] }, + dependentSchemas: { + a: { properties: { c: false } } + } + }); + }); + + it('represents without() dependency as dependentSchemas', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string() + }).without('a', 'b'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + dependentSchemas: { + a: { properties: { b: false } } + } + }); + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string(), + c: Joi.string() + }).without('a', 'b').without('b', 'c'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 }, + c: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + dependentSchemas: { + a: { properties: { b: false } }, + b: { properties: { c: false } } + } + }); + }); + + it('excludes stripped keys in output mode', () => { + + const schema = Joi.object({ + a: Joi.string().required(), + password: Joi.string().strip() + }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + password: { type: 'string', minLength: 1 } + }, + required: ['a'], + additionalProperties: false + }, { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 } + }, + required: ['a'], + additionalProperties: false + }); + }); + it('represents complex nested object', () => { const schema = Joi.object({ @@ -1455,11 +1877,46 @@ describe('jsonSchema', () => { Helper.validateJsonSchema(Joi.string().isoDate(), { type: 'string', minLength: 1, format: 'date-time' }); Helper.validateJsonSchema(Joi.string().isoDuration(), { type: 'string', minLength: 1, format: 'duration' }); Helper.validateJsonSchema(Joi.string().token(), { type: 'string', minLength: 1, format: 'token' }); + Helper.validateJsonSchema(Joi.string().domain(), { + type: 'string', + minLength: 1, + pattern: '^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\\.){1,}[a-zA-Z]{2,}$' + }); + + Helper.validateJsonSchema(Joi.string().domain({ minDomainSegments: 3 }), { + type: 'string', + minLength: 1, + pattern: '^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\\.){2,}[a-zA-Z]{2,}$' + }); + + Helper.validateJsonSchema(Joi.string().domain({ maxDomainSegments: 3 }), { + type: 'string', + minLength: 1, + pattern: '^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\\.){1,2}[a-zA-Z]{2,}$' + }); + + Helper.validateJsonSchema(Joi.string().domain({ allowUnderscore: true }), { + type: 'string', + minLength: 1, + pattern: '^(_?[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\\.){1,}[a-zA-Z]{2,}$' + }); + + Helper.validateJsonSchema(Joi.string().domain({ allowFullyQualified: true }), { + type: 'string', + minLength: 1, + pattern: '^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\\.){1,}[a-zA-Z]{2,}\\.?$' + }); + + Helper.validateJsonSchema(Joi.string().domain({ tlds: false }), { + type: 'string', + minLength: 1, + pattern: '^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\\.){1,}[a-zA-Z][a-zA-Z0-9]*$' + }); }); it('represents string with various options', () => { - Helper.validateJsonSchema(Joi.string().alphanum(), { type: 'string', minLength: 1 }); + Helper.validateJsonSchema(Joi.string().alphanum(), { type: 'string', minLength: 1, pattern: '^[a-zA-Z0-9]+$' }); Helper.validateJsonSchema(Joi.string().allow(''), { type: 'string' }); Helper.validateJsonSchema(Joi.string().min(0), { type: 'string' }); Helper.validateJsonSchema(Joi.string().length(0), { type: 'string', minLength: 0, maxLength: 0 }); @@ -1474,12 +1931,181 @@ describe('jsonSchema', () => { Helper.validateJsonSchema(Joi.string().allow('a'), { type: 'string', minLength: 1 }); }); + it('skips string constraints with ref arguments', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.number(), + b: Joi.string().min(Joi.ref('a')) + }), { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'string', minLength: 1 } + }, + additionalProperties: false + }); + + Helper.validateJsonSchema(Joi.object({ + a: Joi.number(), + b: Joi.string().max(Joi.ref('a')) + }), { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'string', minLength: 1 } + }, + additionalProperties: false + }); + + Helper.validateJsonSchema(Joi.object({ + a: Joi.number(), + b: Joi.string().length(Joi.ref('a')) + }), { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'string', minLength: 1 } + }, + additionalProperties: false + }); + }); + it('represents nullable string', () => { Helper.validateJsonSchema(Joi.string().allow(null), { type: ['string', 'null'], minLength: 1 }); }); }); + describe('preferences', () => { + + it('respects allowUnknown preference', () => { + + Helper.validateJsonSchema( + Joi.object({ a: Joi.string() }).prefs({ allowUnknown: true }), + { + type: 'object', + properties: { a: { type: 'string', minLength: 1 } } + } + ); + }); + + it('propagates allowUnknown preference to nested objects', () => { + + Helper.validateJsonSchema( + Joi.object({ + nested: Joi.object({ x: Joi.string() }) + }).prefs({ allowUnknown: true }), + { + type: 'object', + properties: { + nested: { + type: 'object', + properties: { x: { type: 'string', minLength: 1 } } + } + } + } + ); + }); + + it('explicit unknown(false) overrides allowUnknown preference', () => { + + Helper.validateJsonSchema( + Joi.object({ a: Joi.string() }).unknown(false).prefs({ allowUnknown: true }), + { + type: 'object', + properties: { a: { type: 'string', minLength: 1 } }, + additionalProperties: false + } + ); + }); + + it('respects stripUnknown preference in output mode', () => { + + const schema = Joi.object({ a: Joi.string() }).prefs({ stripUnknown: true }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { a: { type: 'string', minLength: 1 } } + }, { + type: 'object', + properties: { a: { type: 'string', minLength: 1 } }, + additionalProperties: false + }); + }); + + it('respects presence: required preference', () => { + + Helper.validateJsonSchema( + Joi.object({ + a: Joi.string(), + b: Joi.number() + }).prefs({ presence: 'required' }), + { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'number' } + }, + required: ['a', 'b'], + additionalProperties: false + } + ); + }); + + it('respects presence: forbidden preference', () => { + + Helper.validateJsonSchema( + Joi.object({ + a: Joi.string(), + b: Joi.number() + }).prefs({ presence: 'forbidden' }), + { + type: 'object', + properties: {}, + additionalProperties: false + } + ); + }); + + it('explicit presence flag overrides presence preference', () => { + + Helper.validateJsonSchema( + Joi.object({ + a: Joi.string().optional(), + b: Joi.number() + }).prefs({ presence: 'required' }), + { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'number' } + }, + required: ['b'], + additionalProperties: false + } + ); + }); + + it('respects noDefaults preference in output mode', () => { + + const schema = Joi.object({ + a: Joi.string().default('foo'), + b: Joi.number() + }).prefs({ noDefaults: true }); + + const expected = { + type: 'object', + properties: { + a: { type: 'string', minLength: 1, default: 'foo' }, + b: { type: 'number' } + }, + additionalProperties: false + }; + + Helper.validateJsonSchema(schema, expected, expected); + }); + }); + describe('target', () => { it('represents nullable schemas with draft-2020-12 target', () => { @@ -1963,6 +2589,24 @@ describe('jsonSchema', () => { }); }); + it('represents Joi.link() to a child schema with id but without shared', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string().id('aSchema'), + b: Joi.link('#aSchema') + }), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { $ref: '#/$defs/aSchema' } + }, + additionalProperties: false, + $defs: { + aSchema: { type: 'string', minLength: 1 } + } + }); + }); + it('represents Joi.link() with uninitialized schema', () => { Helper.validateJsonSchema(Joi.link(), {}); From 96ab89cb9ae5f77b6247e970fb3aad3b94c8984b Mon Sep 17 00:00:00 2001 From: Hafez Date: Mon, 13 Apr 2026 06:34:59 +0200 Subject: [PATCH 02/39] fix(json-schema): fix bugs, add missing conversions, dependencies, and preferences - Fix alternatives.match('all') producing anyOf instead of allOf - Skip rule jsonSchema handlers when args contain Refs - Handle _invalids as not: { enum: [...] } - Exclude forbidden keys and stripped keys (output mode) from properties - Add all object dependency handlers (with, without, and, nand, or, xor, oxor) - Add string.alphanum pattern and string.domain pattern with options - Add number.precision as multipleOf - Add date.timestamp with ECMA-262 range limits - Register id'd child schemas in $defs for Joi.link() without .shared() - Respect preferences: allowUnknown, stripUnknown, presence, noDefaults re #3108 --- lib/base.js | 19 ++++++- lib/types/alternatives.js | 3 ++ lib/types/date.js | 20 ++++++- lib/types/keys.js | 108 ++++++++++++++++++++++++++++++++++++-- lib/types/number.js | 7 ++- lib/types/string.js | 29 +++++++++- 6 files changed, 175 insertions(+), 11 deletions(-) diff --git a/lib/base.js b/lib/base.js index dd822b65..b6a4432b 100644 --- a/lib/base.js +++ b/lib/base.js @@ -78,6 +78,12 @@ internals.Base = class { const rootCall = !options.$defs; const defs = options.$defs ?? {}; + // Merge parent prefs with this schema's prefs (explicit takes precedence) + + const prefs = this._preferences + ? Common.preferences(options.prefs, this._preferences) + : options.prefs; + let schema = {}; const isTypeAny = this.type === 'any'; @@ -109,7 +115,7 @@ internals.Base = class { // Apply type-specific JSON Schema conversion - const subOptions = { ...options, $defs: defs }; + const subOptions = { ...options, $defs: defs, prefs }; if (this._definition.jsonSchema && typesOverlap) { schema = this._definition.jsonSchema(this, schema, mode, subOptions); } @@ -118,7 +124,7 @@ internals.Base = class { for (const rule of this._rules) { const definition = this._definition.rules[rule.name]; - if (definition.jsonSchema && typesOverlap) { + if (definition.jsonSchema && typesOverlap && !rule._resolve.length) { schema = definition.jsonSchema(rule, schema, isOnly, mode, subOptions); } } @@ -168,6 +174,15 @@ internals.Base = class { } } + // Handle disallowed values (invalids) + + if (this._invalids) { + const invalids = Array.from(this._invalids._values).filter((v) => v !== null && typeof v !== 'symbol'); + if (invalids.length) { + schema.not = { enum: invalids }; + } + } + // Handle 'null' if it's an allowed value if (this._valids && this._valids.has(null) && !(isTypeAny && !isOnly)) { diff --git a/lib/types/alternatives.js b/lib/types/alternatives.js index 84f09f99..09471557 100755 --- a/lib/types/alternatives.js +++ b/lib/types/alternatives.js @@ -181,6 +181,9 @@ module.exports = Any.extend({ if (matchMode === 'one') { res.oneOf = matches; } + else if (matchMode === 'all') { + res.allOf = matches; + } else { res.anyOf = matches; } diff --git a/lib/types/date.js b/lib/types/date.js index 306fa999..a9d9d176 100755 --- a/lib/types/date.js +++ b/lib/types/date.js @@ -8,7 +8,9 @@ const Template = require('../template'); const internals = { - formats: ['iso', 'javascript', 'unix'] + formats: ['iso', 'javascript', 'unix'], + maxJs: 100e6 * 24 * 60 * 60 * 1000, // 100 million days in ms (ECMA-262 §21.4.1.1) + maxUnix: 100e6 * 24 * 60 * 60 // 100 million days in seconds }; @@ -52,6 +54,22 @@ module.exports = Any.extend({ jsonSchema(schema, res, mode, options) { + const format = schema._flags.format; + + if (format === 'javascript') { + res.type = 'number'; + res.minimum = -internals.maxJs; + res.maximum = internals.maxJs; + return res; + } + + if (format === 'unix') { + res.type = 'number'; + res.minimum = -internals.maxUnix; + res.maximum = internals.maxUnix; + return res; + } + res.type = 'string'; res.format = 'date-time'; diff --git a/lib/types/keys.js b/lib/types/keys.js index af281f03..a8ea76e0 100755 --- a/lib/types/keys.js +++ b/lib/types/keys.js @@ -49,6 +49,8 @@ module.exports = Any.extend({ jsonSchema(schema, res, mode, options) { + const prefs = options.prefs || {}; + res.type = 'object'; // Map Joi keys to JSON Schema 'properties' and 'required' @@ -59,10 +61,25 @@ module.exports = Any.extend({ const required = []; for (const child of schema.$_terms.keys) { + const presence = child.schema._flags.presence || prefs.presence; + + if (presence === 'forbidden') { + continue; + } + + if (mode === 'output' && child.schema._flags.result === 'strip') { + continue; + } + const jsonSchema = child.schema.$_jsonSchema(mode, options); res.properties[child.key] = jsonSchema; - if (child.schema._flags.presence === 'required' || - (mode === 'output' && child.schema._flags.default !== undefined)) { + + if (child.schema._flags.id) { + options.$defs[child.schema._flags.id] = jsonSchema; + } + + if (presence === 'required' || + (mode === 'output' && child.schema._flags.default !== undefined && !prefs.noDefaults)) { required.push(child.key); } @@ -99,13 +116,36 @@ module.exports = Any.extend({ } } - // Handle 'additionalProperties' based on unknown keys flag + // Handle dependencies + + if (schema.$_terms.dependencies) { + for (const dep of schema.$_terms.dependencies) { + internals.depJsonSchema[dep.rel]?.(dep, res); + } + } + + // Handle 'additionalProperties' based on unknown keys flag and preferences if (res.additionalProperties === undefined) { - const additionalProperties = schema._flags.unknown === true || (schema._flags.unknown === undefined && !schema.$_terms.keys && !schema.$_terms.patterns && !schema._flags.only); - if (additionalProperties === false) { + const unknownFlag = schema._flags.unknown; + + if (unknownFlag === false) { res.additionalProperties = false; } + else if (unknownFlag === undefined) { + // stripUnknown: input accepts unknowns, output has them stripped + + if (prefs.stripUnknown) { + if (mode === 'output') { + res.additionalProperties = false; + } + } + else if (!prefs.allowUnknown && + (schema.$_terms.keys || schema.$_terms.patterns || schema._flags.only)) { + + res.additionalProperties = false; + } + } } return res; @@ -661,6 +701,64 @@ module.exports = Any.extend({ // Helpers +internals.depJsonSchema = { + + with(dep, res) { + + res.dependentRequired = res.dependentRequired || {}; + res.dependentRequired[dep.key.key] = dep.paths; + }, + + without(dep, res) { + + res.dependentSchemas = res.dependentSchemas || {}; + const prohibited = {}; + for (const peer of dep.paths) { + prohibited[peer] = false; + } + + res.dependentSchemas[dep.key.key] = { properties: prohibited }; + }, + + and(dep, res) { + + res.dependentRequired = res.dependentRequired || {}; + for (const peer of dep.paths) { + res.dependentRequired[peer] = dep.paths.filter((p) => p !== peer); + } + }, + + nand(dep, res) { + + const props = {}; + for (const peer of dep.paths) { + props[peer] = true; + } + + res.not = { properties: props, required: dep.paths }; + }, + + or(dep, res) { + + res.anyOf = dep.paths.map((peer) => ({ properties: { [peer]: true }, required: [peer] })); + }, + + xor(dep, res) { + + res.oneOf = dep.paths.map((peer) => ({ properties: { [peer]: true }, required: [peer] })); + }, + + oxor(dep, res) { + + const branches = dep.paths.map((peer) => ({ properties: { [peer]: true }, required: [peer] })); + res.oneOf = [ + { not: { anyOf: branches } }, + ...branches + ]; + } +}; + + internals.clone = function (value, prefs) { // Object diff --git a/lib/types/number.js b/lib/types/number.js index ae07eae8..ab843ec2 100755 --- a/lib/types/number.js +++ b/lib/types/number.js @@ -303,7 +303,12 @@ module.exports = Any.extend({ return helpers.error('number.precision', { limit, value }); }, - convert: true + convert: true, + jsonSchema(rule, res) { + + res.multipleOf = 1 / Math.pow(10, rule.args.limit); + return res; + } }, sign: { diff --git a/lib/types/string.js b/lib/types/string.js index 56dfe620..9697e43b 100755 --- a/lib/types/string.js +++ b/lib/types/string.js @@ -9,6 +9,7 @@ const Common = require('../common'); const internals = { + minDomainSegments: 2, // Match @hapi/address MIN_DOMAIN_SEGMENTS tlds: Tlds.tlds instanceof Set ? { tlds: { allow: Tlds.tlds, deny: null } } : false, // $lab:coverage:ignore$ base64Regex: { // paddingRequired @@ -151,8 +152,8 @@ module.exports = Any.extend({ const min = schema.$_getRule('min'); const length = schema.$_getRule('length'); - if ((!min || min.args.limit > 0) && - (!length || length.args.limit > 0)) { + if ((!min || min._resolve.length || min.args.limit > 0) && + (!length || length._resolve.length || length.args.limit > 0)) { res.minLength = 1; } @@ -175,6 +176,11 @@ module.exports = Any.extend({ } return helpers.error('string.alphanum'); + }, + jsonSchema(rule, res) { + + res.pattern = '^[a-zA-Z0-9]+$'; + return res; } }, @@ -307,6 +313,25 @@ module.exports = Any.extend({ } return helpers.error('string.domain'); + }, + jsonSchema(rule, res) { + + // Not using format: 'hostname' because it accepts single-label names (e.g. 'localhost') + // while Joi's domain() requires at least 2 segments by default + + const opts = rule.args.options || {}; + + const underscore = opts.allowUnderscore ? '_?' : ''; + const label = `${underscore}[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?`; + + const min = (opts.minDomainSegments || internals.minDomainSegments) - 1; + const max = opts.maxDomainSegments !== undefined ? `,${opts.maxDomainSegments - 1}` : ','; + + const tld = opts.tlds === false ? '[a-zA-Z][a-zA-Z0-9]*' : '[a-zA-Z]{2,}'; + const fqdn = opts.allowFullyQualified ? '\\.?' : ''; + + res.pattern = `^(${label}\\.){${min}${max}}${tld}${fqdn}$`; + return res; } }, From acccd2edb4947e837a3c8b1101e69adc12d2f131 Mon Sep 17 00:00:00 2001 From: Hafez Date: Mon, 13 Apr 2026 07:40:41 +0200 Subject: [PATCH 03/39] test(json-schema): add regression coverage for schema parity re #3108 --- test/helper.js | 23 +++ test/json-schema.js | 446 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 452 insertions(+), 17 deletions(-) diff --git a/test/helper.js b/test/helper.js index a95acdd2..a7b030ad 100644 --- a/test/helper.js +++ b/test/helper.js @@ -152,6 +152,29 @@ exports.validateJsonSchema = function (schema, expectedInput, expectedOutput) { } }; + +exports.validateJsonSchemaValues = function (schema, tests) { + + try { + const validate = internals.ajvValidator.compile(schema); + + for (const [value, pass] of tests) { + const result = validate(value); + + if (result !== pass) { + console.log({ value, errors: validate.errors }); + } + + expect(result).to.equal(pass); + } + } + catch (err) { + console.error(err.stack); + err.at = internals.thrownAt(); // Adjust error location to test + throw err; + } +}; + internals.thrownAt = function () { const error = new Error(); diff --git a/test/json-schema.js b/test/json-schema.js index 8ede4993..b0565eb4 100644 --- a/test/json-schema.js +++ b/test/json-schema.js @@ -201,6 +201,55 @@ describe('jsonSchema', () => { }); }); + it('represents examples', () => { + + Helper.validateJsonSchema(Joi.string().example('a').example('b'), { + type: 'string', + minLength: 1, + examples: ['a', 'b'] + }); + }); + + it('represents supported meta keywords', () => { + + Helper.validateJsonSchema(Joi.string().meta({ + title: 'Greeting', + format: 'banana', + contentEncoding: 'base64', + contentMediaType: 'text/plain', + readOnly: true, + writeOnly: true, + deprecated: true, + examples: ['hi'], + $comment: 'schema note' + }), { + type: 'string', + minLength: 1, + title: 'Greeting', + format: 'banana', + contentEncoding: 'base64', + contentMediaType: 'text/plain', + readOnly: true, + writeOnly: true, + deprecated: true, + examples: ['hi'], + $comment: 'schema note' + }); + }); + + it('merges meta examples without overriding validator-derived schema keywords', () => { + + Helper.validateJsonSchema(Joi.string().email().example('a@example.com').meta({ + format: 'banana', + examples: ['b@example.com'] + }), { + type: 'string', + minLength: 1, + format: 'email', + examples: ['a@example.com', 'b@example.com'] + }); + }); + it('represents description with null allowed', () => { Helper.validateJsonSchema(Joi.allow(null).description('foobar'), { @@ -1033,6 +1082,16 @@ describe('jsonSchema', () => { Helper.validateJsonSchema(Joi.binary().min(1).max(10).length(5).custom(() => {}), { type: 'string', format: 'binary', minLength: 5, maxLength: 5 }); }); + + it('represents binary encoding', () => { + + Helper.validateJsonSchema(Joi.binary().encoding('base64').min(3), { + type: 'string', + format: 'binary', + contentEncoding: 'base64', + minLength: 3 + }); + }); }); describe('boolean', () => { @@ -1478,6 +1537,24 @@ describe('jsonSchema', () => { }); }); + it('merges repeated with() dependencies for the same key', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string(), + c: Joi.string() + }).with('a', 'b').with('a', 'c'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 }, + c: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + dependentRequired: { a: ['b', 'c'] } + }); + }); + it('represents multiple with() dependencies', () => { Helper.validateJsonSchema(Joi.object({ @@ -1527,6 +1604,31 @@ describe('jsonSchema', () => { }); }); + it('merges repeated and() dependencies', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string(), + c: Joi.string(), + d: Joi.string() + }).and('a', 'b').and('a', 'c', 'd'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 }, + c: { type: 'string', minLength: 1 }, + d: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + dependentRequired: { + a: ['b', 'c', 'd'], + b: ['a'], + c: ['a', 'd'], + d: ['a', 'c'] + } + }); + }); + it('represents nand() dependency', () => { Helper.validateJsonSchema(Joi.object({ @@ -1543,6 +1645,27 @@ describe('jsonSchema', () => { }); }); + it('represents multiple nand() dependencies', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string(), + c: Joi.string() + }).nand('a', 'b').nand('a', 'c'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 }, + c: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + allOf: [ + { not: { properties: { a: true, b: true }, required: ['a', 'b'] } }, + { not: { properties: { a: true, c: true }, required: ['a', 'c'] } } + ] + }); + }); + it('represents or() dependency', () => { Helper.validateJsonSchema(Joi.object({ @@ -1562,6 +1685,39 @@ describe('jsonSchema', () => { }); }); + it('represents multiple or() dependencies', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string(), + c: Joi.string(), + d: Joi.string() + }).or('a', 'b').or('c', 'd'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 }, + c: { type: 'string', minLength: 1 }, + d: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + allOf: [ + { + anyOf: [ + { properties: { a: true }, required: ['a'] }, + { properties: { b: true }, required: ['b'] } + ] + }, + { + anyOf: [ + { properties: { c: true }, required: ['c'] }, + { properties: { d: true }, required: ['d'] } + ] + } + ] + }); + }); + it('represents xor() dependency', () => { Helper.validateJsonSchema(Joi.object({ @@ -1581,6 +1737,39 @@ describe('jsonSchema', () => { }); }); + it('represents multiple xor() dependencies', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string(), + c: Joi.string(), + d: Joi.string() + }).xor('a', 'b').xor('c', 'd'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 }, + c: { type: 'string', minLength: 1 }, + d: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + allOf: [ + { + oneOf: [ + { properties: { a: true }, required: ['a'] }, + { properties: { b: true }, required: ['b'] } + ] + }, + { + oneOf: [ + { properties: { c: true }, required: ['c'] }, + { properties: { d: true }, required: ['d'] } + ] + } + ] + }); + }); + it('represents oxor() dependency', () => { Helper.validateJsonSchema(Joi.object({ @@ -1601,6 +1790,41 @@ describe('jsonSchema', () => { }); }); + it('represents multiple oxor() dependencies', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string(), + c: Joi.string(), + d: Joi.string() + }).oxor('a', 'b').oxor('c', 'd'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 }, + c: { type: 'string', minLength: 1 }, + d: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + allOf: [ + { + oneOf: [ + { not: { anyOf: [{ properties: { a: true }, required: ['a'] }, { properties: { b: true }, required: ['b'] }] } }, + { properties: { a: true }, required: ['a'] }, + { properties: { b: true }, required: ['b'] } + ] + }, + { + oneOf: [ + { not: { anyOf: [{ properties: { c: true }, required: ['c'] }, { properties: { d: true }, required: ['d'] }] } }, + { properties: { c: true }, required: ['c'] }, + { properties: { d: true }, required: ['d'] } + ] + } + ] + }); + }); + it('represents with() and and() dependencies together', () => { Helper.validateJsonSchema(Joi.object({ @@ -1676,6 +1900,26 @@ describe('jsonSchema', () => { }); }); + it('merges repeated without() dependencies for the same key', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string(), + c: Joi.string() + }).without('a', 'b').without('a', 'c'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 }, + c: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + dependentSchemas: { + a: { properties: { b: false, c: false } } + } + }); + }); + it('excludes stripped keys in output mode', () => { const schema = Joi.object({ @@ -1701,6 +1945,47 @@ describe('jsonSchema', () => { }); }); + it('merges invalid() with other not-based object constraints', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string() + }).nand('a', 'b').invalid({ foo: 'bar' }), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + allOf: [ + { not: { properties: { a: true, b: true }, required: ['a', 'b'] } }, + { not: { enum: [{ foo: 'bar' }] } } + ] + }); + }); + + it('merges invalid() with existing allOf-based object constraints', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string(), + c: Joi.string() + }).nand('a', 'b').nand('a', 'c').invalid({ foo: 'bar' }), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 }, + c: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + allOf: [ + { not: { properties: { a: true, b: true }, required: ['a', 'b'] } }, + { not: { properties: { a: true, c: true }, required: ['a', 'c'] } }, + { not: { enum: [{ foo: 'bar' }] } } + ] + }); + }); + it('represents complex nested object', () => { const schema = Joi.object({ @@ -1877,41 +2162,124 @@ describe('jsonSchema', () => { Helper.validateJsonSchema(Joi.string().isoDate(), { type: 'string', minLength: 1, format: 'date-time' }); Helper.validateJsonSchema(Joi.string().isoDuration(), { type: 'string', minLength: 1, format: 'duration' }); Helper.validateJsonSchema(Joi.string().token(), { type: 'string', minLength: 1, format: 'token' }); - Helper.validateJsonSchema(Joi.string().domain(), { + Helper.validateJsonSchema(Joi.string().domain({ tlds: false }), { type: 'string', minLength: 1, - pattern: '^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\\.){1,}[a-zA-Z]{2,}$' + pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?\\.){1,}(?=[^.]{1,63}$)[A-Za-z\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?$' }); - - Helper.validateJsonSchema(Joi.string().domain({ minDomainSegments: 3 }), { + Helper.validateJsonSchema(Joi.string().domain({ allowUnicode: false, tlds: false }), { type: 'string', minLength: 1, - pattern: '^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\\.){2,}[a-zA-Z]{2,}$' + pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\\.){1,}(?=[^.]{1,63}$)[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?$' }); - - Helper.validateJsonSchema(Joi.string().domain({ maxDomainSegments: 3 }), { + Helper.validateJsonSchema(Joi.string().domain({ minDomainSegments: 3, tlds: false }), { type: 'string', minLength: 1, - pattern: '^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\\.){1,2}[a-zA-Z]{2,}$' + pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?\\.){2,}(?=[^.]{1,63}$)[A-Za-z\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?$' }); - - Helper.validateJsonSchema(Joi.string().domain({ allowUnderscore: true }), { + Helper.validateJsonSchema(Joi.string().domain({ maxDomainSegments: 3, tlds: false }), { type: 'string', minLength: 1, - pattern: '^(_?[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\\.){1,}[a-zA-Z]{2,}$' + pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?\\.){1,2}(?=[^.]{1,63}$)[A-Za-z\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?$' }); - - Helper.validateJsonSchema(Joi.string().domain({ allowFullyQualified: true }), { + Helper.validateJsonSchema(Joi.string().domain({ allowUnderscore: true, tlds: false }), { type: 'string', minLength: 1, - pattern: '^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\\.){1,}[a-zA-Z]{2,}\\.?$' + pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9_\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?\\.){1,}(?=[^.]{1,63}$)[A-Za-z\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?$' }); - - Helper.validateJsonSchema(Joi.string().domain({ tlds: false }), { + Helper.validateJsonSchema(Joi.string().domain({ allowFullyQualified: true, tlds: false }), { + type: 'string', + minLength: 1, + pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?\\.){1,}(?=[^.]{1,63}(?:\\.?$))[A-Za-z\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?\\.?$' + }); + Helper.validateJsonSchema(Joi.string().domain({ tlds: { allow: ['com'] } }), { type: 'string', minLength: 1, - pattern: '^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\\.){1,}[a-zA-Z][a-zA-Z0-9]*$' + pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?\\.){1,}(?=[^.]{1,63}$)(?:[cC][oO][mM])$' }); + Helper.validateJsonSchema(Joi.string().domain({ tlds: { deny: ['com'] } }), { + type: 'string', + minLength: 1, + pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?\\.){1,}(?!(?:[cC][oO][mM])$)(?=[^.]{1,63}$)[A-Za-z\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?$' + }); + + const defaultDomain = Joi.string().domain()['~standard'].jsonSchema.input(); + expect(defaultDomain.type).to.equal('string'); + expect(defaultDomain.minLength).to.equal(1); + expect(defaultDomain.pattern).to.be.a.string(); + Helper.validateJsonSchemaValues(defaultDomain, [ + ['example.com', true], + ['ä.com', true], + ['localhost', false], + ['example.local', false], + [`${'a'.repeat(63)}.com`, true], + [`${'a'.repeat(64)}.com`, false], + [`${Array(50).fill('abcd').join('.')}.com`, true], + [`${Array(51).fill('abcd').join('.')}.com`, false] + ]); + + const asciiDomain = Joi.string().domain({ allowUnicode: false })['~standard'].jsonSchema.input(); + expect(asciiDomain.type).to.equal('string'); + expect(asciiDomain.minLength).to.equal(1); + expect(asciiDomain.pattern).to.be.a.string(); + Helper.validateJsonSchemaValues(asciiDomain, [ + ['example.com', true], + ['ä.com', false] + ]); + + const minSegmentsDomain = Joi.string().domain({ minDomainSegments: 3 })['~standard'].jsonSchema.input(); + expect(minSegmentsDomain.type).to.equal('string'); + expect(minSegmentsDomain.minLength).to.equal(1); + Helper.validateJsonSchemaValues(minSegmentsDomain, [ + ['a.b.com', true], + ['a.com', false] + ]); + + const maxSegmentsDomain = Joi.string().domain({ maxDomainSegments: 3 })['~standard'].jsonSchema.input(); + expect(maxSegmentsDomain.type).to.equal('string'); + expect(maxSegmentsDomain.minLength).to.equal(1); + Helper.validateJsonSchemaValues(maxSegmentsDomain, [ + ['a.b.com', true], + ['a.b.c.com', false] + ]); + + const underscoreDomain = Joi.string().domain({ allowUnderscore: true })['~standard'].jsonSchema.input(); + expect(underscoreDomain.type).to.equal('string'); + expect(underscoreDomain.minLength).to.equal(1); + Helper.validateJsonSchemaValues(underscoreDomain, [ + ['_sip._tcp.example.com', true] + ]); + + const fqdnDomain = Joi.string().domain({ allowFullyQualified: true })['~standard'].jsonSchema.input(); + expect(fqdnDomain.type).to.equal('string'); + expect(fqdnDomain.minLength).to.equal(1); + Helper.validateJsonSchemaValues(fqdnDomain, [ + ['example.com.', true] + ]); + + const anyTldDomain = Joi.string().domain({ tlds: false })['~standard'].jsonSchema.input(); + expect(anyTldDomain.type).to.equal('string'); + expect(anyTldDomain.minLength).to.equal(1); + Helper.validateJsonSchemaValues(anyTldDomain, [ + ['example.local', true] + ]); + + const allowedTldDomain = Joi.string().domain({ tlds: { allow: ['com'] } })['~standard'].jsonSchema.input(); + expect(allowedTldDomain.type).to.equal('string'); + expect(allowedTldDomain.minLength).to.equal(1); + Helper.validateJsonSchemaValues(allowedTldDomain, [ + ['example.com', true], + ['example.net', false], + ['example.COM', true] + ]); + + const deniedTldDomain = Joi.string().domain({ tlds: { deny: ['com'] } })['~standard'].jsonSchema.input(); + expect(deniedTldDomain.type).to.equal('string'); + expect(deniedTldDomain.minLength).to.equal(1); + Helper.validateJsonSchemaValues(deniedTldDomain, [ + ['example.com', false], + ['example.net', true] + ]); }); it('represents string with various options', () => { @@ -2607,6 +2975,50 @@ describe('jsonSchema', () => { }); }); + it('keeps defs for stripped child schemas referenced by links', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string().id('aSchema').strip(), + b: Joi.link('#aSchema') + }), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { $ref: '#/$defs/aSchema' } + }, + additionalProperties: false, + $defs: { + aSchema: { type: 'string', minLength: 1 } + } + }, { + type: 'object', + properties: { + b: { $ref: '#/$defs/aSchema' } + }, + additionalProperties: false, + $defs: { + aSchema: { type: 'string', minLength: 1 } + } + }); + }); + + it('keeps defs for forbidden child schemas referenced by links', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string().id('aSchema').forbidden(), + b: Joi.link('#aSchema') + }), { + type: 'object', + properties: { + b: { $ref: '#/$defs/aSchema' } + }, + additionalProperties: false, + $defs: { + aSchema: { type: 'string', minLength: 1 } + } + }); + }); + it('represents Joi.link() with uninitialized schema', () => { Helper.validateJsonSchema(Joi.link(), {}); From 5458d7de38c2878698a3b2b9b73f842f2feec3fc Mon Sep 17 00:00:00 2001 From: Hafez Date: Mon, 13 Apr 2026 07:40:51 +0200 Subject: [PATCH 04/39] fix(json-schema): improve conversion parity re #3108 --- lib/base.js | 114 +++++++++++++++++++++++++++++++++- lib/types/binary.js | 4 ++ lib/types/keys.js | 94 +++++++++++++++++++++++----- lib/types/string.js | 148 ++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 331 insertions(+), 29 deletions(-) diff --git a/lib/base.js b/lib/base.js index b6a4432b..56e6eea2 100644 --- a/lib/base.js +++ b/lib/base.js @@ -20,6 +20,18 @@ const internals = { standardTypes: new Set(['string', 'number', 'integer', 'boolean', 'object', 'array', 'null']), jsonSchemaTarget: 'draft-2020-12', primitiveTypes: new Set(['string', 'number', 'boolean']), + supportedMetaKeywords: new Set([ + '$comment', + 'contentEncoding', + 'contentMediaType', + 'contentSchema', + 'deprecated', + 'examples', + 'format', + 'readOnly', + 'title', + 'writeOnly' + ]), nullSchema: () => ({ type: 'null' }) }; @@ -137,6 +149,9 @@ internals.Base = class { } } + internals.applyExamples(schema, this.$_terms.examples); + internals.applyMetas(schema, this.$_terms.metas); + if (rootCall && Object.keys(defs).length) { schema.$defs = defs; } @@ -179,7 +194,7 @@ internals.Base = class { if (this._invalids) { const invalids = Array.from(this._invalids._values).filter((v) => v !== null && typeof v !== 'symbol'); if (invalids.length) { - schema.not = { enum: invalids }; + internals.appendCompositeKeyword(schema, 'not', { enum: invalids }); } } @@ -1308,6 +1323,103 @@ internals.Base = class { }; +internals.appendCompositeKeyword = function (schema, keyword, value) { + + if (schema.allOf) { + if (schema[keyword] !== undefined) { + schema.allOf.push({ [keyword]: schema[keyword] }); + delete schema[keyword]; + } + + schema.allOf.push({ [keyword]: value }); + return; + } + + if (schema[keyword] === undefined) { + schema[keyword] = value; + return; + } + + schema.allOf = [ + { [keyword]: schema[keyword] }, + { [keyword]: value } + ]; + + delete schema[keyword]; +}; + + +internals.applyExamples = function (schema, examples) { + + if (!examples || + !examples.length || + schema.examples !== undefined) { + + return; + } + + schema.examples = [...examples]; +}; + + +internals.applyMetas = function (schema, metas) { + + if (!metas || + !metas.length) { + + return; + } + + for (const meta of metas) { + if (!meta || + typeof meta !== 'object' || + Array.isArray(meta)) { + + continue; + } + + for (const [key, value] of Object.entries(meta)) { + if (key === 'examples') { + const merged = internals.mergeExamples(schema.examples, value); + if (merged !== undefined) { + schema.examples = merged; + } + + continue; + } + + if (!internals.supportedMetaKeywords.has(key) || + value === undefined || + schema[key] !== undefined) { + + continue; + } + + schema[key] = value; + } + } +}; + + +internals.mergeExamples = function (existing, next) { + + if (!Array.isArray(next) || + !next.length) { + + return existing; + } + + const merged = existing ? [...existing] : []; + for (const example of next) { + if (!merged.some((item) => deepEqual(item, example))) { + merged.push(example); + } + } + + return merged; +}; + + internals.Base.prototype[Common.symbols.any] = { version: Common.version, compile: Compile.compile, diff --git a/lib/types/binary.js b/lib/types/binary.js index dbf1fd03..e2a99e97 100755 --- a/lib/types/binary.js +++ b/lib/types/binary.js @@ -38,6 +38,10 @@ module.exports = Any.extend({ res.type = 'string'; res.format = 'binary'; + if (schema._flags.encoding) { + res.contentEncoding = schema._flags.encoding; + } + return res; }, diff --git a/lib/types/keys.js b/lib/types/keys.js index a8ea76e0..473f9c37 100755 --- a/lib/types/keys.js +++ b/lib/types/keys.js @@ -62,6 +62,11 @@ module.exports = Any.extend({ for (const child of schema.$_terms.keys) { const presence = child.schema._flags.presence || prefs.presence; + const jsonSchema = child.schema.$_jsonSchema(mode, options); + + if (child.schema._flags.id) { + options.$defs[child.schema._flags.id] = jsonSchema; + } if (presence === 'forbidden') { continue; @@ -71,13 +76,8 @@ module.exports = Any.extend({ continue; } - const jsonSchema = child.schema.$_jsonSchema(mode, options); res.properties[child.key] = jsonSchema; - if (child.schema._flags.id) { - options.$defs[child.schema._flags.id] = jsonSchema; - } - if (presence === 'required' || (mode === 'output' && child.schema._flags.default !== undefined && !prefs.noDefaults)) { @@ -705,26 +705,23 @@ internals.depJsonSchema = { with(dep, res) { - res.dependentRequired = res.dependentRequired || {}; - res.dependentRequired[dep.key.key] = dep.paths; + internals.mergeDependentRequired(res, dep.key.key, dep.paths); }, without(dep, res) { - res.dependentSchemas = res.dependentSchemas || {}; const prohibited = {}; for (const peer of dep.paths) { prohibited[peer] = false; } - res.dependentSchemas[dep.key.key] = { properties: prohibited }; + internals.mergeDependentSchemaProperties(res, dep.key.key, prohibited); }, and(dep, res) { - res.dependentRequired = res.dependentRequired || {}; for (const peer of dep.paths) { - res.dependentRequired[peer] = dep.paths.filter((p) => p !== peer); + internals.mergeDependentRequired(res, peer, dep.paths.filter((path) => path !== peer)); } }, @@ -735,27 +732,92 @@ internals.depJsonSchema = { props[peer] = true; } - res.not = { properties: props, required: dep.paths }; + internals.appendCompositeKeyword(res, 'not', { properties: props, required: dep.paths }); }, or(dep, res) { - res.anyOf = dep.paths.map((peer) => ({ properties: { [peer]: true }, required: [peer] })); + const branches = dep.paths.map((peer) => ({ properties: { [peer]: true }, required: [peer] })); + internals.appendCompositeKeyword(res, 'anyOf', branches); }, xor(dep, res) { - res.oneOf = dep.paths.map((peer) => ({ properties: { [peer]: true }, required: [peer] })); + const branches = dep.paths.map((peer) => ({ properties: { [peer]: true }, required: [peer] })); + internals.appendCompositeKeyword(res, 'oneOf', branches); }, oxor(dep, res) { const branches = dep.paths.map((peer) => ({ properties: { [peer]: true }, required: [peer] })); - res.oneOf = [ + internals.appendCompositeKeyword(res, 'oneOf', [ { not: { anyOf: branches } }, ...branches - ]; + ]); + } +}; + + +internals.mergeDependentRequired = function (res, key, peers) { + + res.dependentRequired = res.dependentRequired || {}; + res.dependentRequired[key] = internals.mergeUniqueItems(res.dependentRequired[key], peers); +}; + + +internals.mergeDependentSchemaProperties = function (res, key, properties) { + + res.dependentSchemas = res.dependentSchemas || {}; + + const existing = res.dependentSchemas[key]; + if (!existing) { + res.dependentSchemas[key] = { properties }; + return; } + + existing.properties = { + ...existing.properties, + ...properties + }; +}; + + +internals.appendCompositeKeyword = function (res, keyword, value) { + + if (res.allOf) { + if (res[keyword] !== undefined) { + res.allOf.push({ [keyword]: res[keyword] }); + delete res[keyword]; + } + + res.allOf.push({ [keyword]: value }); + return; + } + + if (res[keyword] === undefined) { + res[keyword] = value; + return; + } + + res.allOf = [ + { [keyword]: res[keyword] }, + { [keyword]: value } + ]; + + delete res[keyword]; +}; + + +internals.mergeUniqueItems = function (existing = [], next = []) { + + const items = [...existing]; + for (const value of next) { + if (!items.includes(value)) { + items.push(value); + } + } + + return items; }; diff --git a/lib/types/string.js b/lib/types/string.js index 9697e43b..43a99c78 100755 --- a/lib/types/string.js +++ b/lib/types/string.js @@ -1,5 +1,6 @@ 'use strict'; +const Url = require('url'); const { assert, escapeRegex } = require('@hapi/hoek'); const { isDomainValid, isEmailValid, ipRegex, uriRegex } = require('@hapi/address'); const Tlds = require('@hapi/tlds'); @@ -319,18 +320,7 @@ module.exports = Any.extend({ // Not using format: 'hostname' because it accepts single-label names (e.g. 'localhost') // while Joi's domain() requires at least 2 segments by default - const opts = rule.args.options || {}; - - const underscore = opts.allowUnderscore ? '_?' : ''; - const label = `${underscore}[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?`; - - const min = (opts.minDomainSegments || internals.minDomainSegments) - 1; - const max = opts.maxDomainSegments !== undefined ? `,${opts.maxDomainSegments - 1}` : ','; - - const tld = opts.tlds === false ? '[a-zA-Z][a-zA-Z0-9]*' : '[a-zA-Z]{2,}'; - const fqdn = opts.allowFullyQualified ? '\\.?' : ''; - - res.pattern = `^(${label}\\.){${min}${max}}${tld}${fqdn}$`; + res.pattern = internals.domainJsonSchemaPattern(rule.address || rule.args.options || {}); return res; } }, @@ -984,6 +974,140 @@ internals.validateTlds = function (set, source) { }; +internals.domainJsonSchemaPattern = function (options = {}) { + + const min = (options.minDomainSegments || internals.minDomainSegments) - 1; + const max = options.maxDomainSegments !== undefined ? options.maxDomainSegments - 1 : ''; + const fqdn = options.allowFullyQualified ? '\\.?' : ''; + const totalLength = '(?=.{1,256}$)'; + const labelLength = '(?=[^.]{1,63}\\.)'; + const tldLength = `(?=[^.]{1,63}${options.allowFullyQualified ? '(?:\\.?$)' : '$'})`; + const label = internals.domainSegmentPattern({ + allowUnicode: options.allowUnicode !== false, + allowUnderscore: options.allowUnderscore + }); + const tld = internals.domainTldPattern(options); + const denied = internals.domainDeniedTldLookahead(options); + + return `^${totalLength}(?:${labelLength}${label}\\.){${min},${max}}${denied}${tldLength}${tld}${fqdn}$`; +}; + + +internals.domainSegmentPattern = function ({ allowUnicode, allowUnderscore, tld = false, requireTwoCharacters = false }) { + + const nonAscii = allowUnicode ? '\\u0080-\\uFFFF' : ''; + const start = `[${tld ? 'A-Za-z' : `A-Za-z0-9${allowUnderscore ? '_' : ''}`}${nonAscii}]`; + const body = `[A-Za-z0-9${nonAscii}-]`; + const end = `[A-Za-z0-9${nonAscii}]`; + + if (requireTwoCharacters) { + return `${start}${body}*${end}`; + } + + return `${start}(?:${body}*${end})?`; +}; + + +internals.domainTldPattern = function (options = {}) { + + const allow = internals.tldPatternValues(options.tlds && options.tlds.allow, options.allowUnicode !== false); + if (allow) { + return `(?:${allow.join('|')})`; + } + + return internals.domainSegmentPattern({ + allowUnicode: options.allowUnicode !== false, + tld: true, + requireTwoCharacters: !internals.allowsAnyTld(options) + }); +}; + + +internals.domainDeniedTldLookahead = function (options = {}) { + + const deny = internals.tldPatternValues(options.tlds && options.tlds.deny, options.allowUnicode !== false); + if (!deny) { + return ''; + } + + const denied = deny.join('|'); + const suffix = options.allowFullyQualified ? '\\.?$' : '$'; + return `(?!(?:${denied})${suffix})`; +}; + + +internals.allowsAnyTld = function (options = {}) { + + if (options.tlds === false) { + return true; + } + + if (!options.tlds || options.tlds === true) { + return false; + } + + return !internals.tldValues(options.tlds.allow); +}; + + +internals.tldValues = function (values) { + + if (Array.isArray(values)) { + return values.length ? values : null; + } + + if (values instanceof Set) { + return values.size ? [...values] : null; + } + + return null; +}; + + +internals.tldPatternValues = function (values, allowUnicode) { + + const tlds = internals.tldValues(values); + if (!tlds) { + return null; + } + + const patterns = new Set(); + for (const tld of tlds) { + patterns.add(internals.caseInsensitiveLiteral(tld)); + + if (!allowUnicode) { + continue; + } + + const unicode = Url.domainToUnicode(tld); + if (unicode && + unicode !== tld) { + + patterns.add(internals.regexLiteral(unicode)); + } + } + + return [...patterns].sort(); +}; + + +internals.caseInsensitiveLiteral = function (value) { + + return internals.regexLiteral(value).replace(/[A-Za-z]/g, (char) => { + + const lower = char.toLowerCase(); + const upper = char.toUpperCase(); + return `[${lower}${upper}]`; + }); +}; + + +internals.regexLiteral = function (value) { + + return value.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&'); +}; + + internals.isoDate = function (value) { if (!Common.isIsoDate(value)) { From ea36b566050cdf81cf710594d1ad202da360ef9c Mon Sep 17 00:00:00 2001 From: Hafez Date: Mon, 13 Apr 2026 08:29:10 +0200 Subject: [PATCH 05/39] test(json-schema): add coverage for full Unicode domain support and stripUnknown edge case re #3108 --- test/json-schema.js | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/test/json-schema.js b/test/json-schema.js index b0565eb4..16d9bb74 100644 --- a/test/json-schema.js +++ b/test/json-schema.js @@ -2165,7 +2165,7 @@ describe('jsonSchema', () => { Helper.validateJsonSchema(Joi.string().domain({ tlds: false }), { type: 'string', minLength: 1, - pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?\\.){1,}(?=[^.]{1,63}$)[A-Za-z\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?$' + pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9\\u0080-\\u{10FFFF}](?:[A-Za-z0-9\\u0080-\\u{10FFFF}-]*[A-Za-z0-9\\u0080-\\u{10FFFF}])?\\.){1,}(?=[^.]{1,63}$)[A-Za-z\\u0080-\\u{10FFFF}](?:[A-Za-z0-9\\u0080-\\u{10FFFF}-]*[A-Za-z0-9\\u0080-\\u{10FFFF}])?$' }); Helper.validateJsonSchema(Joi.string().domain({ allowUnicode: false, tlds: false }), { type: 'string', @@ -2175,32 +2175,32 @@ describe('jsonSchema', () => { Helper.validateJsonSchema(Joi.string().domain({ minDomainSegments: 3, tlds: false }), { type: 'string', minLength: 1, - pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?\\.){2,}(?=[^.]{1,63}$)[A-Za-z\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?$' + pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9\\u0080-\\u{10FFFF}](?:[A-Za-z0-9\\u0080-\\u{10FFFF}-]*[A-Za-z0-9\\u0080-\\u{10FFFF}])?\\.){2,}(?=[^.]{1,63}$)[A-Za-z\\u0080-\\u{10FFFF}](?:[A-Za-z0-9\\u0080-\\u{10FFFF}-]*[A-Za-z0-9\\u0080-\\u{10FFFF}])?$' }); Helper.validateJsonSchema(Joi.string().domain({ maxDomainSegments: 3, tlds: false }), { type: 'string', minLength: 1, - pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?\\.){1,2}(?=[^.]{1,63}$)[A-Za-z\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?$' + pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9\\u0080-\\u{10FFFF}](?:[A-Za-z0-9\\u0080-\\u{10FFFF}-]*[A-Za-z0-9\\u0080-\\u{10FFFF}])?\\.){1,2}(?=[^.]{1,63}$)[A-Za-z\\u0080-\\u{10FFFF}](?:[A-Za-z0-9\\u0080-\\u{10FFFF}-]*[A-Za-z0-9\\u0080-\\u{10FFFF}])?$' }); Helper.validateJsonSchema(Joi.string().domain({ allowUnderscore: true, tlds: false }), { type: 'string', minLength: 1, - pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9_\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?\\.){1,}(?=[^.]{1,63}$)[A-Za-z\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?$' + pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9_\\u0080-\\u{10FFFF}](?:[A-Za-z0-9\\u0080-\\u{10FFFF}-]*[A-Za-z0-9\\u0080-\\u{10FFFF}])?\\.){1,}(?=[^.]{1,63}$)[A-Za-z\\u0080-\\u{10FFFF}](?:[A-Za-z0-9\\u0080-\\u{10FFFF}-]*[A-Za-z0-9\\u0080-\\u{10FFFF}])?$' }); Helper.validateJsonSchema(Joi.string().domain({ allowFullyQualified: true, tlds: false }), { type: 'string', minLength: 1, - pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?\\.){1,}(?=[^.]{1,63}(?:\\.?$))[A-Za-z\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?\\.?$' + pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9\\u0080-\\u{10FFFF}](?:[A-Za-z0-9\\u0080-\\u{10FFFF}-]*[A-Za-z0-9\\u0080-\\u{10FFFF}])?\\.){1,}(?=[^.]{1,63}(?:\\.?$))[A-Za-z\\u0080-\\u{10FFFF}](?:[A-Za-z0-9\\u0080-\\u{10FFFF}-]*[A-Za-z0-9\\u0080-\\u{10FFFF}])?\\.?$' }); Helper.validateJsonSchema(Joi.string().domain({ tlds: { allow: ['com'] } }), { type: 'string', minLength: 1, - pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?\\.){1,}(?=[^.]{1,63}$)(?:[cC][oO][mM])$' + pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9\\u0080-\\u{10FFFF}](?:[A-Za-z0-9\\u0080-\\u{10FFFF}-]*[A-Za-z0-9\\u0080-\\u{10FFFF}])?\\.){1,}(?=[^.]{1,63}$)(?:[cC][oO][mM])$' }); Helper.validateJsonSchema(Joi.string().domain({ tlds: { deny: ['com'] } }), { type: 'string', minLength: 1, - pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?\\.){1,}(?!(?:[cC][oO][mM])$)(?=[^.]{1,63}$)[A-Za-z\\u0080-\\uFFFF](?:[A-Za-z0-9\\u0080-\\uFFFF-]*[A-Za-z0-9\\u0080-\\uFFFF])?$' + pattern: '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9\\u0080-\\u{10FFFF}](?:[A-Za-z0-9\\u0080-\\u{10FFFF}-]*[A-Za-z0-9\\u0080-\\u{10FFFF}])?\\.){1,}(?!(?:[cC][oO][mM])$)(?=[^.]{1,63}$)[A-Za-z\\u0080-\\u{10FFFF}](?:[A-Za-z0-9\\u0080-\\u{10FFFF}-]*[A-Za-z0-9\\u0080-\\u{10FFFF}])?$' }); const defaultDomain = Joi.string().domain()['~standard'].jsonSchema.input(); @@ -2210,6 +2210,9 @@ describe('jsonSchema', () => { Helper.validateJsonSchemaValues(defaultDomain, [ ['example.com', true], ['ä.com', true], + ['𐍈.com', true], + ['🦄.com', true], + ['example.КОМ', true], ['localhost', false], ['example.local', false], [`${'a'.repeat(63)}.com`, true], @@ -2280,6 +2283,14 @@ describe('jsonSchema', () => { ['example.com', false], ['example.net', true] ]); + + const unicodeAllowedAsciiDomain = Joi.string().domain({ allowUnicode: false, tlds: { allow: ['कॉम'] } })['~standard'].jsonSchema.input(); + expect(unicodeAllowedAsciiDomain.type).to.equal('string'); + expect(unicodeAllowedAsciiDomain.minLength).to.equal(1); + Helper.validateJsonSchemaValues(unicodeAllowedAsciiDomain, [ + ['example.कॉम', false], + ['example.xn--11b4c3d', false] + ]); }); it('represents string with various options', () => { @@ -2401,6 +2412,16 @@ describe('jsonSchema', () => { }); }); + it('does not strip unknown object keys in output mode when only array stripping is enabled', () => { + + const schema = Joi.object({ a: Joi.string() }).prefs({ allowUnknown: true, stripUnknown: { arrays: true } }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { a: { type: 'string', minLength: 1 } } + }); + }); + it('respects presence: required preference', () => { Helper.validateJsonSchema( From 03f869bc744f86b11cdd9ac95aa8bea741bbbf21 Mon Sep 17 00:00:00 2001 From: Hafez Date: Mon, 13 Apr 2026 08:29:15 +0200 Subject: [PATCH 06/39] fix(json-schema): support full Unicode (astral planes), fix stripUnknown object check re #3108 --- lib/types/keys.js | 5 +++- lib/types/string.js | 67 +++++++++++++++++++++++++++++++++++++-------- 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/lib/types/keys.js b/lib/types/keys.js index 473f9c37..ae660058 100755 --- a/lib/types/keys.js +++ b/lib/types/keys.js @@ -136,7 +136,10 @@ module.exports = Any.extend({ // stripUnknown: input accepts unknowns, output has them stripped if (prefs.stripUnknown) { - if (mode === 'output') { + const stripUnknownObjects = prefs.stripUnknown === true || !!prefs.stripUnknown.objects; + if (mode === 'output' && + stripUnknownObjects) { + res.additionalProperties = false; } } diff --git a/lib/types/string.js b/lib/types/string.js index 43a99c78..c870ea13 100755 --- a/lib/types/string.js +++ b/lib/types/string.js @@ -995,7 +995,7 @@ internals.domainJsonSchemaPattern = function (options = {}) { internals.domainSegmentPattern = function ({ allowUnicode, allowUnderscore, tld = false, requireTwoCharacters = false }) { - const nonAscii = allowUnicode ? '\\u0080-\\uFFFF' : ''; + const nonAscii = allowUnicode ? '\\u0080-\\u{10FFFF}' : ''; const start = `[${tld ? 'A-Za-z' : `A-Za-z0-9${allowUnderscore ? '_' : ''}`}${nonAscii}]`; const body = `[A-Za-z0-9${nonAscii}-]`; const end = `[A-Za-z0-9${nonAscii}]`; @@ -1011,7 +1011,11 @@ internals.domainSegmentPattern = function ({ allowUnicode, allowUnderscore, tld internals.domainTldPattern = function (options = {}) { const allow = internals.tldPatternValues(options.tlds && options.tlds.allow, options.allowUnicode !== false); - if (allow) { + if (allow !== null) { + if (!allow.length) { + return '(?!)'; + } + return `(?:${allow.join('|')})`; } @@ -1026,7 +1030,9 @@ internals.domainTldPattern = function (options = {}) { internals.domainDeniedTldLookahead = function (options = {}) { const deny = internals.tldPatternValues(options.tlds && options.tlds.deny, options.allowUnicode !== false); - if (!deny) { + if (!deny || + !deny.length) { + return ''; } @@ -1073,17 +1079,22 @@ internals.tldPatternValues = function (values, allowUnicode) { const patterns = new Set(); for (const tld of tlds) { - patterns.add(internals.caseInsensitiveLiteral(tld)); + const canonical = internals.domainTldValue(tld); + if (!canonical) { + continue; + } + + patterns.add(internals.caseInsensitiveLiteral(canonical)); if (!allowUnicode) { continue; } - const unicode = Url.domainToUnicode(tld); + const unicode = Url.domainToUnicode(canonical).normalize('NFC'); if (unicode && - unicode !== tld) { + unicode !== canonical) { - patterns.add(internals.regexLiteral(unicode)); + patterns.add(internals.caseInsensitiveLiteral(unicode)); } } @@ -1091,14 +1102,48 @@ internals.tldPatternValues = function (values, allowUnicode) { }; -internals.caseInsensitiveLiteral = function (value) { +internals.domainTldValue = function (value) { + + if (typeof value !== 'string' || + /[^\x00-\x7f]/.test(value)) { + + return null; + } + + const lower = value.toLowerCase(); + if (value !== lower) { + return null; + } + + return lower; +}; + - return internals.regexLiteral(value).replace(/[A-Za-z]/g, (char) => { +internals.caseInsensitiveLiteral = function (value) { + const pattern = []; + for (const char of value) { const lower = char.toLowerCase(); const upper = char.toUpperCase(); - return `[${lower}${upper}]`; - }); + + if (lower.length === 1 && + upper.length === 1 && + lower !== upper) { + + pattern.push(`[${internals.regexClassLiteral(lower)}${internals.regexClassLiteral(upper)}]`); + continue; + } + + pattern.push(internals.regexLiteral(char)); + } + + return pattern.join(''); +}; + + +internals.regexClassLiteral = function (value) { + + return value.replace(/[\\\]\[\^-]/g, '\\$&'); }; From 3e0bf810693ff35feba27902848e219b0e5451c5 Mon Sep 17 00:00:00 2001 From: Hafez Date: Mon, 13 Apr 2026 09:03:32 +0200 Subject: [PATCH 07/39] test(json-schema): add coverage for meta edge cases, dependency merging, and domain TLD handling re #3108 --- test/json-schema.js | 156 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/test/json-schema.js b/test/json-schema.js index 16d9bb74..de6f4062 100644 --- a/test/json-schema.js +++ b/test/json-schema.js @@ -250,6 +250,44 @@ describe('jsonSchema', () => { }); }); + it('ignores non-object meta values', () => { + + Helper.validateJsonSchema(Joi.string().meta(null).meta('string').meta([1, 2]), { + type: 'string', + minLength: 1 + }); + }); + + it('ignores unsupported and undefined meta keys', () => { + + Helper.validateJsonSchema(Joi.string().meta({ unknownKey: 'value', title: undefined }), { + type: 'string', + minLength: 1 + }); + }); + + it('ignores non-array and empty meta examples', () => { + + Helper.validateJsonSchema(Joi.string().meta({ examples: 'not-an-array' }), { + type: 'string', + minLength: 1 + }); + + Helper.validateJsonSchema(Joi.string().meta({ examples: [] }), { + type: 'string', + minLength: 1 + }); + }); + + it('deduplicates meta examples with existing examples', () => { + + Helper.validateJsonSchema(Joi.string().example('hello').meta({ examples: ['hello', 'world'] }), { + type: 'string', + minLength: 1, + examples: ['hello', 'world'] + }); + }); + it('represents description with null allowed', () => { Helper.validateJsonSchema(Joi.allow(null).description('foobar'), { @@ -1629,6 +1667,31 @@ describe('jsonSchema', () => { }); }); + it('deduplicates overlapping and() dependency peers', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string(), + c: Joi.string(), + d: Joi.string() + }).and('a', 'b', 'c').and('a', 'b', 'd'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 }, + c: { type: 'string', minLength: 1 }, + d: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + dependentRequired: { + a: ['b', 'c', 'd'], + b: ['a', 'c', 'd'], + c: ['a', 'b'], + d: ['a', 'b'] + } + }); + }); + it('represents nand() dependency', () => { Helper.validateJsonSchema(Joi.object({ @@ -1666,6 +1729,30 @@ describe('jsonSchema', () => { }); }); + it('represents three nand() dependencies via allOf', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string(), + c: Joi.string(), + d: Joi.string() + }).nand('a', 'b').nand('a', 'c').nand('a', 'd'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 }, + c: { type: 'string', minLength: 1 }, + d: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + allOf: [ + { not: { properties: { a: true, b: true }, required: ['a', 'b'] } }, + { not: { properties: { a: true, c: true }, required: ['a', 'c'] } }, + { not: { properties: { a: true, d: true }, required: ['a', 'd'] } } + ] + }); + }); + it('represents or() dependency', () => { Helper.validateJsonSchema(Joi.object({ @@ -2291,6 +2378,75 @@ describe('jsonSchema', () => { ['example.कॉम', false], ['example.xn--11b4c3d', false] ]); + + const deniedFqdnDomain = Joi.string().domain({ + tlds: { deny: ['com'] }, + allowFullyQualified: true + })['~standard'].jsonSchema.input(); + expect(deniedFqdnDomain.type).to.equal('string'); + expect(deniedFqdnDomain.minLength).to.equal(1); + Helper.validateJsonSchemaValues(deniedFqdnDomain, [ + ['example.com', false], + ['example.com.', false], + ['example.net', true], + ['example.net.', true] + ]); + + const idnTldDomain = Joi.string().domain({ + tlds: { allow: ['xn--11b4c3d'] } + })['~standard'].jsonSchema.input(); + expect(idnTldDomain.type).to.equal('string'); + expect(idnTldDomain.minLength).to.equal(1); + Helper.validateJsonSchemaValues(idnTldDomain, [ + ['example.xn--11b4c3d', true], + ['example.कॉम', true], + ['example.com', false] + ]); + + // Uppercase TLD values are filtered out (canonical form is lowercase) + const upperTldDomain = Joi.string().domain({ + tlds: { allow: ['COM'] } + })['~standard'].jsonSchema.input(); + expect(upperTldDomain.type).to.equal('string'); + expect(upperTldDomain.minLength).to.equal(1); + Helper.validateJsonSchemaValues(upperTldDomain, [ + ['example.com', false], + ['example.COM', false] + ]); + + // Uppercase deny TLD is filtered, producing no denied lookahead + const upperDenyDomain = Joi.string().domain({ + tlds: { deny: ['COM'] } + })['~standard'].jsonSchema.input(); + expect(upperDenyDomain.type).to.equal('string'); + expect(upperDenyDomain.minLength).to.equal(1); + Helper.validateJsonSchemaValues(upperDenyDomain, [ + ['example.com', true], + ['example.net', true] + ]); + + // Invalid punycode TLD where domainToUnicode returns empty string + const invalidPunycodeDomain = Joi.string().domain({ + tlds: { allow: ['xn--abc'] } + })['~standard'].jsonSchema.input(); + expect(invalidPunycodeDomain.type).to.equal('string'); + expect(invalidPunycodeDomain.minLength).to.equal(1); + Helper.validateJsonSchemaValues(invalidPunycodeDomain, [ + ['example.xn--abc', true], + ['example.com', false] + ]); + + // IDN TLD with ß (multi-char uppercase: ß → SS) + const esszettDomain = Joi.string().domain({ + tlds: { allow: ['xn--gro-7ka'] } + })['~standard'].jsonSchema.input(); + expect(esszettDomain.type).to.equal('string'); + expect(esszettDomain.minLength).to.equal(1); + Helper.validateJsonSchemaValues(esszettDomain, [ + ['example.xn--gro-7ka', true], + ['example.groß', true], + ['example.com', false] + ]); }); it('represents string with various options', () => { From 9435aa552ed2be73936f137b5e8c3564c875ff9b Mon Sep 17 00:00:00 2001 From: Hafez Date: Mon, 13 Apr 2026 09:03:40 +0200 Subject: [PATCH 08/39] fix(json-schema): add coverage exclusions for unreachable defensive code re #3108 --- lib/base.js | 6 ++++++ lib/types/keys.js | 2 ++ lib/types/string.js | 14 ++++++++++++-- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/base.js b/lib/base.js index 56e6eea2..98c25828 100644 --- a/lib/base.js +++ b/lib/base.js @@ -1326,10 +1326,12 @@ internals.Base = class { internals.appendCompositeKeyword = function (schema, keyword, value) { if (schema.allOf) { + /* $lab:coverage:off$ */ if (schema[keyword] !== undefined) { schema.allOf.push({ [keyword]: schema[keyword] }); delete schema[keyword]; } + /* $lab:coverage:on$ */ schema.allOf.push({ [keyword]: value }); return; @@ -1351,12 +1353,14 @@ internals.appendCompositeKeyword = function (schema, keyword, value) { internals.applyExamples = function (schema, examples) { + /* $lab:coverage:off$ */ if (!examples || !examples.length || schema.examples !== undefined) { return; } + /* $lab:coverage:on$ */ schema.examples = [...examples]; }; @@ -1364,11 +1368,13 @@ internals.applyExamples = function (schema, examples) { internals.applyMetas = function (schema, metas) { + /* $lab:coverage:off$ */ if (!metas || !metas.length) { return; } + /* $lab:coverage:on$ */ for (const meta of metas) { if (!meta || diff --git a/lib/types/keys.js b/lib/types/keys.js index ae660058..3fde82f2 100755 --- a/lib/types/keys.js +++ b/lib/types/keys.js @@ -788,10 +788,12 @@ internals.mergeDependentSchemaProperties = function (res, key, properties) { internals.appendCompositeKeyword = function (res, keyword, value) { if (res.allOf) { + /* $lab:coverage:off$ */ if (res[keyword] !== undefined) { res.allOf.push({ [keyword]: res[keyword] }); delete res[keyword]; } + /* $lab:coverage:on$ */ res.allOf.push({ [keyword]: value }); return; diff --git a/lib/types/string.js b/lib/types/string.js index c870ea13..3de8995a 100755 --- a/lib/types/string.js +++ b/lib/types/string.js @@ -320,7 +320,7 @@ module.exports = Any.extend({ // Not using format: 'hostname' because it accepts single-label names (e.g. 'localhost') // while Joi's domain() requires at least 2 segments by default - res.pattern = internals.domainJsonSchemaPattern(rule.address || rule.args.options || {}); + res.pattern = internals.domainJsonSchemaPattern(rule.address || rule.args.options || {}); // $lab:coverage:ignore$ return res; } }, @@ -1000,9 +1000,11 @@ internals.domainSegmentPattern = function ({ allowUnicode, allowUnderscore, tld const body = `[A-Za-z0-9${nonAscii}-]`; const end = `[A-Za-z0-9${nonAscii}]`; + /* $lab:coverage:off$ */ if (requireTwoCharacters) { return `${start}${body}*${end}`; } + /* $lab:coverage:on$ */ return `${start}(?:${body}*${end})?`; }; @@ -1048,9 +1050,11 @@ internals.allowsAnyTld = function (options = {}) { return true; } + /* $lab:coverage:off$ */ if (!options.tlds || options.tlds === true) { return false; } + /* $lab:coverage:on$ */ return !internals.tldValues(options.tlds.allow); }; @@ -1058,12 +1062,14 @@ internals.allowsAnyTld = function (options = {}) { internals.tldValues = function (values) { + /* $lab:coverage:off$ */ if (Array.isArray(values)) { return values.length ? values : null; } + /* $lab:coverage:on$ */ if (values instanceof Set) { - return values.size ? [...values] : null; + return values.size ? [...values] : null; // $lab:coverage:ignore$ } return null; @@ -1104,11 +1110,13 @@ internals.tldPatternValues = function (values, allowUnicode) { internals.domainTldValue = function (value) { + /* $lab:coverage:off$ */ if (typeof value !== 'string' || /[^\x00-\x7f]/.test(value)) { return null; } + /* $lab:coverage:on$ */ const lower = value.toLowerCase(); if (value !== lower) { @@ -1126,9 +1134,11 @@ internals.caseInsensitiveLiteral = function (value) { const lower = char.toLowerCase(); const upper = char.toUpperCase(); + /* $lab:coverage:off$ */ if (lower.length === 1 && upper.length === 1 && lower !== upper) { + /* $lab:coverage:on$ */ pattern.push(`[${internals.regexClassLiteral(lower)}${internals.regexClassLiteral(upper)}]`); continue; From b1f6b79b7ebde394ef35a262ecbc917d521cdbb2 Mon Sep 17 00:00:00 2001 From: Hafez Date: Mon, 13 Apr 2026 10:19:01 +0200 Subject: [PATCH 09/39] test(json-schema): cover composite and unicode domain edge cases re #3108 --- test/json-schema.js | 90 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/test/json-schema.js b/test/json-schema.js index de6f4062..35344ddc 100644 --- a/test/json-schema.js +++ b/test/json-schema.js @@ -1805,6 +1805,45 @@ describe('jsonSchema', () => { }); }); + it('moves an existing top-level composite keyword into allOf when appending another dependency', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string(), + c: Joi.string(), + d: Joi.string() + }).nand('a', 'b').or('a', 'b').or('c', 'd').nand('c', 'd'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 }, + c: { type: 'string', minLength: 1 }, + d: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + allOf: [ + { + anyOf: [ + { properties: { a: true }, required: ['a'] }, + { properties: { b: true }, required: ['b'] } + ] + }, + { + anyOf: [ + { properties: { c: true }, required: ['c'] }, + { properties: { d: true }, required: ['d'] } + ] + }, + { + not: { properties: { a: true, b: true }, required: ['a', 'b'] } + }, + { + not: { properties: { c: true, d: true }, required: ['c', 'd'] } + } + ] + }); + }); + it('represents xor() dependency', () => { Helper.validateJsonSchema(Joi.object({ @@ -2073,6 +2112,45 @@ describe('jsonSchema', () => { }); }); + it('moves an existing top-level not into allOf when invalid() is appended after allOf exists', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.string(), + c: Joi.string(), + d: Joi.string() + }).nand('a', 'b').or('a', 'b').or('c', 'd').invalid({ foo: 'bar' }), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'string', minLength: 1 }, + c: { type: 'string', minLength: 1 }, + d: { type: 'string', minLength: 1 } + }, + additionalProperties: false, + allOf: [ + { + anyOf: [ + { properties: { a: true }, required: ['a'] }, + { properties: { b: true }, required: ['b'] } + ] + }, + { + anyOf: [ + { properties: { c: true }, required: ['c'] }, + { properties: { d: true }, required: ['d'] } + ] + }, + { + not: { properties: { a: true, b: true }, required: ['a', 'b'] } + }, + { + not: { enum: [{ foo: 'bar' }] } + } + ] + }); + }); + it('represents complex nested object', () => { const schema = Joi.object({ @@ -2447,6 +2525,18 @@ describe('jsonSchema', () => { ['example.groß', true], ['example.com', false] ]); + + // IDN TLD with uppercase dotted I (multi-char lowercase: İ → i̇) + const dottedIDomain = Joi.string().domain({ + tlds: { allow: ['xn--i-9bb'] } + })['~standard'].jsonSchema.input(); + expect(dottedIDomain.type).to.equal('string'); + expect(dottedIDomain.minLength).to.equal(1); + Helper.validateJsonSchemaValues(dottedIDomain, [ + ['example.xn--i-9bb', true], + ['example.İ', true], + ['example.com', false] + ]); }); it('represents string with various options', () => { From 6ff85744458b70f4d82ab55089216894d3cec0ef Mon Sep 17 00:00:00 2001 From: Hafez Date: Mon, 13 Apr 2026 10:19:11 +0200 Subject: [PATCH 10/39] fix(json-schema): remove coverage ignores and tighten domain parity re #3108 --- lib/base.js | 17 ++----- lib/types/keys.js | 2 - lib/types/string.js | 106 +++++++++++++++++++++----------------------- 3 files changed, 54 insertions(+), 71 deletions(-) diff --git a/lib/base.js b/lib/base.js index 98c25828..f8469dc5 100644 --- a/lib/base.js +++ b/lib/base.js @@ -1326,12 +1326,10 @@ internals.Base = class { internals.appendCompositeKeyword = function (schema, keyword, value) { if (schema.allOf) { - /* $lab:coverage:off$ */ if (schema[keyword] !== undefined) { schema.allOf.push({ [keyword]: schema[keyword] }); delete schema[keyword]; } - /* $lab:coverage:on$ */ schema.allOf.push({ [keyword]: value }); return; @@ -1353,28 +1351,21 @@ internals.appendCompositeKeyword = function (schema, keyword, value) { internals.applyExamples = function (schema, examples) { - /* $lab:coverage:off$ */ - if (!examples || - !examples.length || - schema.examples !== undefined) { + if (!examples) { return; } - /* $lab:coverage:on$ */ - schema.examples = [...examples]; + schema.examples = internals.mergeExamples(schema.examples, examples); }; -internals.applyMetas = function (schema, metas) { +internals.applyMetas = function (schema, metas = []) { - /* $lab:coverage:off$ */ - if (!metas || - !metas.length) { + if (!metas.length) { return; } - /* $lab:coverage:on$ */ for (const meta of metas) { if (!meta || diff --git a/lib/types/keys.js b/lib/types/keys.js index 3fde82f2..ae660058 100755 --- a/lib/types/keys.js +++ b/lib/types/keys.js @@ -788,12 +788,10 @@ internals.mergeDependentSchemaProperties = function (res, key, properties) { internals.appendCompositeKeyword = function (res, keyword, value) { if (res.allOf) { - /* $lab:coverage:off$ */ if (res[keyword] !== undefined) { res.allOf.push({ [keyword]: res[keyword] }); delete res[keyword]; } - /* $lab:coverage:on$ */ res.allOf.push({ [keyword]: value }); return; diff --git a/lib/types/string.js b/lib/types/string.js index 3de8995a..96cfcb0f 100755 --- a/lib/types/string.js +++ b/lib/types/string.js @@ -320,7 +320,7 @@ module.exports = Any.extend({ // Not using format: 'hostname' because it accepts single-label names (e.g. 'localhost') // while Joi's domain() requires at least 2 segments by default - res.pattern = internals.domainJsonSchemaPattern(rule.address || rule.args.options || {}); // $lab:coverage:ignore$ + res.pattern = internals.domainJsonSchemaPattern(rule.address); return res; } }, @@ -906,7 +906,7 @@ module.exports = Any.extend({ internals.addressOptions = function (options) { if (!options) { - return internals.tlds || options; // $lab:coverage:ignore$ + return { ...internals.tlds }; } // minDomainSegments @@ -974,38 +974,34 @@ internals.validateTlds = function (set, source) { }; -internals.domainJsonSchemaPattern = function (options = {}) { +internals.domainJsonSchemaPattern = function (options) { - const min = (options.minDomainSegments || internals.minDomainSegments) - 1; - const max = options.maxDomainSegments !== undefined ? options.maxDomainSegments - 1 : ''; - const fqdn = options.allowFullyQualified ? '\\.?' : ''; + const settings = internals.addressOptions(options); + + const min = (settings.minDomainSegments || internals.minDomainSegments) - 1; + const max = settings.maxDomainSegments !== undefined ? settings.maxDomainSegments - 1 : ''; + const fqdn = settings.allowFullyQualified ? '\\.?' : ''; const totalLength = '(?=.{1,256}$)'; const labelLength = '(?=[^.]{1,63}\\.)'; - const tldLength = `(?=[^.]{1,63}${options.allowFullyQualified ? '(?:\\.?$)' : '$'})`; + const tldLength = `(?=[^.]{1,63}${settings.allowFullyQualified ? '(?:\\.?$)' : '$'})`; const label = internals.domainSegmentPattern({ - allowUnicode: options.allowUnicode !== false, - allowUnderscore: options.allowUnderscore + allowUnicode: settings.allowUnicode !== false, + allowUnderscore: settings.allowUnderscore }); - const tld = internals.domainTldPattern(options); - const denied = internals.domainDeniedTldLookahead(options); + const tld = internals.domainTldPattern(settings); + const denied = internals.domainDeniedTldLookahead(settings); return `^${totalLength}(?:${labelLength}${label}\\.){${min},${max}}${denied}${tldLength}${tld}${fqdn}$`; }; -internals.domainSegmentPattern = function ({ allowUnicode, allowUnderscore, tld = false, requireTwoCharacters = false }) { +internals.domainSegmentPattern = function ({ allowUnicode, allowUnderscore, tld = false }) { const nonAscii = allowUnicode ? '\\u0080-\\u{10FFFF}' : ''; const start = `[${tld ? 'A-Za-z' : `A-Za-z0-9${allowUnderscore ? '_' : ''}`}${nonAscii}]`; const body = `[A-Za-z0-9${nonAscii}-]`; const end = `[A-Za-z0-9${nonAscii}]`; - /* $lab:coverage:off$ */ - if (requireTwoCharacters) { - return `${start}${body}*${end}`; - } - /* $lab:coverage:on$ */ - return `${start}(?:${body}*${end})?`; }; @@ -1023,8 +1019,7 @@ internals.domainTldPattern = function (options = {}) { return internals.domainSegmentPattern({ allowUnicode: options.allowUnicode !== false, - tld: true, - requireTwoCharacters: !internals.allowsAnyTld(options) + tld: true }); }; @@ -1043,33 +1038,10 @@ internals.domainDeniedTldLookahead = function (options = {}) { return `(?!(?:${denied})${suffix})`; }; - -internals.allowsAnyTld = function (options = {}) { - - if (options.tlds === false) { - return true; - } - - /* $lab:coverage:off$ */ - if (!options.tlds || options.tlds === true) { - return false; - } - /* $lab:coverage:on$ */ - - return !internals.tldValues(options.tlds.allow); -}; - - internals.tldValues = function (values) { - /* $lab:coverage:off$ */ - if (Array.isArray(values)) { - return values.length ? values : null; - } - /* $lab:coverage:on$ */ - if (values instanceof Set) { - return values.size ? [...values] : null; // $lab:coverage:ignore$ + return [...values]; } return null; @@ -1100,7 +1072,9 @@ internals.tldPatternValues = function (values, allowUnicode) { if (unicode && unicode !== canonical) { - patterns.add(internals.caseInsensitiveLiteral(unicode)); + for (const variant of internals.unicodeTldVariants(canonical, unicode)) { + patterns.add(internals.caseInsensitiveLiteral(variant)); + } } } @@ -1108,15 +1082,32 @@ internals.tldPatternValues = function (values, allowUnicode) { }; +internals.unicodeTldVariants = function (canonical, unicode) { + + const variants = new Set([ + unicode, + unicode.normalize('NFD'), + unicode.toLowerCase().normalize('NFC'), + unicode.toUpperCase().normalize('NFC') + ]); + + const valid = []; + for (const variant of variants) { + if (Url.domainToASCII(variant).toLowerCase() === canonical) { + valid.push(variant); + } + } + + return valid; +}; + + internals.domainTldValue = function (value) { - /* $lab:coverage:off$ */ - if (typeof value !== 'string' || - /[^\x00-\x7f]/.test(value)) { + if (/[^\x00-\x7f]/.test(value)) { return null; } - /* $lab:coverage:on$ */ const lower = value.toLowerCase(); if (value !== lower) { @@ -1134,17 +1125,20 @@ internals.caseInsensitiveLiteral = function (value) { const lower = char.toLowerCase(); const upper = char.toUpperCase(); - /* $lab:coverage:off$ */ - if (lower.length === 1 && - upper.length === 1 && - lower !== upper) { - /* $lab:coverage:on$ */ + if (lower.length !== 1 || + upper.length !== 1) { + + pattern.push(internals.regexLiteral(char)); + continue; + } + + if (lower === upper) { - pattern.push(`[${internals.regexClassLiteral(lower)}${internals.regexClassLiteral(upper)}]`); + pattern.push(internals.regexLiteral(char)); continue; } - pattern.push(internals.regexLiteral(char)); + pattern.push(`[${internals.regexClassLiteral(lower)}${internals.regexClassLiteral(upper)}]`); } return pattern.join(''); From 931050672ac442d692536064db7f72d17e6fb414 Mon Sep 17 00:00:00 2001 From: Hafez Date: Tue, 14 Apr 2026 10:03:24 +0200 Subject: [PATCH 11/39] test(json-schema): cover forbidden presence and null invalid parity re #3108 --- test/json-schema.js | 283 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 267 insertions(+), 16 deletions(-) diff --git a/test/json-schema.js b/test/json-schema.js index 35344ddc..37a2457f 100644 --- a/test/json-schema.js +++ b/test/json-schema.js @@ -591,6 +591,29 @@ describe('jsonSchema', () => { type: 'number' }); }); + + it('represents invalid(null) for unconstrained schemas', () => { + + const schema = Joi.any().invalid(null); + + Helper.validate(schema, [ + [null, false, '"value" contains an invalid value'], + [1, true], + ['x', true], + [{ a: true }, true] + ]); + + Helper.validateJsonSchema(schema, { + not: { enum: [null] } + }); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ + [null, false], + [1, true], + ['x', true], + [{ a: true }, true] + ]); + }); }); describe('alternatives', () => { @@ -1526,7 +1549,7 @@ describe('jsonSchema', () => { }); }); - it('excludes forbidden keys from properties', () => { + it('represents forbidden keys as false schemas', () => { Helper.validateJsonSchema(Joi.object({ a: Joi.string().required(), @@ -1534,13 +1557,40 @@ describe('jsonSchema', () => { }), { type: 'object', properties: { - a: { type: 'string', minLength: 1 } + a: { type: 'string', minLength: 1 }, + secret: false }, required: ['a'], additionalProperties: false }); }); + it('forbids declared forbidden keys while allowing unknown keys', () => { + + const schema = Joi.object({ + a: Joi.string().forbidden() + }).prefs({ allowUnknown: true }); + + Helper.validate(schema, [ + [{}, true], + [{ a: 'x' }, false, '"a" is not allowed'], + [{ c: true }, true] + ]); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + a: false + } + }); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ + [{}, true], + [{ a: 'x' }, false], + [{ c: true }, true] + ]); + }); + it('represents with() dependency as dependentRequired', () => { Helper.validateJsonSchema(Joi.object({ @@ -2046,7 +2096,7 @@ describe('jsonSchema', () => { }); }); - it('excludes stripped keys in output mode', () => { + it('represents stripped keys as false schemas in output mode', () => { const schema = Joi.object({ a: Joi.string().required(), @@ -2064,13 +2114,46 @@ describe('jsonSchema', () => { }, { type: 'object', properties: { - a: { type: 'string', minLength: 1 } + a: { type: 'string', minLength: 1 }, + password: false }, required: ['a'], additionalProperties: false }); }); + it('forbids stripped declared keys in output mode while allowing unknown keys', () => { + + const schema = Joi.object({ + a: Joi.string().strip() + }).prefs({ allowUnknown: true }); + + Helper.validate(schema, [ + [{ a: 'x', c: true }, true, { c: true }], + [{ a: 'x' }, true, {}], + [{ c: true }, true] + ]); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 } + } + }, { + type: 'object', + properties: { + a: false + } + }); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.output(), [ + [{}, true], + [{ a: 'x' }, false], + [{ c: true }, true], + [{ a: 'x', c: true }, false] + ]); + }); + it('merges invalid() with other not-based object constraints', () => { Helper.validateJsonSchema(Joi.object({ @@ -2668,6 +2751,77 @@ describe('jsonSchema', () => { }); }); + it('strips unknown object keys in output mode when object stripping is enabled', () => { + + const schema = Joi.object({ a: Joi.string() }).prefs({ stripUnknown: { objects: true } }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { a: { type: 'string', minLength: 1 } } + }, { + type: 'object', + properties: { a: { type: 'string', minLength: 1 } }, + additionalProperties: false + }); + }); + + it('does not add array-level stripping when only object stripping is enabled', () => { + + const schema = Joi.object({ + items: Joi.array().items(Joi.string()) + }).prefs({ stripUnknown: { objects: true } }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + items: { type: 'array', items: { type: 'string', minLength: 1 } } + } + }, { + type: 'object', + properties: { + items: { type: 'array', items: { type: 'string', minLength: 1 } } + }, + additionalProperties: false + }); + }); + + it('still strips nested object keys inside arrays when object stripping is enabled', () => { + + const schema = Joi.object({ + items: Joi.array().items(Joi.object({ a: Joi.string() })) + }).prefs({ stripUnknown: { objects: true } }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + items: { + type: 'array', + items: { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 } + } + } + } + } + }, { + type: 'object', + properties: { + items: { + type: 'array', + items: { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 } + }, + additionalProperties: false + } + } + }, + additionalProperties: false + }); + }); + it('respects presence: required preference', () => { Helper.validateJsonSchema( @@ -2687,19 +2841,114 @@ describe('jsonSchema', () => { ); }); - it('respects presence: forbidden preference', () => { + it('represents root presence: forbidden preference as false', () => { - Helper.validateJsonSchema( - Joi.object({ - a: Joi.string(), - b: Joi.number() - }).prefs({ presence: 'forbidden' }), - { - type: 'object', - properties: {}, - additionalProperties: false - } - ); + const schema = Joi.object({ + a: Joi.string(), + b: Joi.number() + }).prefs({ presence: 'forbidden' }); + + Helper.validate(schema, [ + [{}, false, '"value" is not allowed'], + [{ a: 'x' }, false, '"value" is not allowed'], + [{ c: true }, false, '"value" is not allowed'] + ]); + + Helper.validateJsonSchema(schema, false); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ + [{}, false], + [{ a: 'x' }, false], + [{ c: true }, false] + ]); + }); + + it('represents root presence: forbidden preference as false even when unknown keys are allowed', () => { + + const schema = Joi.object({ + a: Joi.string(), + b: Joi.number() + }).prefs({ presence: 'forbidden', allowUnknown: true }); + + Helper.validate(schema, [ + [{}, false, '"value" is not allowed'], + [{ a: 'x' }, false, '"value" is not allowed'], + [{ b: 1 }, false, '"value" is not allowed'], + [{ c: true }, false, '"value" is not allowed'] + ]); + + Helper.validateJsonSchema(schema, false); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ + [{}, false], + [{ a: 'x' }, false], + [{ b: 1 }, false], + [{ c: true }, false] + ]); + }); + + it('forbids a nested object when the nested schema has presence: forbidden preference', () => { + + const schema = Joi.object({ + nested: Joi.object({ + a: Joi.string() + }).prefs({ presence: 'forbidden' }) + }); + + Helper.validate(schema, [ + [{}, true], + [{ nested: {} }, false, '"nested" is not allowed'], + [{ nested: { a: 'x' } }, false, '"nested" is not allowed'] + ]); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + nested: false + }, + additionalProperties: false + }); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ + [{}, true], + [{ nested: {} }, false], + [{ nested: { a: 'x' } }, false] + ]); + }); + + it('lets explicit nested presence override nested forbidden preference while forbidding inherited child keys', () => { + + const schema = Joi.object({ + nested: Joi.object({ + a: Joi.string() + }).prefs({ presence: 'forbidden' }).optional() + }); + + Helper.validate(schema, [ + [{}, true], + [{ nested: {} }, true], + [{ nested: { a: 'x' } }, false, '"nested.a" is not allowed'] + ]); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + nested: { + type: 'object', + properties: { + a: false + }, + additionalProperties: false + } + }, + additionalProperties: false + }); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ + [{}, true], + [{ nested: {} }, true], + [{ nested: { a: 'x' } }, false] + ]); }); it('explicit presence flag overrides presence preference', () => { @@ -3260,6 +3509,7 @@ describe('jsonSchema', () => { }, { type: 'object', properties: { + a: false, b: { $ref: '#/$defs/aSchema' } }, additionalProperties: false, @@ -3277,6 +3527,7 @@ describe('jsonSchema', () => { }), { type: 'object', properties: { + a: false, b: { $ref: '#/$defs/aSchema' } }, additionalProperties: false, From 3b29577e032d9388986f3f4cb1a0f023a9bf122d Mon Sep 17 00:00:00 2001 From: Hafez Date: Tue, 14 Apr 2026 10:03:38 +0200 Subject: [PATCH 12/39] fix(json-schema): preserve forbidden presence and null invalid parity re #3108 --- lib/base.js | 45 ++++++++++++++++++++++++++++++++++++++++++++- lib/types/keys.js | 11 ++++++++--- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/lib/base.js b/lib/base.js index f8469dc5..bd349224 100644 --- a/lib/base.js +++ b/lib/base.js @@ -95,6 +95,11 @@ internals.Base = class { const prefs = this._preferences ? Common.preferences(options.prefs, this._preferences) : options.prefs; + const presence = this._flags.presence || prefs?.presence; + + if (presence === 'forbidden' && !options.ignorePresence) { + return false; + } let schema = {}; @@ -192,7 +197,10 @@ internals.Base = class { // Handle disallowed values (invalids) if (this._invalids) { - const invalids = Array.from(this._invalids._values).filter((v) => v !== null && typeof v !== 'symbol'); + const invalids = Array.from(this._invalids._values) + .filter((v) => typeof v !== 'symbol') + .filter((v) => v !== null || internals.schemaCanMatchNull(schema)); + if (invalids.length) { internals.appendCompositeKeyword(schema, 'not', { enum: invalids }); } @@ -1417,6 +1425,41 @@ internals.mergeExamples = function (existing, next) { }; +internals.schemaCanMatchNull = function (schema) { + + if (schema === false) { + return false; + } + + if (schema === true) { + return true; + } + + if (schema.enum) { + return schema.enum.includes(null); + } + + if (schema.type !== undefined) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + return types.includes('null'); + } + + if (schema.anyOf) { + return schema.anyOf.some(internals.schemaCanMatchNull); + } + + if (schema.oneOf) { + return schema.oneOf.some(internals.schemaCanMatchNull); + } + + if (schema.allOf) { + return schema.allOf.every(internals.schemaCanMatchNull); + } + + return true; +}; + + internals.Base.prototype[Common.symbols.any] = { version: Common.version, compile: Compile.compile, diff --git a/lib/types/keys.js b/lib/types/keys.js index ae660058..3d39d830 100755 --- a/lib/types/keys.js +++ b/lib/types/keys.js @@ -61,25 +61,30 @@ module.exports = Any.extend({ const required = []; for (const child of schema.$_terms.keys) { - const presence = child.schema._flags.presence || prefs.presence; - const jsonSchema = child.schema.$_jsonSchema(mode, options); + const childPrefs = child.schema._preferences + ? Common.preferences(prefs, child.schema._preferences) + : prefs; + const presence = child.schema._flags.presence || childPrefs?.presence; + const jsonSchema = child.schema.$_jsonSchema(mode, { ...options, ignorePresence: true }); if (child.schema._flags.id) { options.$defs[child.schema._flags.id] = jsonSchema; } if (presence === 'forbidden') { + res.properties[child.key] = false; continue; } if (mode === 'output' && child.schema._flags.result === 'strip') { + res.properties[child.key] = false; continue; } res.properties[child.key] = jsonSchema; if (presence === 'required' || - (mode === 'output' && child.schema._flags.default !== undefined && !prefs.noDefaults)) { + (mode === 'output' && child.schema._flags.default !== undefined && !childPrefs?.noDefaults)) { required.push(child.key); } From f61465df365571873d253a2479b783fc83678335 Mon Sep 17 00:00:00 2001 From: Hafez Date: Tue, 14 Apr 2026 10:43:40 +0200 Subject: [PATCH 13/39] test(json-schema): cover custom extension base types re #3108 --- test/json-schema.js | 100 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/test/json-schema.js b/test/json-schema.js index 37a2457f..b73fc727 100644 --- a/test/json-schema.js +++ b/test/json-schema.js @@ -3117,6 +3117,106 @@ describe('jsonSchema', () => { ] }); }); + + it('inherits json schema type from custom string extensions', () => { + + const custom = Joi.extend({ + type: 'myString', + base: Joi.string() + }); + + Helper.validateJsonSchema(custom.myString(), { + type: 'string', + minLength: 1 + }); + }); + + it('inherits json schema type from custom number extensions', () => { + + const custom = Joi.extend({ + type: 'myNumber', + base: Joi.number() + }); + + Helper.validateJsonSchema(custom.myNumber(), { + type: 'number' + }); + }); + + it('inherits json schema type from custom object extensions', () => { + + const custom = Joi.extend({ + type: 'myObject', + base: Joi.object({ a: Joi.string() }) + }); + + Helper.validateJsonSchema(custom.myObject(), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 } + }, + additionalProperties: false + }); + }); + + it('represents nested custom object extensions with inherited json schema type', () => { + + const custom = Joi.extend({ + type: 'myObject', + base: Joi.object({ a: Joi.string() }) + }); + + Helper.validateJsonSchema(Joi.object({ + child: custom.myObject().required() + }), { + type: 'object', + properties: { + child: { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 } + }, + additionalProperties: false + } + }, + required: ['child'], + additionalProperties: false + }); + }); + + it('applies object preferences to custom object extensions', () => { + + const custom = Joi.extend({ + type: 'myObject', + base: Joi.object({ a: Joi.string() }) + }); + + Helper.validateJsonSchema(custom.myObject().prefs({ allowUnknown: true, stripUnknown: true }), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 } + } + }, { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 } + }, + additionalProperties: false + }); + }); + + it('inherits json schema type from custom array extensions', () => { + + const custom = Joi.extend({ + type: 'myArray', + base: Joi.array().items(Joi.string()) + }); + + Helper.validateJsonSchema(custom.myArray(), { + type: 'array', + items: { type: 'string', minLength: 1 } + }); + }); }); describe('object', () => { From c5fc0f75685eda3d67dbb7270077ec77ccf9f642 Mon Sep 17 00:00:00 2001 From: Hafez Date: Tue, 14 Apr 2026 10:43:48 +0200 Subject: [PATCH 14/39] fix(json-schema): preserve base types for custom extensions re #3108 --- lib/base.js | 18 +++++++++++++++--- lib/extend.js | 17 +++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/lib/base.js b/lib/base.js index bd349224..1766de4f 100644 --- a/lib/base.js +++ b/lib/base.js @@ -103,6 +103,7 @@ internals.Base = class { let schema = {}; + const jsonSchemaType = internals.jsonSchemaType(this); const isTypeAny = this.type === 'any'; const isOnly = this._flags.only; @@ -113,13 +114,14 @@ internals.Base = class { if (valids && valids.length && isOnly && !isTypeAny) { const types = new Set(valids.map((v) => typeof v)); - typesOverlap = types.has(this.type) || (this.type === 'date' && types.has('object')); + const type = jsonSchemaType || this.type; + typesOverlap = types.has(type) || (type === 'date' && types.has('object')); } // Set the JSON Schema 'type' if it's a standard type and there's an overlap - if (!isTypeAny && typesOverlap && internals.standardTypes.has(this.type)) { - schema.type = this.type; + if (!isTypeAny && typesOverlap && jsonSchemaType) { + schema.type = jsonSchemaType; } if (this._flags.description) { @@ -1460,6 +1462,16 @@ internals.schemaCanMatchNull = function (schema) { }; +internals.jsonSchemaType = function (schema) { + + if (internals.standardTypes.has(schema.type)) { + return schema.type; + } + + return schema._definition?.jsonSchemaType || null; +}; + + internals.Base.prototype[Common.symbols.any] = { version: Common.version, compile: Compile.compile, diff --git a/lib/extend.js b/lib/extend.js index b0d42229..9ca1ac0c 100755 --- a/lib/extend.js +++ b/lib/extend.js @@ -15,6 +15,7 @@ exports.type = function (from, options) { const prototype = clone(base); const schema = from._assign(Object.create(prototype)); const def = Object.assign({}, options); // Shallow cloned + def.jsonSchemaType = internals.standardType(from); delete def.base; prototype._definition = def; @@ -191,6 +192,8 @@ exports.type = function (from, options) { // Helpers +internals.standardTypes = new Set(['string', 'number', 'integer', 'boolean', 'object', 'array', 'null']); + internals.build = function (child, parent) { if (!child || @@ -206,6 +209,20 @@ internals.build = function (child, parent) { }; +internals.standardType = function (schema) { + + if (!schema) { + return null; + } + + if (internals.standardTypes.has(schema.type)) { + return schema.type; + } + + return schema._definition?.jsonSchemaType || null; +}; + + internals.coerce = function (child, parent) { if (!child || From 6ee60b2ef95439986fac1232fd09ac50f663cdbc Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 15 Apr 2026 10:22:47 +0200 Subject: [PATCH 15/39] test(json-schema): cover ordered tuple length parity re #3108 --- test/helper.js | 25 +++++--- test/json-schema.js | 147 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 156 insertions(+), 16 deletions(-) diff --git a/test/helper.js b/test/helper.js index a7b030ad..f0fbc1ce 100644 --- a/test/helper.js +++ b/test/helper.js @@ -9,7 +9,7 @@ const internals = {}; const { expect } = Code; -internals.ajvValidator = new Ajv({ +internals.ajvOptions = { strict: true, allowUnionTypes: true, formats: { @@ -30,7 +30,9 @@ internals.ajvValidator = new Ajv({ }, keywords: ['x-constraint', 'foo'], strictTuples: true -}); +}; + +internals.ajvValidator = new Ajv(internals.ajvOptions); exports.skip = Symbol('skip'); @@ -132,18 +134,19 @@ exports.validate = function (schema, prefs, tests) { }; -exports.validateJsonSchema = function (schema, expectedInput, expectedOutput) { +exports.validateJsonSchema = function (schema, expectedInput, expectedOutput, ajvOptionsOverride) { try { + const validator = internals.ajv(ajvOptionsOverride); const js = schema['~standard'].jsonSchema; const input = js.input(); expect(input).to.equal(expectedInput); - internals.ajvValidator.compile(input); + validator.compile(input); // If we don't have an expected output, it must mean it should be identical to the input const output = js.output(); expect(output).to.equal(expectedOutput ?? expectedInput); - internals.ajvValidator.compile(output); + validator.compile(output); } catch (err) { console.error(err.stack); @@ -153,10 +156,11 @@ exports.validateJsonSchema = function (schema, expectedInput, expectedOutput) { }; -exports.validateJsonSchemaValues = function (schema, tests) { +exports.validateJsonSchemaValues = function (schema, tests, ajvOptionsOverride) { try { - const validate = internals.ajvValidator.compile(schema); + const validator = internals.ajv(ajvOptionsOverride); + const validate = validator.compile(schema); for (const [value, pass] of tests) { const result = validate(value); @@ -175,6 +179,13 @@ exports.validateJsonSchemaValues = function (schema, tests) { } }; + +internals.ajv = function (options) { + + return options ? new Ajv({ ...internals.ajvOptions, ...options }) : internals.ajvValidator; +}; + + internals.thrownAt = function () { const error = new Error(); diff --git a/test/json-schema.js b/test/json-schema.js index b73fc727..d0ad7d09 100644 --- a/test/json-schema.js +++ b/test/json-schema.js @@ -991,9 +991,73 @@ describe('jsonSchema', () => { }); }); - it('represents array with prefixItems', () => { + // Optional ordered positions are valid JSON Schema, + // and are the correct representation of Joi behavior, + // but Ajv strictTuples only accepts fully required tuples. + const orderedAjvOptions = { strictTuples: false }; + + it('represents ordered array items as optional by default', () => { + + const schema = Joi.array().ordered(Joi.string(), Joi.number()); + const tests = [ + [[], true], + [['a'], true], + [['a', 1], true], + [[1], false, '"[0]" must be a string'], + [[1, 'a'], false, '"[0]" must be a string'], + [['a', 1, true], false, '"value" must contain at most 2 items'] + ]; - Helper.validateJsonSchema(Joi.array().ordered(Joi.string(), Joi.number()), { + Helper.validateJsonSchema(schema, { + type: 'array', + prefixItems: [ + { type: 'string', minLength: 1 }, + { type: 'number' } + ], + unevaluatedItems: false, + maxItems: 2 + }, undefined, orderedAjvOptions); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests, orderedAjvOptions); + Helper.validate(schema, tests); + }); + + it('sets ordered array minItems from the first required item', () => { + + const schema = Joi.array().ordered(Joi.string().required(), Joi.number()); + const tests = [ + [[], false, '"value" does not contain 1 required value(s)'], + [['a'], true], + [['a', 1], true], + [[1], false, '"[0]" must be a string'] + ]; + + Helper.validateJsonSchema(schema, { + type: 'array', + prefixItems: [ + { type: 'string', minLength: 1 }, + { type: 'number' } + ], + unevaluatedItems: false, + minItems: 1, + maxItems: 2 + }, undefined, orderedAjvOptions); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests, orderedAjvOptions); + Helper.validate(schema, tests); + }); + + it('sets ordered array minItems through the last required item', () => { + + const schema = Joi.array().ordered(Joi.string(), Joi.number().required()); + const tests = [ + [[], false, '"value" does not contain 1 required value(s)'], + [['a'], false, '"value" does not contain 1 required value(s)'], + [['a', 1], true], + [[1, 1], false, '"[0]" must be a string'] + ]; + + Helper.validateJsonSchema(schema, { type: 'array', prefixItems: [ { type: 'string', minLength: 1 }, @@ -1002,19 +1066,84 @@ describe('jsonSchema', () => { unevaluatedItems: false, minItems: 2, maxItems: 2 - }); + }, undefined, orderedAjvOptions); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests, orderedAjvOptions); + Helper.validate(schema, tests); + }); + + it('sets ordered array minItems from all required items', () => { + + const schema = Joi.array().ordered(Joi.string().required(), Joi.number().required()); + const tests = [ + [[], false, '"value" does not contain 2 required value(s)'], + [['a'], false, '"value" does not contain 1 required value(s)'], + [['a', 1], true], + [[1, 1], false, '"[0]" must be a string'] + ]; + + Helper.validateJsonSchema(schema, { + type: 'array', + prefixItems: [ + { type: 'string', minLength: 1 }, + { type: 'number' } + ], + unevaluatedItems: false, + minItems: 2, + maxItems: 2 + }, undefined, orderedAjvOptions); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests, orderedAjvOptions); + Helper.validate(schema, tests); + }); + + it('represents optional ordered array items with additional item schemas', () => { + + const schema = Joi.array().ordered(Joi.string()).items(Joi.number()); + const tests = [ + [[], true], + [['a'], true], + [['a', 1, 2], true], + [[1, 2], false, '"[0]" must be a string'], + [['a', 'b'], false, '"[1]" must be a number'] + ]; + + Helper.validateJsonSchema(schema, { + type: 'array', + prefixItems: [ + { type: 'string', minLength: 1 } + ], + unevaluatedItems: { type: 'number' } + }, undefined, orderedAjvOptions); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests, orderedAjvOptions); + Helper.validate(schema, tests); }); - it('represents array with prefixItems and items', () => { + it('sets ordered array minItems with additional item schemas', () => { - expect((Joi.array().ordered(Joi.string()).items(Joi.number()))['~standard'].jsonSchema.input()).to.equal({ + const schema = Joi.array().ordered(Joi.string().required()).items(Joi.number()); + const tests = [ + [[], false, '"value" does not contain 1 required value(s)'], + [['a'], true], + [['a', 1, 2], true], + [[1, 2], false, '"[0]" must be a string'] + ]; + + Helper.validateJsonSchema(schema, { type: 'array', prefixItems: [ { type: 'string', minLength: 1 } ], unevaluatedItems: { type: 'number' }, minItems: 1 - }); + }, undefined, orderedAjvOptions); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests, orderedAjvOptions); + Helper.validate(schema, tests); + }); + + it('represents array with multiple item schemas after ordered items', () => { expect((Joi.array().ordered(Joi.string()).items(Joi.number(), Joi.boolean()))['~standard'].jsonSchema.input()).to.equal({ type: 'array', @@ -1026,8 +1155,7 @@ describe('jsonSchema', () => { { type: 'number' }, { type: 'boolean' } ] - }, - minItems: 1 + } }); }); @@ -1058,7 +1186,7 @@ describe('jsonSchema', () => { it('omits items: false when unevaluatedItems is used', () => { - Helper.validateJsonSchema(Joi.array().ordered(Joi.string()), { + Helper.validateJsonSchema(Joi.array().ordered(Joi.string().required()), { type: 'array', prefixItems: [{ type: 'string', minLength: 1 }], unevaluatedItems: false, @@ -1125,6 +1253,7 @@ describe('jsonSchema', () => { type: 'array' }); }); + }); describe('binary', () => { From d2301123f921dde5726b5a61563fac3fcb2347c2 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 15 Apr 2026 10:22:55 +0200 Subject: [PATCH 16/39] fix(json-schema): derive ordered minItems from required items re #3108 --- lib/types/array.js | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/lib/types/array.js b/lib/types/array.js index 1d8c81b4..c0110979 100755 --- a/lib/types/array.js +++ b/lib/types/array.js @@ -93,7 +93,7 @@ module.exports = Any.extend({ if (ordered.length) { res.unevaluatedItems = items; - res.minItems = ordered.length; + internals.setOrderedMinItems(res, ordered); } else { res.items = items; @@ -103,7 +103,7 @@ module.exports = Any.extend({ // No additional items allowed beyond the ordered ones res.unevaluatedItems = false; - res.minItems = ordered.length; + internals.setOrderedMinItems(res, ordered); res.maxItems = ordered.length; } @@ -768,6 +768,29 @@ module.exports = Any.extend({ // Helpers +internals.setOrderedMinItems = function (res, ordered) { + + // Ordered items are optional by default; the array only needs to reach the + // last explicitly required position. + const minItems = internals.orderedMinItems(ordered); + if (minItems) { + res.minItems = minItems; + } +}; + + +internals.orderedMinItems = function (ordered) { + + for (let i = ordered.length - 1; i >= 0; --i) { + if (ordered[i]._flags.presence === 'required') { + return i + 1; + } + } + + return 0; +}; + + internals.fillMissedErrors = function (schema, errors, requireds, value, state, prefs) { const knownMisses = []; From 4e973b3bcb195cb41488ca243e44703db89ec06f Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 15 Apr 2026 10:46:54 +0200 Subject: [PATCH 17/39] test(json-schema): cover array contains and unique parity re #3108 --- test/json-schema.js | 51 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/test/json-schema.js b/test/json-schema.js index d0ad7d09..2ab5e45e 100644 --- a/test/json-schema.js +++ b/test/json-schema.js @@ -947,6 +947,41 @@ describe('jsonSchema', () => { }); }); + it('skips uniqueItems for custom unique comparators', () => { + + const schema = Joi.array().unique(() => false); + + Helper.validateJsonSchema(schema, { + type: 'array' + }); + + Helper.validate(schema, [ + [[1, 1], true] + ]); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ + [[1, 1], true] + ]); + }); + + it('skips uniqueItems for path unique comparators', () => { + + const schema = Joi.array().unique('id', { ignoreUndefined: true }); + + Helper.validateJsonSchema(schema, { + type: 'array' + }); + + Helper.validate(schema, [ + [[{}, {}], true], + [[{ id: 1 }, { id: 1 }], false, '"[1]" contains a duplicate value'] + ]); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ + [[{}, {}], true] + ]); + }); + it('represents array with multiple items types', () => { Helper.validateJsonSchema(Joi.array().items(Joi.string(), Joi.number()), { @@ -991,6 +1026,22 @@ describe('jsonSchema', () => { }); }); + it('skips contains for has schemas with refs', () => { + + const schema = Joi.array().items(Joi.number()).has(Joi.number().greater(Joi.ref('..0'))); + + Helper.validateJsonSchema(schema, { + type: 'array', + items: { type: 'number' } + }); + + Helper.validate(schema, [ + [[10, 1, 11], true], + [[10, 1, 2], false, '"value" does not contain at least one required match'], + [[10], false, '"value" does not contain at least one required match'] + ]); + }); + // Optional ordered positions are valid JSON Schema, // and are the correct representation of Joi behavior, // but Ajv strictTuples only accepts fully required tuples. From 614d8d61a59b2ed463aaa40e117aa1e4a3823cc9 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 15 Apr 2026 10:47:03 +0200 Subject: [PATCH 18/39] fix(json-schema): skip lossy array contains and unique output re #3108 --- lib/types/array.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/types/array.js b/lib/types/array.js index c0110979..ba559253 100755 --- a/lib/types/array.js +++ b/lib/types/array.js @@ -111,7 +111,9 @@ module.exports = Any.extend({ const contains = []; for (const rule of schema._rules) { - if (rule.name === 'has') { + if (rule.name === 'has' && + !internals.hasReferences(rule.args.schema)) { + contains.push(rule.args.schema.$_jsonSchema(mode, options)); } } @@ -660,7 +662,9 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - res.uniqueItems = true; + if (!rule.args.comparator) { + res.uniqueItems = true; + } return res; }, @@ -791,6 +795,12 @@ internals.orderedMinItems = function (ordered) { }; +internals.hasReferences = function (schema) { + + return !!schema._refs.refs.length; +}; + + internals.fillMissedErrors = function (schema, errors, requireds, value, state, prefs) { const knownMisses = []; From dcea796382c6e873ed434b2597535d672a59ffe7 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 15 Apr 2026 10:54:22 +0200 Subject: [PATCH 19/39] test(json-schema): cover invalid null schema branches re #3108 --- lib/base.js | 12 +++++-- lib/extend.js | 4 --- test/json-schema.js | 79 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/lib/base.js b/lib/base.js index 1766de4f..fd49bd9c 100644 --- a/lib/base.js +++ b/lib/base.js @@ -204,7 +204,7 @@ internals.Base = class { .filter((v) => v !== null || internals.schemaCanMatchNull(schema)); if (invalids.length) { - internals.appendCompositeKeyword(schema, 'not', { enum: invalids }); + schema = internals.appendCompositeKeyword(schema, 'not', { enum: invalids }); } } @@ -1335,6 +1335,10 @@ internals.Base = class { internals.appendCompositeKeyword = function (schema, keyword, value) { + if (typeof schema === 'boolean') { + return schema ? { [keyword]: value } : false; + } + if (schema.allOf) { if (schema[keyword] !== undefined) { schema.allOf.push({ [keyword]: schema[keyword] }); @@ -1342,12 +1346,12 @@ internals.appendCompositeKeyword = function (schema, keyword, value) { } schema.allOf.push({ [keyword]: value }); - return; + return schema; } if (schema[keyword] === undefined) { schema[keyword] = value; - return; + return schema; } schema.allOf = [ @@ -1356,6 +1360,8 @@ internals.appendCompositeKeyword = function (schema, keyword, value) { ]; delete schema[keyword]; + + return schema; }; diff --git a/lib/extend.js b/lib/extend.js index 9ca1ac0c..ebf3362a 100755 --- a/lib/extend.js +++ b/lib/extend.js @@ -211,10 +211,6 @@ internals.build = function (child, parent) { internals.standardType = function (schema) { - if (!schema) { - return null; - } - if (internals.standardTypes.has(schema.type)) { return schema.type; } diff --git a/test/json-schema.js b/test/json-schema.js index 2ab5e45e..44f4b569 100644 --- a/test/json-schema.js +++ b/test/json-schema.js @@ -614,6 +614,85 @@ describe('jsonSchema', () => { [{ a: true }, true] ]); }); + + it('filters invalid(null) according to whether the emitted schema can match null', () => { + + const custom = Joi.extend( + { + type: 'nothing', + base: Joi.any(), + jsonSchema() { + + return false; + } + }, + { + type: 'anything', + base: Joi.any(), + jsonSchema() { + + return true; + } + }, + { + type: 'nullableEnum', + base: Joi.any(), + jsonSchema(schema, res) { + + res.enum = [null, 'x']; + return res; + } + } + ); + + Helper.validateJsonSchema(custom.nothing().invalid(null), false); + Helper.validateJsonSchema(custom.nothing().invalid('x'), false); + + Helper.validateJsonSchema(custom.anything().invalid(null), { + not: { enum: [null] } + }); + + Helper.validateJsonSchema(custom.nullableEnum().invalid(null), { + enum: [null, 'x'], + not: { enum: [null] } + }); + + Helper.validateJsonSchema(Joi.valid(null, 'a').invalid(null), { + enum: ['a'], + type: 'string' + }); + + Helper.validateJsonSchema(Joi.string().allow(1).invalid(null), { + anyOf: [ + { type: 'string', minLength: 1 }, + { enum: [1] } + ] + }); + + Helper.validateJsonSchema(Joi.alternatives().try(Joi.string().allow(null), Joi.number()).invalid(null), { + anyOf: [ + { type: ['string', 'null'], minLength: 1 }, + { type: 'number' } + ], + not: { enum: [null] } + }); + + Helper.validateJsonSchema(Joi.alternatives().try(Joi.string().allow(null), Joi.number()).match('one').invalid(null), { + oneOf: [ + { type: ['string', 'null'], minLength: 1 }, + { type: 'number' } + ], + not: { enum: [null] } + }); + + Helper.validateJsonSchema(Joi.alternatives().try(Joi.any().allow(null), Joi.string().allow(null)).match('all').invalid(null), { + allOf: [ + {}, + { type: ['string', 'null'], minLength: 1 }, + { not: { enum: [null] } } + ] + }); + }); }); describe('alternatives', () => { From 9a164d681d3a02acf564fc125d0bc5996afce57b Mon Sep 17 00:00:00 2001 From: Hafez Date: Fri, 17 Apr 2026 13:14:40 +0200 Subject: [PATCH 20/39] test(json-schema): cover exclusive valid parity --- test/json-schema.js | 402 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 385 insertions(+), 17 deletions(-) diff --git a/test/json-schema.js b/test/json-schema.js index 44f4b569..2b6b3be2 100644 --- a/test/json-schema.js +++ b/test/json-schema.js @@ -128,8 +128,8 @@ describe('jsonSchema', () => { Helper.validateJsonSchema(Joi.string().valid('a', 'b').when('c', { is: 1, then: Joi.string().description('d') }), { anyOf: [ - { enum: ['a', 'b'], type: 'string', description: 'd' }, - { enum: ['a', 'b'], type: 'string' } + { enum: ['a', 'b'], type: 'string', description: 'd', minLength: 1 }, + { enum: ['a', 'b'], type: 'string', minLength: 1 } ] }); }); @@ -148,8 +148,8 @@ describe('jsonSchema', () => { Helper.validateJsonSchema(Joi.string().valid('a').when('c', { is: 1, then: Joi.string().valid('b') }), { anyOf: [ - { type: 'string', enum: ['a', 'b'] }, - { type: 'string', enum: ['a'] } + { type: 'string', minLength: 1, enum: ['a', 'b'] }, + { type: 'string', minLength: 1, enum: ['a'] } ] }); }); @@ -355,7 +355,7 @@ describe('jsonSchema', () => { it('avoids duplicate types when merging null and valid', () => { - Helper.validateJsonSchema(Joi.string().valid('a').allow(null), { type: ['string', 'null'], enum: ['a'] }); + Helper.validateJsonSchema(Joi.string().valid('a').allow(null), { type: ['string', 'null'], minLength: 1, enum: ['a', null] }); }); it('represents valids with multiple types', () => { @@ -441,7 +441,151 @@ describe('jsonSchema', () => { it('represents inferred mixed string and null valids', () => { - Helper.validateJsonSchema(Joi.valid('foo', null), { type: ['string', 'null'], enum: ['foo'] }); + Helper.validateJsonSchema(Joi.valid('foo', null), { type: ['string', 'null'], enum: ['foo', null] }); + }); + + it('retains null in exclusive valid enums', () => { + + const schema = Joi.string().valid('a', 'b', null); + const tests = [ + ['a', true], + ['b', true], + [null, true], + ['c', false, '"value" must be one of [a, b, null]'], + [1, false, '"value" must be one of [a, b, null]'], + [true, false, '"value" must be one of [a, b, null]'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, { type: ['string', 'null'], minLength: 1, enum: ['a', 'b', null] }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); + }); + + it('retains null in numeric exclusive valid enums', () => { + + const schema = Joi.number().valid(1, 2, null); + const tests = [ + [1, true], + [2, true], + [null, true], + [3, false, '"value" must be one of [1, 2, null]'], + [true, false, '"value" must be one of [1, 2, null]'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, { type: ['number', 'null'], enum: [1, 2, null] }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); + }); + + it('flattens exclusive valid type unions when null is mixed with multiple primitives', () => { + + const schema = Joi.valid('a', 1, null); + const tests = [ + ['a', true], + [1, true], + [null, true], + [true, false, '"value" must be one of [a, 1, null]'], + [{}, false, '"value" must be one of [a, 1, null]'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, { type: ['string', 'number', 'null'], enum: ['a', 1, null] }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); + }); + + it('retains null in exclusive valid enums inside objects', () => { + + const schema = Joi.object({ + status: Joi.string().valid('active', 'inactive', null) + }); + const tests = [ + [{}, true], + [{ status: 'active' }, true], + [{ status: 'inactive' }, true], + [{ status: null }, true], + [{ status: 'paused' }, false, '"status" must be one of [active, inactive, null]'], + [{ status: 1 }, false, '"status" must be one of [active, inactive, null]'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + status: { type: ['string', 'null'], minLength: 1, enum: ['active', 'inactive', null] } + }, + additionalProperties: false + }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); + }); + + it('lets exclusive valids override conflicting string rules', () => { + + const schema = Joi.string().min(5).valid('abc'); + const tests = [ + ['abc', true], + ['abcde', false, '"value" must be [abc]'], + ['ab', false, '"value" must be [abc]'], + [1, false, '"value" must be [abc]'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, { + type: 'string', + enum: ['abc'] + }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); + }); + + it('lets exclusive valids override conflicting object rules', () => { + + const schema = Joi.object().min(1).valid({}); + const tests = [ + [{}, true], + [{ a: 1 }, false, '"value" must be [[object Object]]'], + [null, false, '"value" must be [[object Object]]'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, { + enum: [{}] + }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); + }); + + it('lets exclusive valids override conflicting date rules', () => { + + const value = new Date('2019-01-01T00:00:00.000Z'); + const schema = Joi.date().min('2020-01-01').valid(value); + const tests = [ + [value.toISOString(), true, value], + ['2020-01-01T00:00:00.000Z', false, '"value" must be [2019-01-01T00:00:00.000Z]'], + [null, false, '"value" must be [2019-01-01T00:00:00.000Z]'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, { + type: 'string', + enum: [value.toISOString()] + }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([testValue, pass]) => [testValue, pass])); + }); + + it('does not narrow mixed exclusive valids with object members', () => { + + const schema = Joi.any().valid({ a: 1 }, 'a', null); + const tests = [ + [{ a: 1 }, true], + ['a', true], + [null, true], + [{ a: 2 }, false, '"value" must be one of [[object Object], a, null]'], + [1, false, '"value" must be one of [[object Object], a, null]'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, { + enum: [{ a: 1 }, 'a', null] + }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); }); it('represents string schema with number valid as number type', () => { @@ -451,7 +595,7 @@ describe('jsonSchema', () => { it('represents string schema with mixed number and string valids', () => { - Helper.validateJsonSchema(Joi.string().valid(1, 'a'), { type: ['number', 'string'], enum: [1, 'a'] }); + Helper.validateJsonSchema(Joi.string().valid(1, 'a'), { type: ['number', 'string'], minLength: 1, enum: [1, 'a'] }); }); it('represents string schema with min length and number valid', () => { @@ -501,7 +645,7 @@ describe('jsonSchema', () => { it('represents string schema with multiple number and string valids', () => { - Helper.validateJsonSchema(Joi.string().valid(1, 2, 'a'), { type: ['number', 'string'], enum: [1, 2, 'a'] }); + Helper.validateJsonSchema(Joi.string().valid(1, 2, 'a'), { type: ['number', 'string'], minLength: 1, enum: [1, 2, 'a'] }); }); it('represents string schema with mixed number and boolean valids', () => { @@ -511,7 +655,7 @@ describe('jsonSchema', () => { it('represents string schema with mixed number and string valids (reordered)', () => { - Helper.validateJsonSchema(Joi.string().valid(1, 'a', 2), { type: ['number', 'string'], enum: [1, 'a', 2] }); + Helper.validateJsonSchema(Joi.string().valid(1, 'a', 2), { type: ['number', 'string'], minLength: 1, enum: [1, 'a', 2] }); }); it('represents string schema with mixed number and boolean valids (reordered)', () => { @@ -521,12 +665,12 @@ describe('jsonSchema', () => { it('represents string schema with mixed number and object valids', () => { - Helper.validateJsonSchema(Joi.string().valid(1, {}), { type: 'number', enum: [1, {}] }); + Helper.validateJsonSchema(Joi.string().valid(1, {}), { enum: [1, {}] }); }); it('represents string schema with multiple numbers and object valid', () => { - Helper.validateJsonSchema(Joi.string().valid(1, 2, {}), { type: 'number', enum: [1, 2, {}] }); + Helper.validateJsonSchema(Joi.string().valid(1, 2, {}), { enum: [1, 2, {}] }); }); it('represents string only with number valid', () => { @@ -536,7 +680,7 @@ describe('jsonSchema', () => { it('represents string only with allowed string', () => { - Helper.validateJsonSchema(Joi.string().valid('a'), { type: 'string', enum: ['a'] }); + Helper.validateJsonSchema(Joi.string().valid('a'), { type: 'string', minLength: 1, enum: ['a'] }); }); it('represents empty any valids', () => { @@ -566,12 +710,38 @@ describe('jsonSchema', () => { it('represents valids with string and object', () => { - Helper.validateJsonSchema(Joi.any().valid('a', {}), { type: 'string', enum: ['a', {}] }); + Helper.validateJsonSchema(Joi.any().valid('a', {}), { enum: ['a', {}] }); }); it('represents valids with string, number and object', () => { - Helper.validateJsonSchema(Joi.any().valid('a', 1, {}), { type: ['string', 'number'], enum: ['a', 1, {}] }); + const schema = Joi.any().valid('a', 1, {}); + const tests = [ + ['a', true], + [1, true], + [{}, true], + [true, false], + [null, false] + ]; + + Helper.validateJsonSchema(schema, { enum: ['a', 1, {}] }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests); + }); + + it('preserves annotations and shared defs when exclusive valids fall back to enum-only output', () => { + + const schema = Joi.any() + .shared(Joi.string().id('shared')) + .description('annotated') + .valid({ a: 1 }); + + Helper.validateJsonSchema(schema, { + $defs: { + shared: { type: 'string', minLength: 1 } + }, + description: 'annotated', + enum: [{ a: 1 }] + }); }); it('represents invalid values as not enum', () => { @@ -1466,11 +1636,131 @@ describe('jsonSchema', () => { it('represents date with valid rule', () => { - Helper.validateJsonSchema(Joi.date().valid(new Date(1741708800000)), { + const value = new Date(1741708800000); + const schema = Joi.date().valid(value); + const tests = [ + [value.toISOString(), true, value], + ['2025-03-12T16:00:00.000Z', false, '"value" must be [2025-03-11T16:00:00.000Z]'], + [null, false, '"value" must be [2025-03-11T16:00:00.000Z]'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, { + type: 'string', + format: 'date-time', + enum: [value.toISOString()] + }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([testValue, pass]) => [testValue, pass])); + }); + + it('represents iso date with valid rule', () => { + + const value = new Date(1741708800000); + const schema = Joi.date().iso().valid(value); + const tests = [ + [value.toISOString(), true, value], + ['2025-03-12T16:00:00.000Z', false, '"value" must be [2025-03-11T16:00:00.000Z]'], + [null, false, '"value" must be [2025-03-11T16:00:00.000Z]'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, { type: 'string', format: 'date-time', - enum: [new Date(1741708800000)] + enum: [value.toISOString()] + }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([testValue, pass]) => [testValue, pass])); + }); + + it('represents javascript timestamp with valid rule', () => { + + const value = new Date(1741708800000); + const schema = Joi.date().timestamp('javascript').valid(value); + const tests = [ + [value.getTime(), true, value], + [value.getTime() + 1, false, '"value" must be [2025-03-11T16:00:00.000Z]'], + [null, false, '"value" must be [2025-03-11T16:00:00.000Z]'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, { + type: 'number', + minimum: -100e6 * 24 * 60 * 60 * 1000, + maximum: 100e6 * 24 * 60 * 60 * 1000, + enum: [value.getTime()] }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([testValue, pass]) => [testValue, pass])); + }); + + it('represents unix timestamp with valid rule', () => { + + const value = new Date(1741708800000); + const unix = value.getTime() / 1000; + const schema = Joi.date().timestamp('unix').valid(value); + const tests = [ + [unix, true, value], + [unix + 1, false, '"value" must be [2025-03-11T16:00:00.000Z]'], + [null, false, '"value" must be [2025-03-11T16:00:00.000Z]'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, { + type: 'number', + minimum: -100e6 * 24 * 60 * 60, + maximum: 100e6 * 24 * 60 * 60, + enum: [unix] + }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([testValue, pass]) => [testValue, pass])); + }); + + it('represents allowed dates using canonical JSON values', () => { + + const value = new Date(1741708800000); + Helper.validateJsonSchema(Joi.date().allow(value), { + anyOf: [ + { type: 'string', format: 'date-time' }, + { enum: [value.toISOString()] } + ] + }); + }); + + it('represents invalid dates using canonical JSON values', () => { + + const value = new Date(1741708800000); + const schema = Joi.date().invalid(value); + const tests = [ + [value.toISOString(), false, '"value" contains an invalid value'], + ['2025-03-12T16:00:00.000Z', true, new Date('2025-03-12T16:00:00.000Z')], + [null, false, '"value" must be a valid date'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, { + type: 'string', + format: 'date-time', + not: { enum: [value.toISOString()] } + }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([testValue, pass]) => [testValue, pass])); + }); + + it('represents invalid javascript timestamps using canonical JSON values', () => { + + const value = new Date(1741708800000); + const schema = Joi.date().timestamp('javascript').invalid(value); + const tests = [ + [value.getTime(), false, '"value" contains an invalid value'], + [value.getTime() + 1, true, new Date(value.getTime() + 1)], + [null, false, '"value" must be a valid date'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, { + type: 'number', + minimum: -100e6 * 24 * 60 * 60 * 1000, + maximum: 100e6 * 24 * 60 * 60 * 1000, + not: { enum: [value.getTime()] } + }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([testValue, pass]) => [testValue, pass])); }); it('represents javascript timestamp', () => { @@ -2582,6 +2872,7 @@ describe('jsonSchema', () => { Helper.validateJsonSchema(Joi.string().valid('a', 'b'), { type: 'string', + minLength: 1, enum: ['a', 'b'] }); @@ -2610,6 +2901,7 @@ describe('jsonSchema', () => { Helper.validateJsonSchema(Joi.string().max(5).valid('abcde'), { type: 'string', enum: ['abcde'], + minLength: 1, maxLength: 5 }); @@ -2623,28 +2915,32 @@ describe('jsonSchema', () => { Helper.validateJsonSchema(Joi.string().pattern(/abc/).valid('abc'), { type: 'string', enum: ['abc'], + minLength: 1, pattern: 'abc' }); Helper.validateJsonSchema(Joi.string().email().valid('a@b.com'), { type: 'string', enum: ['a@b.com'], + minLength: 1, format: 'email' }); Helper.validateJsonSchema(Joi.string().guid().valid('550e8400-e29b-41d4-a716-446655440000'), { type: 'string', enum: ['550e8400-e29b-41d4-a716-446655440000'], + minLength: 1, format: 'uuid' }); Helper.validateJsonSchema(Joi.string().ip().valid('127.0.0.1'), { type: 'string', format: 'ip', + minLength: 1, enum: ['127.0.0.1'] }); - Helper.validateJsonSchema(Joi.string().valid('a'), { enum: ['a'], type: 'string' }); + Helper.validateJsonSchema(Joi.string().valid('a'), { enum: ['a'], type: 'string', minLength: 1 }); }); it('represents string with formats', () => { @@ -3377,6 +3673,78 @@ describe('jsonSchema', () => { }); }); + it('deduplicates null when custom jsonSchema already emits a null type', () => { + + const custom = Joi.extend({ + type: 'nullable', + base: Joi.any(), + jsonSchema(schema, res) { + + res.type = 'null'; + return res; + } + }); + + Helper.validateJsonSchema(custom.nullable().allow(null), { + type: 'null' + }); + }); + + it('drops boolean custom jsonSchema output when exclusive valids supply the full schema', () => { + + const custom = Joi.extend({ + type: 'object', + base: Joi.any(), + jsonSchema() { + + return false; + } + }); + + Helper.validateJsonSchema(custom.object().valid({ foo: 'bar' }), { + enum: [{ foo: 'bar' }] + }); + }); + + it('appends null to custom union types and narrows exclusive valids against them', () => { + + const custom = Joi.extend({ + type: 'either', + base: Joi.any(), + jsonSchema(schema, res) { + + res.type = ['string', 'number']; + return res; + } + }); + + Helper.validateJsonSchema(custom.either().allow(null), { + type: ['string', 'number', 'null'] + }); + + Helper.validateJsonSchema(custom.either().valid('a'), { + type: 'string', + enum: ['a'] + }); + }); + + it('normalizes retained boolean custom jsonSchema output before applying exclusive valids', () => { + + const custom = Joi.extend({ + type: 'string', + base: Joi.any(), + jsonSchema() { + + return false; + } + }); + + Helper.validateJsonSchema(custom.string().valid('a'), { + type: 'string', + enum: ['a'] + }); + }); + it('inherits json schema type from custom string extensions', () => { const custom = Joi.extend({ From 81061aca64de8efa74ddacab25129e4b945f4088 Mon Sep 17 00:00:00 2001 From: Hafez Date: Fri, 17 Apr 2026 13:14:55 +0200 Subject: [PATCH 21/39] fix(json-schema): preserve exclusive valid semantics --- lib/base.js | 255 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 234 insertions(+), 21 deletions(-) diff --git a/lib/base.js b/lib/base.js index fd49bd9c..3c333151 100644 --- a/lib/base.js +++ b/lib/base.js @@ -20,7 +20,8 @@ const internals = { standardTypes: new Set(['string', 'number', 'integer', 'boolean', 'object', 'array', 'null']), jsonSchemaTarget: 'draft-2020-12', primitiveTypes: new Set(['string', 'number', 'boolean']), - supportedMetaKeywords: new Set([ + // Keywords copied through from user-provided meta() values. + metaPassthroughKeywords: new Set([ '$comment', 'contentEncoding', 'contentMediaType', @@ -32,6 +33,22 @@ const internals = { 'title', 'writeOnly' ]), + // Keywords preserved when exclusive valids have to fall back to enum-only + // output. This intentionally excludes validating keywords such as format. + onlyFallbackAnnotationKeywords: new Set([ + '$comment', + '$defs', + 'contentEncoding', + 'contentMediaType', + 'contentSchema', + 'default', + 'deprecated', + 'description', + 'examples', + 'readOnly', + 'title', + 'writeOnly' + ]), nullSchema: () => ({ type: 'null' }) }; @@ -107,15 +124,24 @@ internals.Base = class { const isTypeAny = this.type === 'any'; const isOnly = this._flags.only; - const valids = this._valids && Array.from(this._valids._values).filter((v) => v !== null); + const rawValids = this._valids && Array.from(this._valids._values); + const valids = rawValids && internals.jsonSchemaValues(this, rawValids); + const onlyValues = valids && valids.filter((v) => typeof v !== 'symbol'); + const nonNullValids = valids && valids.filter((v) => v !== null); let typesOverlap = true; // If 'only' is set, check if the allowed values' types overlap with the schema type - if (valids && valids.length && isOnly && !isTypeAny) { - const types = new Set(valids.map((v) => typeof v)); - const type = jsonSchemaType || this.type; - typesOverlap = types.has(type) || (type === 'date' && types.has('object')); + if (rawValids && isOnly && !isTypeAny) { + const comparableValues = rawValids.filter((v) => typeof v !== 'symbol' && v !== null); + if (!comparableValues.length) { + typesOverlap = true; + } + else { + const types = new Set(comparableValues.map((v) => typeof v)); + const type = jsonSchemaType || this.type; + typesOverlap = types.has(type) || (type === 'date' && types.has('object')); + } } // Set the JSON Schema 'type' if it's a standard type and there's an overlap @@ -167,16 +193,19 @@ internals.Base = class { if (this._valids) { - const values = valids.filter((v) => typeof v !== 'symbol'); + const values = isOnly ? onlyValues : nonNullValids.filter((v) => typeof v !== 'symbol'); if (values.length) { if (this._flags.only) { - schema.enum = values; + if (!(values.length === 1 && values[0] === null)) { + if (typeof schema !== 'boolean') { + schema.enum = values; - const list = Common.intersect(new Set(values.map((v) => typeof v)), internals.primitiveTypes); + const types = internals.onlyTypes(values); - if (list.size) { - const types = [...list]; - schema.type = types.length === 1 ? types[0] : types; + if (types) { + schema.type = types.length === 1 ? types[0] : types; + } + } } } else { @@ -199,8 +228,8 @@ internals.Base = class { // Handle disallowed values (invalids) if (this._invalids) { - const invalids = Array.from(this._invalids._values) - .filter((v) => typeof v !== 'symbol') + const invalids = internals.jsonSchemaValues(this, Array.from(this._invalids._values) + .filter((v) => typeof v !== 'symbol')) .filter((v) => v !== null || internals.schemaCanMatchNull(schema)); if (invalids.length) { @@ -210,12 +239,9 @@ internals.Base = class { // Handle 'null' if it's an allowed value - if (this._valids && this._valids.has(null) && !(isTypeAny && !isOnly)) { - if (this._valids.length === 1 && (isTypeAny || isOnly)) { - schema.type = 'null'; - } - else if (schema.type) { - schema.type = [schema.type, 'null']; + if (!isOnly && this._valids && this._valids.has(null) && !isTypeAny) { + if (schema.type) { + schema.type = internals.appendType(schema.type, 'null'); } else if (schema.anyOf) { schema.anyOf.unshift(internals.nullSchema()); @@ -266,6 +292,10 @@ internals.Base = class { return { anyOf: results }; } + if (isOnly && onlyValues && onlyValues.length) { + schema = internals.finalizeOnlySchema(this, schema, onlyValues, mode, subOptions); + } + return schema; } @@ -1365,6 +1395,189 @@ internals.appendCompositeKeyword = function (schema, keyword, value) { }; +internals.finalizeOnlySchema = function (source, schema, values, mode, options) { + + let base = internals.onlyCanRetainBaseSchema(source, values, options.prefs) + ? internals.onlyBaseSchema(source, mode, options) + : internals.onlyFallbackAnnotations(schema); + + if (typeof base === 'boolean') { + base = {}; + } + + if (schema.$defs) { + base.$defs = schema.$defs; + } + + if (values.length === 1 && + values[0] === null) { + + base.type = 'null'; + delete base.enum; + return base; + } + + base.enum = values; + + const types = internals.onlyTypes(values); + if (types) { + base.type = types.length === 1 ? types[0] : types; + } + else { + delete base.type; + } + + return base; +}; + + +internals.appendType = function (type, addition) { + + const types = Array.isArray(type) ? type.slice() : [type]; + + if (!types.includes(addition)) { + types.push(addition); + } + + return types.length === 1 ? types[0] : types; +}; + + +internals.onlyCanRetainBaseSchema = function (source, values, prefs) { + + const base = internals.onlyBaseClone(source); + const baseSchema = base.$_jsonSchema('input', { prefs, $defs: {} }); + const baseTypes = internals.schemaTypes(baseSchema); + let checked = false; + + for (const value of values) { + if (value === null) { + continue; + } + + const valueType = internals.jsonSchemaValueType(value); + if (valueType === null) { + return false; + } + + if (baseTypes && + !baseTypes.has(valueType)) { + + continue; + } + + checked = true; + + if (base.validate(value, prefs).error) { + return false; + } + } + + return checked || !baseTypes; +}; + + +internals.onlyBaseSchema = function (source, mode, options) { + + return internals.onlyBaseClone(source).$_jsonSchema(mode, options); +}; + + +internals.onlyBaseClone = function (source) { + + const base = source.clone(); + base._valids = null; + base._invalids = null; + delete base._flags.only; + return base; +}; + + +internals.onlyFallbackAnnotations = function (schema) { + + if (typeof schema === 'boolean') { + return {}; + } + + const annotations = {}; + for (const key of internals.onlyFallbackAnnotationKeywords) { + if (schema[key] !== undefined) { + annotations[key] = schema[key]; + } + } + + return annotations; +}; + + +internals.onlyTypes = function (values) { + + const types = values.map(internals.jsonSchemaValueType); + if (types.includes(null)) { + return null; + } + + return [...new Set(types)]; +}; + + +internals.jsonSchemaValues = function (source, values) { + + return values.map((value) => internals.jsonSchemaValue(source, value)); +}; + + +internals.jsonSchemaValue = function (source, value) { + + if (source.type === 'date' && + value instanceof Date) { + + return internals.jsonSchemaDateValue(source, value); + } + + return value; +}; + + +internals.jsonSchemaDateValue = function (source, value) { + + const format = source._flags.format; + if (format === 'javascript') { + return value.getTime(); + } + + if (format === 'unix') { + return value.getTime() / 1000; + } + + return value.toISOString(); +}; + + +internals.jsonSchemaValueType = function (value) { + + if (value === null) { + return 'null'; + } + + const type = typeof value; + return internals.primitiveTypes.has(type) ? type : null; +}; + + +internals.schemaTypes = function (schema) { + + if (typeof schema === 'boolean' || + schema.type === undefined) { + + return null; + } + + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + return new Set(types); +}; + + internals.applyExamples = function (schema, examples) { if (!examples) { @@ -1401,7 +1614,7 @@ internals.applyMetas = function (schema, metas = []) { continue; } - if (!internals.supportedMetaKeywords.has(key) || + if (!internals.metaPassthroughKeywords.has(key) || value === undefined || schema[key] !== undefined) { From 2ad254e1878f2200d3733cca1c0f65f0b2f5b2c3 Mon Sep 17 00:00:00 2001 From: Hafez Date: Fri, 17 Apr 2026 15:07:29 +0200 Subject: [PATCH 22/39] test(json-schema): cover allow exceptions and string patterns --- test/helper.js | 4 - test/json-schema.js | 468 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 456 insertions(+), 16 deletions(-) diff --git a/test/helper.js b/test/helper.js index f0fbc1ce..c9f2d4a8 100644 --- a/test/helper.js +++ b/test/helper.js @@ -14,17 +14,13 @@ internals.ajvOptions = { allowUnionTypes: true, formats: { banana: true, - base64: true, binary: true, - 'data-uri': true, 'date-time': true, duration: true, email: true, - hex: true, hostname: true, ip: true, ipv4: true, - token: true, uri: true, uuid: true }, diff --git a/test/json-schema.js b/test/json-schema.js index 2b6b3be2..49ac61a9 100644 --- a/test/json-schema.js +++ b/test/json-schema.js @@ -1,6 +1,7 @@ 'use strict'; const Joi = require('..'); +const { ipRegex } = require('@hapi/address'); const Code = require('@hapi/code'); const Lab = require('@hapi/lab'); const Helper = require('./helper'); @@ -419,6 +420,76 @@ describe('jsonSchema', () => { Helper.validateJsonSchema(Joi.string().allow(null).min(5), { type: ['string', 'null'], minLength: 5 }); }); + it('represents inclusive allow exceptions for conflicting string rules', () => { + + const schema = Joi.string().min(5).allow('abc'); + const tests = [ + ['abc', true], + ['abcde', true], + ['ab', false, '"value" length must be at least 5 characters long'], + [1, false, '"value" must be a string'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, { + anyOf: [ + { type: 'string', minLength: 5 }, + { enum: ['abc'] } + ] + }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); + }); + + it('keeps explicit empty-string exceptions when other string constraints still reject them', () => { + + Helper.validateJsonSchema(Joi.string().pattern(/abc/).allow(''), { + anyOf: [ + { type: 'string', pattern: 'abc' }, + { enum: [''] } + ] + }); + }); + + it('represents inclusive allow exceptions for conflicting number rules', () => { + + const schema = Joi.number().greater(10).allow(5); + const tests = [ + [5, true], + [11, true], + [10, false, '"value" must be greater than 10'], + [1, false, '"value" must be greater than 10'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, { + anyOf: [ + { type: 'number', exclusiveMinimum: 10 }, + { enum: [5] } + ] + }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); + }); + + it('represents inclusive allow exceptions for conflicting object rules', () => { + + const schema = Joi.object().min(1).allow({}); + const tests = [ + [{}, true], + [{ a: 1 }, true], + [null, false, '"value" must be of type object'], + [1, false, '"value" must be of type object'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, { + anyOf: [ + { type: 'object', minProperties: 1 }, + { enum: [{}] } + ] + }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); + }); + it('represents inferred types for valids', () => { Helper.validateJsonSchema(Joi.compile('foo'), { enum: ['foo'], type: 'string' }); @@ -2847,6 +2918,16 @@ describe('jsonSchema', () => { describe('string', () => { + const expectedIpPattern = (options) => ipRegex(options).regex.source.replace(/\[\\w-\\\./g, '[\\w.\\-'); + const unanchoredPattern = (pattern) => pattern.replace(/^\^/, '').replace(/\$$/, ''); + const expectedHostnamePattern = () => { + + const domain = '^(?=.{1,256}$)(?:(?=[^.]{1,63}\\.)[A-Za-z0-9\\u0080-\\u{10FFFF}](?:[A-Za-z0-9\\u0080-\\u{10FFFF}-]*[A-Za-z0-9\\u0080-\\u{10FFFF}])?\\.){0,}(?=[^.]{1,63}$)[A-Za-z\\u0080-\\u{10FFFF}](?:[A-Za-z0-9\\u0080-\\u{10FFFF}-]*[A-Za-z0-9\\u0080-\\u{10FFFF}])?$'; + const ip = expectedIpPattern({ cidr: 'forbidden' }); + + return `^(?:${unanchoredPattern(domain)}|${unanchoredPattern(ip)})$`; + }; + it('represents basic string', () => { Helper.validateJsonSchema(Joi.string().description('A string').default('foo'), { @@ -2889,7 +2970,10 @@ describe('jsonSchema', () => { }); Helper.validateJsonSchema(Joi.string().allow(''), { - type: 'string' + anyOf: [ + { type: 'string' }, + { enum: [''] } + ] }); Helper.validateJsonSchema(Joi.string().min(5).valid('abcde'), { @@ -2935,7 +3019,7 @@ describe('jsonSchema', () => { Helper.validateJsonSchema(Joi.string().ip().valid('127.0.0.1'), { type: 'string', - format: 'ip', + pattern: expectedIpPattern(), minLength: 1, enum: ['127.0.0.1'] }); @@ -2949,22 +3033,25 @@ describe('jsonSchema', () => { type: 'string', minLength: 5, maxLength: 5, - pattern: 'foo', + allOf: [ + { pattern: 'foo' }, + { pattern: expectedHostnamePattern() } + ], format: 'uuid' }); - Helper.validateJsonSchema(Joi.string().ip(), { type: 'string', minLength: 1, format: 'ip' }); - Helper.validateJsonSchema(Joi.string().ip({ version: 'ipv4' }), { type: 'string', minLength: 1, format: 'ipv4' }); - Helper.validateJsonSchema(Joi.string().ip({ version: ['ipv4', 'ipv6'] }), { type: 'string', minLength: 1, format: 'ip' }); - Helper.validateJsonSchema(Joi.string().base64(), { type: 'string', minLength: 1, format: 'base64' }); - Helper.validateJsonSchema(Joi.string().dataUri(), { type: 'string', minLength: 1, format: 'data-uri' }); + Helper.validateJsonSchema(Joi.string().ip(), { type: 'string', minLength: 1, pattern: expectedIpPattern() }); + Helper.validateJsonSchema(Joi.string().ip({ version: 'ipv4' }), { type: 'string', minLength: 1, pattern: expectedIpPattern({ version: 'ipv4' }) }); + Helper.validateJsonSchema(Joi.string().ip({ version: ['ipv4', 'ipv6'] }), { type: 'string', minLength: 1, pattern: expectedIpPattern({ version: ['ipv4', 'ipv6'] }) }); + Helper.validateJsonSchema(Joi.string().base64(), { type: 'string', minLength: 1, pattern: '^(?:[A-Za-z0-9+\\/]{2}[A-Za-z0-9+\\/]{2})*(?:[A-Za-z0-9+\\/]{2}==|[A-Za-z0-9+\\/]{3}=)?$' }); + Helper.validateJsonSchema(Joi.string().dataUri(), { type: 'string', minLength: 1, pattern: '^data:[\\w+.-]+\\/[\\w+.-]+;(?:base64,(?:[A-Za-z0-9+\\/]{2}[A-Za-z0-9+\\/]{2})*(?:[A-Za-z0-9+\\/]{2}==|[A-Za-z0-9+\\/]{3}=)?|(?!base64,).*)$' }); Helper.validateJsonSchema(Joi.string().email(), { type: 'string', minLength: 1, format: 'email' }); Helper.validateJsonSchema(Joi.string().guid(), { type: 'string', minLength: 1, format: 'uuid' }); - Helper.validateJsonSchema(Joi.string().hex(), { type: 'string', minLength: 1, format: 'hex' }); - Helper.validateJsonSchema(Joi.string().hostname(), { type: 'string', minLength: 1, format: 'hostname' }); + Helper.validateJsonSchema(Joi.string().hex(), { type: 'string', minLength: 1, pattern: '^[0-9A-Fa-f]+$' }); + Helper.validateJsonSchema(Joi.string().hostname(), { type: 'string', minLength: 1, pattern: expectedHostnamePattern() }); Helper.validateJsonSchema(Joi.string().isoDate(), { type: 'string', minLength: 1, format: 'date-time' }); Helper.validateJsonSchema(Joi.string().isoDuration(), { type: 'string', minLength: 1, format: 'duration' }); - Helper.validateJsonSchema(Joi.string().token(), { type: 'string', minLength: 1, format: 'token' }); + Helper.validateJsonSchema(Joi.string().token(), { type: 'string', minLength: 1, pattern: '^[A-Za-z0-9_]+$' }); Helper.validateJsonSchema(Joi.string().domain({ tlds: false }), { type: 'string', minLength: 1, @@ -3177,10 +3264,367 @@ describe('jsonSchema', () => { ]); }); + it('represents token with a validating pattern', () => { + + const schema = Joi.string().token(); + const tests = [ + ['abc_123', true], + ['abc-123', false], + ['', false] + ]; + + Helper.validateJsonSchema(schema, { + type: 'string', + minLength: 1, + pattern: '^[A-Za-z0-9_]+$' + }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests); + + for (const [value, pass] of tests) { + expect(!schema.validate(value).error).to.equal(pass); + } + }); + + it('represents ip with validating patterns', () => { + + const anyIp = Joi.string().ip(); + const ipv4 = Joi.string().ip({ version: 'ipv4' }); + const ipv6 = Joi.string().ip({ version: 'ipv6' }); + const mixedIp = Joi.string().ip({ version: ['ipv4', 'ipv6'] }); + const requiredCidr = Joi.string().ip({ cidr: 'required' }); + const forbiddenCidr = Joi.string().ip({ cidr: 'forbidden' }); + + Helper.validateJsonSchema(anyIp, { + type: 'string', + minLength: 1, + pattern: expectedIpPattern() + }); + Helper.validateJsonSchemaValues(anyIp['~standard'].jsonSchema.input(), [ + ['127.0.0.1', true], + ['::1', true], + ['1.2.3.4/24', true], + ['2001:db8::/32', true], + ['v1.a:1', true], + ['example.com', false] + ]); + + Helper.validateJsonSchema(ipv4, { + type: 'string', + minLength: 1, + pattern: expectedIpPattern({ version: 'ipv4' }) + }); + Helper.validateJsonSchemaValues(ipv4['~standard'].jsonSchema.input(), [ + ['127.0.0.1', true], + ['1.2.3.4/24', true], + ['::1', false], + ['v1.a:1', false] + ]); + + Helper.validateJsonSchema(ipv6, { + type: 'string', + minLength: 1, + pattern: expectedIpPattern({ version: 'ipv6' }) + }); + Helper.validateJsonSchemaValues(ipv6['~standard'].jsonSchema.input(), [ + ['::1', true], + ['2001:db8::/32', true], + ['127.0.0.1', false], + ['v1.a:1', false] + ]); + + Helper.validateJsonSchema(mixedIp, { + type: 'string', + minLength: 1, + pattern: expectedIpPattern({ version: ['ipv4', 'ipv6'] }) + }); + Helper.validateJsonSchemaValues(mixedIp['~standard'].jsonSchema.input(), [ + ['127.0.0.1', true], + ['::1', true], + ['v1.a:1', false], + ['example.com', false] + ]); + + Helper.validateJsonSchema(requiredCidr, { + type: 'string', + minLength: 1, + pattern: expectedIpPattern({ cidr: 'required' }) + }); + Helper.validateJsonSchemaValues(requiredCidr['~standard'].jsonSchema.input(), [ + ['127.0.0.1', false], + ['1.2.3.4/24', true], + ['::1', false], + ['2001:db8::/32', true] + ]); + + Helper.validateJsonSchema(forbiddenCidr, { + type: 'string', + minLength: 1, + pattern: expectedIpPattern({ cidr: 'forbidden' }) + }); + Helper.validateJsonSchemaValues(forbiddenCidr['~standard'].jsonSchema.input(), [ + ['127.0.0.1', true], + ['1.2.3.4/24', false], + ['::1', true], + ['2001:db8::/32', false], + ['v1.a:1', true] + ]); + }); + + it('represents hostname with a hostname-or-ip pattern', () => { + + const schema = Joi.string().hostname(); + + Helper.validateJsonSchema(schema, { + type: 'string', + minLength: 1, + pattern: expectedHostnamePattern() + }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ + ['example.com', true], + ['localhost', true], + ['bücher', true], + ['127.0.0.1', true], + ['::1', true], + ['v1.a:1', true], + ['bad host', false], + ['1.2.3.4/24', false] + ]); + }); + + it('appends inherited string patterns onto existing allOf branches', () => { + + const custom = Joi.extend({ + type: 'patterned', + base: Joi.string(), + jsonSchema(schema, res) { + + res.allOf = [{ minLength: 2 }]; + res.pattern = '^base$'; + return res; + } + }); + + Helper.validateJsonSchema(custom.patterned().token(), { + type: 'string', + allOf: [ + { minLength: 2 }, + { pattern: '^base$' }, + { pattern: '^[A-Za-z0-9_]+$' } + ] + }); + }); + + it('represents hex with option-aware patterns', () => { + + const hex = Joi.string().hex(); + const hexPrefix = Joi.string().hex({ prefix: true }); + const hexOptionalPrefix = Joi.string().hex({ prefix: 'optional' }); + const hexByteAligned = Joi.string().hex({ byteAligned: true }); + const hexPrefixByteAligned = Joi.string().hex({ prefix: true, byteAligned: true }); + const hexOptionalPrefixByteAligned = Joi.string().hex({ prefix: 'optional', byteAligned: true }); + + Helper.validateJsonSchema(hex, { + type: 'string', + minLength: 1, + pattern: '^[0-9A-Fa-f]+$' + }); + Helper.validateJsonSchemaValues(hex['~standard'].jsonSchema.input(), [ + ['deadBEEF', true], + ['0xdeadBEEF', false], + ['xyz', false] + ]); + + Helper.validateJsonSchema(hexPrefix, { + type: 'string', + minLength: 1, + pattern: '^0[xX][0-9A-Fa-f]+$' + }); + Helper.validateJsonSchemaValues(hexPrefix['~standard'].jsonSchema.input(), [ + ['0xdeadBEEF', true], + ['0XdeadBEEF', true], + ['deadBEEF', false], + ['xyz', false] + ]); + + Helper.validateJsonSchema(hexOptionalPrefix, { + type: 'string', + minLength: 1, + pattern: '^(?:0[xX])?[0-9A-Fa-f]+$' + }); + Helper.validateJsonSchemaValues(hexOptionalPrefix['~standard'].jsonSchema.input(), [ + ['deadBEEF', true], + ['0xdeadBEEF', true], + ['0XdeadBEEF', true], + ['xyz', false] + ]); + + Helper.validateJsonSchema(hexByteAligned, { + type: 'string', + minLength: 1, + pattern: '^[0-9A-Fa-f]+$' + }, { + type: 'string', + minLength: 1, + pattern: '^(?:[0-9A-Fa-f]{2})+$' + }); + Helper.validateJsonSchemaValues(hexByteAligned['~standard'].jsonSchema.input(), [ + ['a', true], + ['0a', true], + ['abc', true], + ['0abc', true], + ['0xabc', false] + ]); + Helper.validateJsonSchemaValues(hexByteAligned['~standard'].jsonSchema.output(), [ + ['a', false], + ['0a', true], + ['abc', false], + ['0abc', true] + ]); + + Helper.validateJsonSchema(hexPrefixByteAligned, { + type: 'string', + minLength: 1, + pattern: '^0[xX](?:[0-9A-Fa-f]{2})+$' + }); + Helper.validateJsonSchemaValues(hexPrefixByteAligned['~standard'].jsonSchema.input(), [ + ['0x0a', true], + ['0X0A', true], + ['0xa', false], + ['0xabc', false], + ['0x0abc', true] + ]); + + Helper.validateJsonSchema(hexOptionalPrefixByteAligned, { + type: 'string', + minLength: 1, + pattern: '^(?:[0-9A-Fa-f]+|0[xX](?:[0-9A-Fa-f]{2})+)$' + }, { + type: 'string', + minLength: 1, + pattern: '^(?:(?:[0-9A-Fa-f]{2})+|0[xX](?:[0-9A-Fa-f]{2})+)$' + }); + Helper.validateJsonSchemaValues(hexOptionalPrefixByteAligned['~standard'].jsonSchema.input(), [ + ['a', true], + ['0a', true], + ['0xa', false], + ['0x0a', true], + ['0xabc', false] + ]); + Helper.validateJsonSchemaValues(hexOptionalPrefixByteAligned['~standard'].jsonSchema.output(), [ + ['a', false], + ['0a', true], + ['0xa', false], + ['0x0a', true] + ]); + }); + + it('represents base64 with option-aware patterns', () => { + + const base64 = Joi.string().base64(); + const base64NoPadding = Joi.string().base64({ paddingRequired: false }); + const base64UrlSafe = Joi.string().base64({ urlSafe: true }); + const base64NoPaddingUrlSafe = Joi.string().base64({ paddingRequired: false, urlSafe: true }); + + Helper.validateJsonSchema(base64, { + type: 'string', + minLength: 1, + pattern: '^(?:[A-Za-z0-9+\\/]{2}[A-Za-z0-9+\\/]{2})*(?:[A-Za-z0-9+\\/]{2}==|[A-Za-z0-9+\\/]{3}=)?$' + }); + Helper.validateJsonSchemaValues(base64['~standard'].jsonSchema.input(), [ + ['YWJjZA==', true], + ['YWJjZA', false], + ['YWJjZA=', false], + ['YWJjZA--', false] + ]); + + Helper.validateJsonSchema(base64NoPadding, { + type: 'string', + minLength: 1, + pattern: '^(?:[A-Za-z0-9+\\/]{2}[A-Za-z0-9+\\/]{2})*(?:[A-Za-z0-9+\\/]{2}(==)?|[A-Za-z0-9+\\/]{3}=?)?$' + }); + Helper.validateJsonSchemaValues(base64NoPadding['~standard'].jsonSchema.input(), [ + ['YWJjZA==', true], + ['YWJjZA', true], + ['YWJjZA=', false], + ['YQ', true] + ]); + + Helper.validateJsonSchema(base64UrlSafe, { + type: 'string', + minLength: 1, + pattern: '^(?:[\\w\\-]{2}[\\w\\-]{2})*(?:[\\w\\-]{2}==|[\\w\\-]{3}=)?$' + }); + Helper.validateJsonSchemaValues(base64UrlSafe['~standard'].jsonSchema.input(), [ + ['YWJjZA==', true], + ['YWJjZA--', true], + ['YWJjZA++', false], + ['YWJjZA__', true] + ]); + + Helper.validateJsonSchema(base64NoPaddingUrlSafe, { + type: 'string', + minLength: 1, + pattern: '^(?:[\\w\\-]{2}[\\w\\-]{2})*(?:[\\w\\-]{2}(==)?|[\\w\\-]{3}=?)?$' + }); + Helper.validateJsonSchemaValues(base64NoPaddingUrlSafe['~standard'].jsonSchema.input(), [ + ['YWJjZA==', true], + ['YWJjZA', true], + ['YWJjZA=', false], + ['YWJjZA__', true], + ['YWJjZA++', false] + ]); + }); + + it('represents dataUri with validating patterns', () => { + + const dataUri = Joi.string().dataUri(); + const dataUriNoPadding = Joi.string().dataUri({ paddingRequired: false }); + + Helper.validateJsonSchema(dataUri, { + type: 'string', + minLength: 1, + pattern: '^data:[\\w+.-]+\\/[\\w+.-]+;(?:base64,(?:[A-Za-z0-9+\\/]{2}[A-Za-z0-9+\\/]{2})*(?:[A-Za-z0-9+\\/]{2}==|[A-Za-z0-9+\\/]{3}=)?|(?!base64,).*)$' + }); + Helper.validateJsonSchemaValues(dataUri['~standard'].jsonSchema.input(), [ + ['data:text/plain;base64,YWJjZA==', true], + ['data:text/plain;base64,YWJjZA', false], + ['data:text/plain;base64,', true], + ['data:text/plain;hello', true], + ['data:text/plain;', true], + ['data:text/plain;charset=utf-8,hello', true], + ['data:text/plain;charset=utf-8;base64,YQ==', true], + ['data:text/plain;BASE64,%', true], + ['data:text/plain;base64x,%', true], + ['data:text/plain,hello', false], + ['data:;base64,aGVsbG8=', false], + ['data:application/json,{}', false], + ['data:text/plain;base64,%%', false] + ]); + + Helper.validateJsonSchema(dataUriNoPadding, { + type: 'string', + minLength: 1, + pattern: '^data:[\\w+.-]+\\/[\\w+.-]+;(?:base64,(?:[A-Za-z0-9+\\/]{2}[A-Za-z0-9+\\/]{2})*(?:[A-Za-z0-9+\\/]{2}(==)?|[A-Za-z0-9+\\/]{3}=?)?|(?!base64,).*)$' + }); + Helper.validateJsonSchemaValues(dataUriNoPadding['~standard'].jsonSchema.input(), [ + ['data:text/plain;base64,YWJjZA==', true], + ['data:text/plain;base64,YWJjZA', true], + ['data:text/plain;base64,', true], + ['data:text/plain;hello', true], + ['data:text/plain;charset=utf-8,hello', true], + ['data:text/plain;base64,%%', false] + ]); + }); + it('represents string with various options', () => { Helper.validateJsonSchema(Joi.string().alphanum(), { type: 'string', minLength: 1, pattern: '^[a-zA-Z0-9]+$' }); - Helper.validateJsonSchema(Joi.string().allow(''), { type: 'string' }); + Helper.validateJsonSchema(Joi.string().allow(''), { + anyOf: [ + { type: 'string' }, + { enum: [''] } + ] + }); Helper.validateJsonSchema(Joi.string().min(0), { type: 'string' }); Helper.validateJsonSchema(Joi.string().length(0), { type: 'string', minLength: 0, maxLength: 0 }); From ad4092a4a19ac9b113c432378b031bb4228ab120 Mon Sep 17 00:00:00 2001 From: Hafez Date: Fri, 17 Apr 2026 15:07:46 +0200 Subject: [PATCH 23/39] fix(json-schema): preserve allow exceptions and pattern validators --- lib/base.js | 34 +++++++++-- lib/types/string.js | 134 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 138 insertions(+), 30 deletions(-) diff --git a/lib/base.js b/lib/base.js index 3c333151..0cd1d9ba 100644 --- a/lib/base.js +++ b/lib/base.js @@ -128,6 +128,8 @@ internals.Base = class { const valids = rawValids && internals.jsonSchemaValues(this, rawValids); const onlyValues = valids && valids.filter((v) => typeof v !== 'symbol'); const nonNullValids = valids && valids.filter((v) => v !== null); + const rawAllowedValues = rawValids && rawValids.filter((v) => typeof v !== 'symbol' && v !== null); + const allowedValues = valids && valids.filter((v) => typeof v !== 'symbol' && v !== null); let typesOverlap = true; // If 'only' is set, check if the allowed values' types overlap with the schema type @@ -209,17 +211,18 @@ internals.Base = class { } } else { - // If values are allowed but not exclusive, add them via 'anyOf' if they differ from the main type + // If values are allowed but not exclusive, add them via 'anyOf' when + // the base schema would otherwise reject them. - const otherTypes = values.filter((v) => typeof v !== this.type || isTypeAny); - if (otherTypes.length && !(isTypeAny && !isOnly)) { + const extras = isTypeAny ? [] : internals.allowedValuesNeedingEnum(this, schema, rawAllowedValues, allowedValues, jsonSchemaType, prefs); + if (extras.length) { if (!schema.anyOf) { schema = { anyOf: [schema] }; } - schema.anyOf.push({ enum: otherTypes }); + schema.anyOf.push({ enum: extras }); } } } @@ -1477,6 +1480,29 @@ internals.onlyCanRetainBaseSchema = function (source, values, prefs) { }; +internals.allowedValuesNeedingEnum = function (source, schema, rawValues, values, jsonSchemaType, prefs) { + + const base = internals.onlyBaseClone(source); + const comparableType = jsonSchemaType || source.type; + const extras = []; + + for (let i = 0; i < values.length; ++i) { + const value = values[i]; + + if (typeof value !== comparableType) { + extras.push(value); + continue; + } + + if (base.validate(rawValues[i], prefs).error) { + extras.push(value); + } + } + + return extras; +}; + + internals.onlyBaseSchema = function (source, mode, options) { return internals.onlyBaseClone(source).$_jsonSchema(mode, options); diff --git a/lib/types/string.js b/lib/types/string.js index 96cfcb0f..91a93cd9 100755 --- a/lib/types/string.js +++ b/lib/types/string.js @@ -180,8 +180,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - res.pattern = '^[a-zA-Z0-9]+$'; - return res; + return internals.appendPattern(res, '^[a-zA-Z0-9]+$'); } }, @@ -207,8 +206,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - res.format = 'base64'; - return res; + return internals.appendPattern(res, internals.base64JsonSchemaPattern(rule.args.options)); } }, @@ -292,8 +290,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - res.format = 'data-uri'; - return res; + return internals.appendPattern(res, internals.dataUriJsonSchemaPattern(rule.args.options)); } }, @@ -320,8 +317,7 @@ module.exports = Any.extend({ // Not using format: 'hostname' because it accepts single-label names (e.g. 'localhost') // while Joi's domain() requires at least 2 segments by default - res.pattern = internals.domainJsonSchemaPattern(rule.address); - return res; + return internals.appendPattern(res, internals.domainJsonSchemaPattern(rule.address)); } }, @@ -483,10 +479,9 @@ module.exports = Any.extend({ return value; }, - jsonSchema(rule, res) { + jsonSchema(rule, res, isOnly, mode) { - res.format = 'hex'; - return res; + return internals.appendPattern(res, internals.hexJsonSchemaPattern(rule.args.options, mode)); } }, @@ -507,8 +502,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - res.format = 'hostname'; - return res; + return internals.appendPattern(res, internals.hostnameJsonSchemaPattern()); } }, @@ -542,15 +536,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - const version = rule.args.options.version; - if (version && version.length === 1) { - res.format = version[0]; - } - else { - res.format = 'ip'; - } - - return res; + return internals.appendPattern(res, internals.ipJsonSchemaPattern(rule.args.options)); } }, @@ -708,8 +694,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - res.pattern = rule.args.regex.source; - return res; + return internals.appendPattern(res, rule.args.regex.source); }, args: ['regex', 'options'], multi: true @@ -751,8 +736,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - res.format = 'token'; - return res; + return internals.appendPattern(res, '^[A-Za-z0-9_]+$'); } }, @@ -974,6 +958,104 @@ internals.validateTlds = function (set, source) { }; +internals.base64JsonSchemaPattern = function (options) { + + return internals.base64Regex[options.paddingRequired][options.urlSafe].source; +}; + + +internals.appendPattern = function (res, pattern) { + + if (res.allOf) { + const existingPattern = res.pattern; + delete res.pattern; + res.allOf.push(...[existingPattern].filter((value) => value !== undefined).map((value) => ({ pattern: value })), { pattern }); + return res; + } + + if (res.pattern === undefined) { + res.pattern = pattern; + return res; + } + + res.allOf = [ + { pattern: res.pattern }, + { pattern } + ]; + + delete res.pattern; + return res; +}; + + +internals.hostnameJsonSchemaPattern = function () { + + const hostname = internals.unanchoredPattern(internals.domainJsonSchemaPattern({ minDomainSegments: 1, tlds: false })); + const ip = internals.unanchoredPattern(internals.ipJsonSchemaPattern({ cidr: 'forbidden' })); + + return `^(?:${hostname}|${ip})$`; +}; + + +internals.ipJsonSchemaPattern = function (options) { + + return ipRegex(options).regex.source.replace(/\[\\w-\\\./g, '[\\w.\\-'); +}; + + +internals.dataUriJsonSchemaPattern = function (options) { + + const mediaType = 'data:[\\w+.-]+\\/[\\w+.-]+;'; + const base64 = internals.unanchoredPattern(internals.base64Regex[options.paddingRequired].false); + + return `^${mediaType}(?:base64,${base64}|(?!base64,).*)$`; +}; + + +internals.hexJsonSchemaPattern = function (options, mode) { + + const digits = '[0-9A-Fa-f]+'; + const bytes = '(?:[0-9A-Fa-f]{2})+'; + + if (!options.byteAligned) { + if (options.prefix === true) { + return '^0[xX][0-9A-Fa-f]+$'; + } + + if (options.prefix === 'optional') { + return '^(?:0[xX])?[0-9A-Fa-f]+$'; + } + + return '^[0-9A-Fa-f]+$'; + } + + if (options.prefix === true) { + return `^0[xX]${bytes}$`; + } + + if (options.prefix === 'optional') { + if (mode === 'output') { + return `^(?:${bytes}|0[xX]${bytes})$`; + } + + return `^(?:${digits}|0[xX]${bytes})$`; + } + + if (mode === 'output') { + return `^${bytes}$`; + } + + return '^[0-9A-Fa-f]+$'; +}; + + +internals.unanchoredPattern = function (regex) { + + const pattern = typeof regex === 'string' ? regex : regex.source; + return pattern.replace(/^\^/, '').replace(/\$$/, ''); +}; + + internals.domainJsonSchemaPattern = function (options) { const settings = internals.addressOptions(options); From 500586408fb7ca60df92b125407318266ad574b7 Mon Sep 17 00:00:00 2001 From: Hafez Date: Sat, 18 Apr 2026 13:41:37 +0200 Subject: [PATCH 24/39] test(json-schema): cover date parity edge cases --- test/json-schema.js | 440 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 371 insertions(+), 69 deletions(-) diff --git a/test/json-schema.js b/test/json-schema.js index 49ac61a9..e649c991 100644 --- a/test/json-schema.js +++ b/test/json-schema.js @@ -211,6 +211,100 @@ describe('jsonSchema', () => { }); }); + it('represents Date defaults using canonical JSON values', () => { + + const value = new Date('2025-03-11T16:00:00.000Z'); + + Helper.validateJsonSchema(Joi.any().default(value), { + default: value.toISOString() + }); + + Helper.validateJsonSchema(Joi.date().default(value), { + default: value.toISOString(), + type: ['string', 'number'], + format: 'date-time', + minimum: -100e6 * 24 * 60 * 60 * 1000, + maximum: 100e6 * 24 * 60 * 60 * 1000 + }); + + Helper.validateJsonSchema(Joi.date().timestamp('javascript').default(value), { + default: value.getTime(), + type: 'number', + minimum: -100e6 * 24 * 60 * 60 * 1000, + maximum: 100e6 * 24 * 60 * 60 * 1000 + }); + + Helper.validateJsonSchema(Joi.date().timestamp('unix').default(value), { + default: value.getTime() / 1000, + type: 'number', + minimum: -100e6 * 24 * 60 * 60, + maximum: 100e6 * 24 * 60 * 60 + }); + }); + + it('omits date default("now") from JSON Schema output', () => { + + const schema = Joi.date().default('now'); + + Helper.validate(schema, [ + [undefined, true, 'now'] + ]); + + Helper.validateJsonSchema(schema, { + type: ['string', 'number'], + format: 'date-time', + minimum: -100e6 * 24 * 60 * 60 * 1000, + maximum: 100e6 * 24 * 60 * 60 * 1000 + }); + }); + + it('omits date function defaults from JSON Schema output', () => { + + const value = new Date('2025-03-11T16:00:00.000Z'); + const schema = Joi.date().default(() => value); + + const result = schema.validate(undefined); + expect(result.error).to.not.exist(); + expect(result.value).to.equal(value); + + Helper.validateJsonSchema(schema, { + type: ['string', 'number'], + format: 'date-time', + minimum: -100e6 * 24 * 60 * 60 * 1000, + maximum: 100e6 * 24 * 60 * 60 * 1000 + }); + }); + + it('represents Date examples using canonical JSON values', () => { + + const value = new Date('2025-03-11T16:00:00.000Z'); + + Helper.validateJsonSchema(Joi.any().example(value), { + examples: [value.toISOString()] + }); + + Helper.validateJsonSchema(Joi.date().example(value), { + type: ['string', 'number'], + format: 'date-time', + minimum: -100e6 * 24 * 60 * 60 * 1000, + maximum: 100e6 * 24 * 60 * 60 * 1000, + examples: [value.toISOString()] + }); + + Helper.validateJsonSchema(Joi.date().timestamp('javascript').example(value), { + type: 'number', + minimum: -100e6 * 24 * 60 * 60 * 1000, + maximum: 100e6 * 24 * 60 * 60 * 1000, + examples: [value.getTime()] + }); + + Helper.validateJsonSchema(Joi.any().example({ createdAt: value }).meta({ + examples: [{ createdAt: value }] + }), { + examples: [{ createdAt: value.toISOString() }] + }); + }); + it('represents supported meta keywords', () => { Helper.validateJsonSchema(Joi.string().meta({ @@ -238,6 +332,28 @@ describe('jsonSchema', () => { }); }); + it('canonicalizes Date values inside null-prototype meta objects', () => { + + const value = new Date('2025-03-11T16:00:00.000Z'); + const contentSchema = Object.assign(Object.create(null), { + type: 'string', + default: value, + examples: [null, value] + }); + + Helper.validateJsonSchema(Joi.any().meta({ + title: 'Greeting', + contentSchema + }), { + title: 'Greeting', + contentSchema: { + type: 'string', + default: value.toISOString(), + examples: [null, value.toISOString()] + } + }); + }); + it('merges meta examples without overriding validator-derived schema keywords', () => { Helper.validateJsonSchema(Joi.string().email().example('a@example.com').meta({ @@ -629,14 +745,16 @@ describe('jsonSchema', () => { const schema = Joi.date().min('2020-01-01').valid(value); const tests = [ [value.toISOString(), true, value], + [value.getTime(), true, value], ['2020-01-01T00:00:00.000Z', false, '"value" must be [2019-01-01T00:00:00.000Z]'], + [new Date('2020-01-01T00:00:00.000Z').getTime(), false, '"value" must be [2019-01-01T00:00:00.000Z]'], [null, false, '"value" must be [2019-01-01T00:00:00.000Z]'] ]; Helper.validate(schema, tests); Helper.validateJsonSchema(schema, { - type: 'string', - enum: [value.toISOString()] + type: ['string', 'number'], + enum: [value.toISOString(), value.getTime()] }); Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([testValue, pass]) => [testValue, pass])); }); @@ -684,14 +802,92 @@ describe('jsonSchema', () => { Helper.validateJsonSchema(Joi.string().ip().valid(1), { type: 'number', enum: [1] }); }); - it('represents date schema with number valid', () => { + it('represents convert:true date schema with numeric valid literal as false', () => { + + const schema = Joi.date().valid(1); + const tests = [ + [1, false, '"value" must be [1]'], + ['1970-01-01T00:00:00.001Z', false, '"value" must be [1]'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, false); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); + }); + + it('represents convert:true date schema with min and numeric valid literal as false', () => { + + const schema = Joi.date().min('2020-01-01').valid(1); + const tests = [ + [1, false, '"value" must be [1]'], + ['2025-03-11T16:00:00.000Z', false, '"value" must be [1]'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, false); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); + }); + + it('represents convert:true iso date schema with string valid literal as false', () => { + + const schema = Joi.date().iso().valid('2025-03-11T16:00:00.000Z'); + const tests = [ + ['2025-03-11T16:00:00.000Z', false, '"value" must be [2025-03-11T16:00:00.000Z]'], + [1741708800000, false, '"value" must be [2025-03-11T16:00:00.000Z]'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, false); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); + }); + + it('drops generic Date object valids from any-only schemas', () => { - Helper.validateJsonSchema(Joi.date().valid(1), { type: 'number', enum: [1] }); + const value = new Date('2025-03-11T16:00:00.000Z'); + const schema = Joi.any().valid(value); + const tests = [ + [value, true], + [new Date(value.getTime()), true], + [value.toISOString(), false, '"value" must be [2025-03-11T16:00:00.000Z]'], + [value.getTime(), false, '"value" must be [2025-03-11T16:00:00.000Z]'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, false); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ + // Joi runtime above covers live Date instances. JSON Schema below + // only asserts behavior for JSON-serializable values. + ...tests + .filter(([testValue]) => !(testValue instanceof Date)) + .map(([testValue, pass]) => [testValue, pass]), + [null, false] + ]); }); - it('represents date schema with min and number valid', () => { + it('drops generic Date object valids from mixed any-only schemas', () => { - Helper.validateJsonSchema(Joi.date().min('2020-01-01').valid(1), { type: 'number', enum: [1] }); + const value = new Date('2025-03-11T16:00:00.000Z'); + const schema = Joi.any().valid('a', value); + const tests = [ + ['a', true], + [value, true], + [new Date(value.getTime()), true], + [value.toISOString(), false, '"value" must be one of [a, 2025-03-11T16:00:00.000Z]'] + ]; + + Helper.validate(schema, tests); + Helper.validateJsonSchema(schema, { + type: 'string', + enum: ['a'] + }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ + // Joi runtime above covers live Date instances. JSON Schema below + // only asserts behavior for JSON-serializable values. + ...tests + .filter(([testValue]) => !(testValue instanceof Date)) + .map(([testValue, pass]) => [testValue, pass]), + [value.getTime(), false] + ]); }); it('represents null only valid', () => { @@ -836,26 +1032,74 @@ describe('jsonSchema', () => { it('represents invalid(null) for unconstrained schemas', () => { const schema = Joi.any().invalid(null); - - Helper.validate(schema, [ + const tests = [ [null, false, '"value" contains an invalid value'], [1, true], ['x', true], [{ a: true }, true] - ]); + ]; + + Helper.validate(schema, tests); Helper.validateJsonSchema(schema, { not: { enum: [null] } }); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); + }); + + it('drops generic Date object invalids from unconstrained schemas', () => { + + const value = new Date('2025-03-11T16:00:00.000Z'); + const schema = Joi.any().invalid(value); + const tests = [ + [value, false, '"value" contains an invalid value'], + [new Date(value.getTime()), false, '"value" contains an invalid value'], + [value.toISOString(), true], + [value.getTime(), true], + [null, true] + ]; + + Helper.validate(schema, tests); + + Helper.validateJsonSchema(schema, {}); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ - [null, false], - [1, true], - ['x', true], + // Joi runtime above covers live Date instances. JSON Schema below + // only asserts behavior for JSON-serializable values. + ...tests + .filter(([testValue]) => !(testValue instanceof Date)) + .map(([testValue, pass]) => [testValue, pass]), [{ a: true }, true] ]); }); + it('drops generic Date object invalids while preserving JSON invalids', () => { + + const value = new Date('2025-03-11T16:00:00.000Z'); + const schema = Joi.any().invalid('a', value); + const tests = [ + ['a', false, '"value" contains an invalid value'], + [value, false, '"value" contains an invalid value'], + [new Date(value.getTime()), false, '"value" contains an invalid value'], + [value.toISOString(), true], + [value.getTime(), true], + [null, true] + ]; + + Helper.validate(schema, tests); + + Helper.validateJsonSchema(schema, { + not: { enum: ['a'] } + }); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests + // Joi runtime above covers live Date instances. JSON Schema below + // only asserts behavior for JSON-serializable values. + .filter(([testValue]) => !(testValue instanceof Date)) + .map(([testValue, pass]) => [testValue, pass])); + }); + it('filters invalid(null) according to whether the emitted schema can match null', () => { const custom = Joi.extend( @@ -1665,11 +1909,41 @@ describe('jsonSchema', () => { describe('date', () => { + const expectedIsoDatePattern = () => '^(?:[-+]\\d{2})?(?:\\d{4}(?!\\d{2}\\b))(?:(-?)(?:(?:0[1-9]|1[0-2])(?:\\1(?:[12]\\d|0[1-9]|3[01]))?|W(?:[0-4]\\d|5[0-2])(?:-?[1-7])?|(?:00[1-9]|0[1-9]\\d|[12]\\d{2}|3(?:[0-5]\\d|6[1-6])))(?![T]$|[T][\\d]+Z$)(?:[T\\s](?:(?:(?:[01]\\d|2[0-3])(?:(:?)[0-5]\\d)?|24:?00)(?:[.,]\\d+(?!:))?)(?:\\2[0-5]\\d(?:[.,]\\d+)?)?(?:[Z]|(?:[+-])(?:[01]\\d|2[0-3])(?::?[0-5]\\d)?)?)?)?$'; + it('represents basic date', () => { - const expected = { format: 'date-time', type: 'string' }; - Helper.validateJsonSchema(Joi.date(), expected); - Helper.validateJsonSchema(Joi.date().iso(), expected); + const timestamp = 1741708800000; + + Helper.validateJsonSchema(Joi.date(), { + type: ['string', 'number'], + format: 'date-time', + minimum: -100e6 * 24 * 60 * 60 * 1000, + maximum: 100e6 * 24 * 60 * 60 * 1000 + }); + Helper.validateJsonSchemaValues(Joi.date()['~standard'].jsonSchema.input(), [ + ['2025-03-11T16:00:00.000Z', true], + [0, true], + [1, true], + [-1, true], + [timestamp, true], + [100e6 * 24 * 60 * 60 * 1000 + 1, false], + [-(100e6 * 24 * 60 * 60 * 1000) - 1, false], + [true, false], + [null, false], + [{}, false], + [[], false] + ]); + + Helper.validateJsonSchema(Joi.date().iso(), { type: 'string', pattern: expectedIsoDatePattern() }); + Helper.validateJsonSchemaValues(Joi.date().iso()['~standard'].jsonSchema.input(), [ + ['2025-03-11T16:00:00.000Z', true], + ['2025-03-11', true], + ['2025-03-11T16:00', true], + ['2025-03-11T17:00:00+0100', true], + ['2025-03-11T23:59:60Z', false], + [timestamp, false] + ]); }); it('represents date with constraints', () => { @@ -1678,8 +1952,10 @@ describe('jsonSchema', () => { const d2 = new Date(1741795200000); Helper.validateJsonSchema(Joi.date().min(d1).max(d2).greater(d1).less(d2), { - type: 'string', + type: ['string', 'number'], format: 'date-time', + minimum: -100e6 * 24 * 60 * 60 * 1000, + maximum: 100e6 * 24 * 60 * 60 * 1000, 'x-constraint': { min: d1.toISOString(), max: d2.toISOString(), @@ -1689,8 +1965,10 @@ describe('jsonSchema', () => { }); Helper.validateJsonSchema(Joi.date().min(d1).max(d2).greater(d1).less(d2).only(), { - type: 'string', + type: ['string', 'number'], format: 'date-time', + minimum: -100e6 * 24 * 60 * 60 * 1000, + maximum: 100e6 * 24 * 60 * 60 * 1000, 'x-constraint': { greater: d1.toISOString(), less: d2.toISOString(), @@ -1700,8 +1978,10 @@ describe('jsonSchema', () => { }); Helper.validateJsonSchema(Joi.date().min('now').max('now').greater('now').less('now'), { - type: 'string', - format: 'date-time' + type: ['string', 'number'], + format: 'date-time', + minimum: -100e6 * 24 * 60 * 60 * 1000, + maximum: 100e6 * 24 * 60 * 60 * 1000 }); }); @@ -1711,15 +1991,19 @@ describe('jsonSchema', () => { const schema = Joi.date().valid(value); const tests = [ [value.toISOString(), true, value], + [value.getTime(), true, value], ['2025-03-12T16:00:00.000Z', false, '"value" must be [2025-03-11T16:00:00.000Z]'], + [value.getTime() + 1, false, '"value" must be [2025-03-11T16:00:00.000Z]'], [null, false, '"value" must be [2025-03-11T16:00:00.000Z]'] ]; Helper.validate(schema, tests); Helper.validateJsonSchema(schema, { - type: 'string', + type: ['string', 'number'], format: 'date-time', - enum: [value.toISOString()] + minimum: -100e6 * 24 * 60 * 60 * 1000, + maximum: 100e6 * 24 * 60 * 60 * 1000, + enum: [value.toISOString(), value.getTime()] }); Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([testValue, pass]) => [testValue, pass])); }); @@ -1737,7 +2021,7 @@ describe('jsonSchema', () => { Helper.validate(schema, tests); Helper.validateJsonSchema(schema, { type: 'string', - format: 'date-time', + pattern: expectedIsoDatePattern(), enum: [value.toISOString()] }); Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([testValue, pass]) => [testValue, pass])); @@ -1788,10 +2072,10 @@ describe('jsonSchema', () => { const value = new Date(1741708800000); Helper.validateJsonSchema(Joi.date().allow(value), { - anyOf: [ - { type: 'string', format: 'date-time' }, - { enum: [value.toISOString()] } - ] + type: ['string', 'number'], + format: 'date-time', + minimum: -100e6 * 24 * 60 * 60 * 1000, + maximum: 100e6 * 24 * 60 * 60 * 1000 }); }); @@ -1801,15 +2085,19 @@ describe('jsonSchema', () => { const schema = Joi.date().invalid(value); const tests = [ [value.toISOString(), false, '"value" contains an invalid value'], + [value.getTime(), false, '"value" contains an invalid value'], ['2025-03-12T16:00:00.000Z', true, new Date('2025-03-12T16:00:00.000Z')], + [value.getTime() + 1, true, new Date(value.getTime() + 1)], [null, false, '"value" must be a valid date'] ]; Helper.validate(schema, tests); Helper.validateJsonSchema(schema, { - type: 'string', + type: ['string', 'number'], format: 'date-time', - not: { enum: [value.toISOString()] } + minimum: -100e6 * 24 * 60 * 60 * 1000, + maximum: 100e6 * 24 * 60 * 60 * 1000, + not: { enum: [value.toISOString(), value.getTime()] } }); Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([testValue, pass]) => [testValue, pass])); }); @@ -1841,6 +2129,11 @@ describe('jsonSchema', () => { minimum: -100e6 * 24 * 60 * 60 * 1000, // 100 million days in ms (ECMA-262 §21.4.1.1) maximum: 100e6 * 24 * 60 * 60 * 1000 }); + Helper.validateJsonSchemaValues(Joi.date().timestamp('javascript')['~standard'].jsonSchema.input(), [ + [1741708800000, true], + [100e6 * 24 * 60 * 60 * 1000 + 1, false], + ['2025-03-11T16:00:00.000Z', false] + ]); }); it('represents unix timestamp', () => { @@ -2190,12 +2483,13 @@ describe('jsonSchema', () => { const schema = Joi.object({ a: Joi.string().forbidden() }).prefs({ allowUnknown: true }); - - Helper.validate(schema, [ + const tests = [ [{}, true], [{ a: 'x' }, false, '"a" is not allowed'], [{ c: true }, true] - ]); + ]; + + Helper.validate(schema, tests); Helper.validateJsonSchema(schema, { type: 'object', @@ -2204,11 +2498,7 @@ describe('jsonSchema', () => { } }); - Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ - [{}, true], - [{ a: 'x' }, false], - [{ c: true }, true] - ]); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); }); it('represents with() dependency as dependentRequired', () => { @@ -3846,20 +4136,17 @@ describe('jsonSchema', () => { a: Joi.string(), b: Joi.number() }).prefs({ presence: 'forbidden' }); - - Helper.validate(schema, [ + const tests = [ [{}, false, '"value" is not allowed'], [{ a: 'x' }, false, '"value" is not allowed'], [{ c: true }, false, '"value" is not allowed'] - ]); + ]; + + Helper.validate(schema, tests); Helper.validateJsonSchema(schema, false); - Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ - [{}, false], - [{ a: 'x' }, false], - [{ c: true }, false] - ]); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); }); it('represents root presence: forbidden preference as false even when unknown keys are allowed', () => { @@ -3868,22 +4155,18 @@ describe('jsonSchema', () => { a: Joi.string(), b: Joi.number() }).prefs({ presence: 'forbidden', allowUnknown: true }); - - Helper.validate(schema, [ + const tests = [ [{}, false, '"value" is not allowed'], [{ a: 'x' }, false, '"value" is not allowed'], [{ b: 1 }, false, '"value" is not allowed'], [{ c: true }, false, '"value" is not allowed'] - ]); + ]; + + Helper.validate(schema, tests); Helper.validateJsonSchema(schema, false); - Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ - [{}, false], - [{ a: 'x' }, false], - [{ b: 1 }, false], - [{ c: true }, false] - ]); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); }); it('forbids a nested object when the nested schema has presence: forbidden preference', () => { @@ -3893,12 +4176,13 @@ describe('jsonSchema', () => { a: Joi.string() }).prefs({ presence: 'forbidden' }) }); - - Helper.validate(schema, [ + const tests = [ [{}, true], [{ nested: {} }, false, '"nested" is not allowed'], [{ nested: { a: 'x' } }, false, '"nested" is not allowed'] - ]); + ]; + + Helper.validate(schema, tests); Helper.validateJsonSchema(schema, { type: 'object', @@ -3908,11 +4192,7 @@ describe('jsonSchema', () => { additionalProperties: false }); - Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ - [{}, true], - [{ nested: {} }, false], - [{ nested: { a: 'x' } }, false] - ]); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); }); it('lets explicit nested presence override nested forbidden preference while forbidding inherited child keys', () => { @@ -3922,12 +4202,13 @@ describe('jsonSchema', () => { a: Joi.string() }).prefs({ presence: 'forbidden' }).optional() }); - - Helper.validate(schema, [ + const tests = [ [{}, true], [{ nested: {} }, true], [{ nested: { a: 'x' } }, false, '"nested.a" is not allowed'] - ]); + ]; + + Helper.validate(schema, tests); Helper.validateJsonSchema(schema, { type: 'object', @@ -3943,11 +4224,7 @@ describe('jsonSchema', () => { additionalProperties: false }); - Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ - [{}, true], - [{ nested: {} }, true], - [{ nested: { a: 'x' } }, false] - ]); + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); }); it('explicit presence flag overrides presence preference', () => { @@ -4063,6 +4340,21 @@ describe('jsonSchema', () => { }); }); + it('represents custom type allow values when the base schema has no type', () => { + + const custom = Joi.extend({ + type: 'mystery', + base: Joi.any() + }); + + Helper.validateJsonSchema(custom.mystery().allow('a'), { + anyOf: [ + {}, + { enum: ['a'] } + ] + }); + }); + it('represents custom rule with options jsonSchema', () => { const custom = Joi.extend({ @@ -4505,6 +4797,16 @@ describe('jsonSchema', () => { Helper.validateJsonSchema(Joi.symbol(), {}); }); + it('represents symbol().allow() string exceptions as anyOf', () => { + + Helper.validateJsonSchema(Joi.symbol().allow('a'), { + anyOf: [ + {}, + { enum: ['a'] } + ] + }); + }); + it('represents anyOf for Joi.symbol().map()', () => { const s1 = Symbol('1'); From 7bf57015986aba2912038ef754bb7a9c382b1743 Mon Sep 17 00:00:00 2001 From: Hafez Date: Sat, 18 Apr 2026 13:41:55 +0200 Subject: [PATCH 25/39] fix(json-schema): preserve allowed values and date annotations --- lib/base.js | 189 +++++++++++++++++++++++++++++++++++----------- lib/common.js | 6 ++ lib/types/date.js | 10 ++- 3 files changed, 158 insertions(+), 47 deletions(-) diff --git a/lib/base.js b/lib/base.js index 0cd1d9ba..f0802142 100644 --- a/lib/base.js +++ b/lib/base.js @@ -129,7 +129,6 @@ internals.Base = class { const onlyValues = valids && valids.filter((v) => typeof v !== 'symbol'); const nonNullValids = valids && valids.filter((v) => v !== null); const rawAllowedValues = rawValids && rawValids.filter((v) => typeof v !== 'symbol' && v !== null); - const allowedValues = valids && valids.filter((v) => typeof v !== 'symbol' && v !== null); let typesOverlap = true; // If 'only' is set, check if the allowed values' types overlap with the schema type @@ -157,7 +156,10 @@ internals.Base = class { } if (this._flags.default !== undefined && typeof this._flags.default !== 'function') { - schema.default = this._flags.default; + const defaultValue = internals.jsonSchemaDefaultValue(this, this._flags.default); + if (defaultValue !== undefined) { + schema.default = defaultValue; + } } // Apply type-specific JSON Schema conversion @@ -184,8 +186,8 @@ internals.Base = class { } } - internals.applyExamples(schema, this.$_terms.examples); - internals.applyMetas(schema, this.$_terms.metas); + internals.applyExamples(this, schema, this.$_terms.examples); + internals.applyMetas(this, schema, this.$_terms.metas); if (rootCall && Object.keys(defs).length) { schema.$defs = defs; @@ -214,15 +216,45 @@ internals.Base = class { // If values are allowed but not exclusive, add them via 'anyOf' when // the base schema would otherwise reject them. - const extras = isTypeAny ? [] : internals.allowedValuesNeedingEnum(this, schema, rawAllowedValues, allowedValues, jsonSchemaType, prefs); - if (extras.length) { + const extras = []; + if (!isTypeAny) { + const base = internals.onlyBaseClone(this); + const schemaTypes = new Set(internals.schemaTypes(schema) || []); + schemaTypes.add(jsonSchemaType || this.type); + + for (const rawValue of rawAllowedValues) { + const representations = internals.jsonSchemaRepresentations(this, rawValue); + const candidates = []; + + for (const value of representations) { + const valueType = internals.jsonSchemaValueType(value); + if (valueType === null || + !schemaTypes.has(valueType)) { + + extras.push(value); + continue; + } + + candidates.push(value); + } + + if (candidates.length && + base.validate(rawValue, prefs).error) { + + extras.push(...candidates); + } + } + } + + const uniqueExtras = internals.uniqueJsonSchemaValues(extras); + if (uniqueExtras.length) { if (!schema.anyOf) { schema = { anyOf: [schema] }; } - schema.anyOf.push({ enum: extras }); + schema.anyOf.push({ enum: uniqueExtras }); } } } @@ -295,6 +327,14 @@ internals.Base = class { return { anyOf: results }; } + if (isOnly && + rawValids && + rawValids.some((v) => typeof v !== 'symbol') && + !onlyValues.length) { + + return false; + } + if (isOnly && onlyValues && onlyValues.length) { schema = internals.finalizeOnlySchema(this, schema, onlyValues, mode, subOptions); } @@ -1480,29 +1520,6 @@ internals.onlyCanRetainBaseSchema = function (source, values, prefs) { }; -internals.allowedValuesNeedingEnum = function (source, schema, rawValues, values, jsonSchemaType, prefs) { - - const base = internals.onlyBaseClone(source); - const comparableType = jsonSchemaType || source.type; - const extras = []; - - for (let i = 0; i < values.length; ++i) { - const value = values[i]; - - if (typeof value !== comparableType) { - extras.push(value); - continue; - } - - if (base.validate(rawValues[i], prefs).error) { - extras.push(value); - } - } - - return extras; -}; - - internals.onlyBaseSchema = function (source, mode, options) { return internals.onlyBaseClone(source).$_jsonSchema(mode, options); @@ -1549,34 +1566,44 @@ internals.onlyTypes = function (values) { internals.jsonSchemaValues = function (source, values) { - return values.map((value) => internals.jsonSchemaValue(source, value)); + return internals.uniqueJsonSchemaValues(values.flatMap((value) => internals.jsonSchemaRepresentations(source, value))); }; -internals.jsonSchemaValue = function (source, value) { +internals.jsonSchemaRepresentations = function (source, value) { + + if (source.type === 'date') { + if (!(value instanceof Date)) { + return []; + } - if (source.type === 'date' && - value instanceof Date) { + return internals.jsonSchemaDateValues(source, value); + } - return internals.jsonSchemaDateValue(source, value); + if (value instanceof Date) { + return []; } - return value; + return [value]; }; -internals.jsonSchemaDateValue = function (source, value) { +internals.jsonSchemaDateValues = function (source, value) { const format = source._flags.format; if (format === 'javascript') { - return value.getTime(); + return [value.getTime()]; } if (format === 'unix') { - return value.getTime() / 1000; + return [value.getTime() / 1000]; } - return value.toISOString(); + if (format === 'iso') { + return [value.toISOString()]; + } + + return [value.toISOString(), value.getTime()]; }; @@ -1604,18 +1631,17 @@ internals.schemaTypes = function (schema) { }; -internals.applyExamples = function (schema, examples) { +internals.applyExamples = function (source, schema, examples) { if (!examples) { return; } - schema.examples = internals.mergeExamples(schema.examples, examples); + schema.examples = internals.mergeExamples(schema.examples, internals.jsonSchemaAnnotationValue(source, examples)); }; - -internals.applyMetas = function (schema, metas = []) { +internals.applyMetas = function (source, schema, metas = []) { if (!metas.length) { @@ -1632,7 +1658,7 @@ internals.applyMetas = function (schema, metas = []) { for (const [key, value] of Object.entries(meta)) { if (key === 'examples') { - const merged = internals.mergeExamples(schema.examples, value); + const merged = internals.mergeExamples(schema.examples, internals.jsonSchemaAnnotationValue(source, value)); if (merged !== undefined) { schema.examples = merged; } @@ -1647,7 +1673,7 @@ internals.applyMetas = function (schema, metas = []) { continue; } - schema[key] = value; + schema[key] = internals.jsonSchemaAnnotationValue(source, value); } } }; @@ -1672,6 +1698,77 @@ internals.mergeExamples = function (existing, next) { }; +internals.uniqueJsonSchemaValues = function (values) { + + return internals.mergeExamples([], values); +}; + + +internals.jsonSchemaAnnotationValue = function (source, value) { + + if (value instanceof Date) { + return internals.jsonSchemaAnnotationDateValue(source, value); + } + + if (Array.isArray(value)) { + return value.map((item) => internals.jsonSchemaAnnotationValue(source, item)); + } + + if (internals.isPlainObject(value)) { + const copy = {}; + for (const [key, item] of Object.entries(value)) { + copy[key] = internals.jsonSchemaAnnotationValue(source, item); + } + + return copy; + } + + return value; +}; + + +internals.jsonSchemaDefaultValue = function (source, value) { + + if (source?.type === 'date' && + value === 'now') { + + return undefined; + } + + return internals.jsonSchemaAnnotationValue(source, value); +}; + + +internals.jsonSchemaAnnotationDateValue = function (source, value) { + + if (source?.type === 'date') { + const format = source._flags.format; + if (format === 'javascript') { + return value.getTime(); + } + + if (format === 'unix') { + return value.getTime() / 1000; + } + } + + return value.toISOString(); +}; + + +internals.isPlainObject = function (value) { + + if (!value || + typeof value !== 'object') { + + return false; + } + + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + + internals.schemaCanMatchNull = function (schema) { if (schema === false) { diff --git a/lib/common.js b/lib/common.js index 7f065320..16061e1f 100755 --- a/lib/common.js +++ b/lib/common.js @@ -124,6 +124,12 @@ exports.isIsoDate = function (date) { }; +exports.isoDatePattern = function () { + + return internals.isoDate.source.replace(/\\:/g, ':'); +}; + + exports.isNumber = function (value) { return typeof value === 'number' && !isNaN(value); diff --git a/lib/types/date.js b/lib/types/date.js index a9d9d176..eb050589 100755 --- a/lib/types/date.js +++ b/lib/types/date.js @@ -70,8 +70,16 @@ module.exports = Any.extend({ return res; } - res.type = 'string'; + if (format === 'iso') { + res.type = 'string'; + res.pattern = Common.isoDatePattern(); + return res; + } + + res.type = ['string', 'number']; res.format = 'date-time'; + res.minimum = -internals.maxJs; + res.maximum = internals.maxJs; return res; }, From b1a1cc119846fb3381deb2fda24ac293faabfad0 Mon Sep 17 00:00:00 2001 From: Hafez Date: Sat, 18 Apr 2026 14:01:37 +0200 Subject: [PATCH 26/39] test(json-schema): drop binary format expectation --- test/json-schema.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/json-schema.js b/test/json-schema.js index e649c991..85dd3cce 100644 --- a/test/json-schema.js +++ b/test/json-schema.js @@ -1877,7 +1877,6 @@ describe('jsonSchema', () => { Helper.validateJsonSchema(Joi.binary().min(10).max(100), { type: 'string', - format: 'binary', minLength: 10, maxLength: 100 }); @@ -1885,14 +1884,13 @@ describe('jsonSchema', () => { it('represents binary with constraints', () => { - Helper.validateJsonSchema(Joi.binary().min(1).max(10).length(5).custom(() => {}), { type: 'string', format: 'binary', minLength: 5, maxLength: 5 }); + Helper.validateJsonSchema(Joi.binary().min(1).max(10).length(5).custom(() => {}), { type: 'string', minLength: 5, maxLength: 5 }); }); it('represents binary encoding', () => { Helper.validateJsonSchema(Joi.binary().encoding('base64').min(3), { type: 'string', - format: 'binary', contentEncoding: 'base64', minLength: 3 }); From 00cd9772451eae95905fc21230d9494afa830a05 Mon Sep 17 00:00:00 2001 From: Hafez Date: Sat, 18 Apr 2026 14:01:40 +0200 Subject: [PATCH 27/39] fix(json-schema): remove binary format from draft output --- lib/types/binary.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/types/binary.js b/lib/types/binary.js index e2a99e97..af22b226 100755 --- a/lib/types/binary.js +++ b/lib/types/binary.js @@ -36,7 +36,6 @@ module.exports = Any.extend({ jsonSchema(schema, res, mode, options) { res.type = 'string'; - res.format = 'binary'; if (schema._flags.encoding) { res.contentEncoding = schema._flags.encoding; From 6a8d64fa9f10db6c8de74e98f1ca18501d1ffa70 Mon Sep 17 00:00:00 2001 From: Hafez Date: Sat, 18 Apr 2026 14:33:33 +0200 Subject: [PATCH 28/39] test(json-schema): cover binary contentEncoding mapping --- test/json-schema.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/json-schema.js b/test/json-schema.js index 85dd3cce..4fddf608 100644 --- a/test/json-schema.js +++ b/test/json-schema.js @@ -1895,6 +1895,32 @@ describe('jsonSchema', () => { minLength: 3 }); }); + + it('represents base64url binary encoding', () => { + + Helper.validateJsonSchema(Joi.binary().encoding('base64url').min(3), { + type: 'string', + contentEncoding: 'base64url', + minLength: 3 + }); + }); + + it('represents hex binary encoding as base16', () => { + + Helper.validateJsonSchema(Joi.binary().encoding('hex').min(2), { + type: 'string', + contentEncoding: 'base16', + minLength: 2 + }); + }); + + it('omits non-transfer binary encodings from JSON Schema annotations', () => { + + Helper.validateJsonSchema(Joi.binary().encoding('utf8').min(3), { + type: 'string', + minLength: 3 + }); + }); }); describe('boolean', () => { From b09db0f8d985bfef039f9c09bcf614f9a680629f Mon Sep 17 00:00:00 2001 From: Hafez Date: Sat, 18 Apr 2026 14:33:35 +0200 Subject: [PATCH 29/39] fix(json-schema): normalize binary contentEncoding values --- lib/types/binary.js | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/lib/types/binary.js b/lib/types/binary.js index af22b226..15be6c6f 100755 --- a/lib/types/binary.js +++ b/lib/types/binary.js @@ -37,8 +37,9 @@ module.exports = Any.extend({ res.type = 'string'; - if (schema._flags.encoding) { - res.contentEncoding = schema._flags.encoding; + const contentEncoding = internals.contentEncoding(schema._flags.encoding); + if (contentEncoding) { + res.contentEncoding = contentEncoding; } return res; @@ -125,3 +126,27 @@ module.exports = Any.extend({ 'binary.min': '{{#label}} must be at least {{#limit}} bytes' } }); + + +internals.contentEncoding = function (encoding) { + + // JSON Schema's contentEncoding annotation follows RFC 4648 / MIME transfer + // encoding names, not Node's full Buffer encoding namespace. Map only the + // binary transfer encodings Joi can express here and omit charset-style + // encodings such as utf8/latin1 that have no honest contentEncoding value. + if (!encoding) { + return undefined; + } + + if (encoding === 'hex') { + return 'base16'; + } + + if (encoding === 'base64' || + encoding === 'base64url') { + + return encoding; + } + + return undefined; +}; From 9ec80d0a467dd5820c1ce9f35437b9d0fd10eca1 Mon Sep 17 00:00:00 2001 From: Hafez Date: Sat, 18 Apr 2026 14:56:38 +0200 Subject: [PATCH 30/39] test(json-schema): exercise standard format validation --- test/json-schema.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/json-schema.js b/test/json-schema.js index 4fddf608..e05b29f8 100644 --- a/test/json-schema.js +++ b/test/json-schema.js @@ -3578,6 +3578,19 @@ describe('jsonSchema', () => { ]); }); + it('validates standard formats with Ajv formats', () => { + + Helper.validateJsonSchemaValues(Joi.string().email()['~standard'].jsonSchema.input(), [ + ['person@example.com', true], + ['not-an-email', false] + ]); + + Helper.validateJsonSchemaValues(Joi.string().guid()['~standard'].jsonSchema.input(), [ + ['550e8400-e29b-41d4-a716-446655440000', true], + ['not-a-uuid', false] + ]); + }); + it('represents token with a validating pattern', () => { const schema = Joi.string().token(); From d9debf45db6a23b9ba769f9b110b82ebc7bd3d7c Mon Sep 17 00:00:00 2001 From: Hafez Date: Sat, 18 Apr 2026 14:56:46 +0200 Subject: [PATCH 31/39] test(json-schema): validate standard formats with ajv-formats --- package.json | 1 + test/helper.js | 21 ++++++++------------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index a2a7c717..87d8b63b 100755 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@hapi/lab": "^26.0.0", "@types/node": "^20.17.47", "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", "typescript": "^5.8.3" }, "scripts": { diff --git a/test/helper.js b/test/helper.js index c9f2d4a8..4caded44 100644 --- a/test/helper.js +++ b/test/helper.js @@ -2,6 +2,7 @@ const Code = require('@hapi/code'); const Ajv = require('ajv/dist/2020'); +const AjvFormats = require('ajv-formats'); const internals = {}; @@ -13,23 +14,12 @@ internals.ajvOptions = { strict: true, allowUnionTypes: true, formats: { - banana: true, - binary: true, - 'date-time': true, - duration: true, - email: true, - hostname: true, - ip: true, - ipv4: true, - uri: true, - uuid: true + banana: true }, keywords: ['x-constraint', 'foo'], strictTuples: true }; -internals.ajvValidator = new Ajv(internals.ajvOptions); - exports.skip = Symbol('skip'); @@ -178,10 +168,15 @@ exports.validateJsonSchemaValues = function (schema, tests, ajvOptionsOverride) internals.ajv = function (options) { - return options ? new Ajv({ ...internals.ajvOptions, ...options }) : internals.ajvValidator; + const validator = new Ajv(options ? { ...internals.ajvOptions, ...options } : internals.ajvOptions); + AjvFormats(validator); + return validator; }; +internals.ajvValidator = internals.ajv(); + + internals.thrownAt = function () { const error = new Error(); From c101a44008257d7db4d5a8f170d8d82c51dd16f1 Mon Sep 17 00:00:00 2001 From: Hafez Date: Sun, 19 Apr 2026 13:28:38 +0200 Subject: [PATCH 32/39] test(json-schema): cover hoisted when conditionals --- test/json-schema.js | 606 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 597 insertions(+), 9 deletions(-) diff --git a/test/json-schema.js b/test/json-schema.js index e05b29f8..1b4e60c9 100644 --- a/test/json-schema.js +++ b/test/json-schema.js @@ -4801,30 +4801,618 @@ describe('jsonSchema', () => { }); }); - it('represents complex alternatives with multiple conditions on object', () => { + it('hoists sibling literal whens to object-level conditionals', () => { const schema = Joi.object({ - a: Joi.any(), - b: Joi.any() - .when('a', { is: 1, then: Joi.string() }) - .when('a', { is: 2, then: Joi.number() }) + mode: Joi.string().required(), + payload: Joi.any().when('mode', { is: 'numeric', then: Joi.number(), otherwise: Joi.string() }) }); Helper.validateJsonSchema(schema, { type: 'object', properties: { - a: {}, - b: { + mode: { type: 'string', minLength: 1 }, + payload: { anyOf: [ + { type: 'number' }, + { type: 'string', minLength: 1 } + ] + } + }, + required: ['mode'], + additionalProperties: false, + if: { + type: 'object', + properties: { + mode: { const: 'numeric' } + }, + required: ['mode'] + }, + then: { + properties: { + payload: { type: 'number' } + } + }, + else: { + properties: { + payload: { type: 'string', minLength: 1 } + } + } + }); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ + [{ mode: 'numeric', payload: 1 }, true], + [{ mode: 'numeric' }, true], + [{ mode: 'label', payload: 'hi' }, true], + [{ mode: 'label' }, true], + [{ mode: 'other', payload: 'hello' }, true], + [{ mode: 'numeric', payload: 'oops' }, false], + [{ mode: 'label', payload: 42 }, false], + [{ mode: 'other', payload: { nested: true } }, false] + ]); + }); + + it('hoists required branch presence to the object-level conditional', () => { + + const schema = Joi.object({ + mode: Joi.string().required(), + payload: Joi.any().when('mode', { is: 'needs-payload', then: Joi.required() }) + }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + mode: { type: 'string', minLength: 1 }, + payload: {} + }, + required: ['mode'], + additionalProperties: false, + if: { + type: 'object', + properties: { + mode: { const: 'needs-payload' } + }, + required: ['mode'] + }, + then: { + properties: { + payload: {} + }, + required: ['payload'] + } + }); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ + [{ mode: 'needs-payload', payload: 1 }, true], + [{ mode: 'needs-payload', payload: 'hi' }, true], + [{ mode: 'needs-payload' }, false], + [{ mode: 'passthrough' }, true], + [{ mode: 'passthrough', payload: 'hi' }, true], + [{ mode: 'passthrough', payload: 42 }, true], + [{ mode: 'passthrough', payload: { nested: true } }, true] + ]); + }); + + it('hoists sibling literal whens with passthrough matches and string fallbacks', () => { + + const schema = Joi.object({ + mode: Joi.string().required(), + payload: Joi.any().when('mode', { is: 'passthrough', otherwise: Joi.string() }) + }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + mode: { type: 'string', minLength: 1 }, + payload: {} + }, + required: ['mode'], + additionalProperties: false, + if: { + type: 'object', + properties: { + mode: { const: 'passthrough' } + }, + required: ['mode'] + }, + else: { + properties: { + payload: { type: 'string', minLength: 1 } + } + } + }); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ + [{ mode: 'passthrough', payload: 1 }, true], + [{ mode: 'passthrough', payload: { nested: true } }, true], + [{ mode: 'passthrough' }, true], + [{ mode: 'label', payload: 'hi' }, true], + [{ mode: 'label' }, true], + [{ mode: 'label', payload: 42 }, false], + [{ mode: 'label', payload: { nested: true } }, false] + ]); + }); + + it('hoists multiple sibling literal whens as separate object-level conditionals', () => { + + const schema = Joi.object({ + mode: Joi.string(), + payload: Joi.any() + .when('mode', { is: 'text', then: Joi.string() }) + .when('mode', { is: 'count', then: Joi.number() }) + }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + mode: { type: 'string', minLength: 1 }, + payload: {} + }, + additionalProperties: false, + allOf: [ + { + if: { + type: 'object', + properties: { + mode: { const: 'text' } + }, + required: ['mode'] + }, + then: { + properties: { + payload: { type: 'string', minLength: 1 } + } + } + }, + { + if: { + type: 'object', + properties: { + mode: { const: 'count' } + }, + required: ['mode'] + }, + then: { + properties: { + payload: { type: 'number' } + } + } + } + ] + }); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ + [{ mode: 'text', payload: 'hello' }, true], + [{ mode: 'count', payload: 3 }, true], + [{ mode: 'other', payload: { nested: true } }, true], + [{ payload: 3 }, true], + [{ mode: 'text', payload: 3 }, false], + [{ mode: 'count', payload: 'hello' }, false] + ]); + }); + + it('appends hoisted conditionals to existing object allOf constraints', () => { + + const schema = Joi.object({ + mode: Joi.string(), + overrideMode: Joi.string(), + legacyMode: Joi.string(), + payload: Joi.any().when('mode', { is: 'numeric', then: Joi.number() }) + }) + .nand('mode', 'overrideMode') + .nand('overrideMode', 'legacyMode'); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + mode: { type: 'string', minLength: 1 }, + overrideMode: { type: 'string', minLength: 1 }, + legacyMode: { type: 'string', minLength: 1 }, + payload: {} + }, + additionalProperties: false, + allOf: [ + { + not: { + properties: { + mode: true, + overrideMode: true + }, + required: ['mode', 'overrideMode'] + } + }, + { + not: { + properties: { + overrideMode: true, + legacyMode: true + }, + required: ['overrideMode', 'legacyMode'] + } + }, + { + if: { + type: 'object', + properties: { + mode: { const: 'numeric' } + }, + required: ['mode'] + }, + then: { + properties: { + payload: { type: 'number' } + } + } + } + ] + }); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ + [{ mode: 'numeric', payload: 3 }, true], + [{ mode: 'label', payload: 'hi' }, true], + [{ overrideMode: 'manual', payload: { nested: true } }, true], + [{ mode: 'numeric', payload: 'oops' }, false], + [{ mode: 'numeric', overrideMode: 'manual' }, false], + [{ overrideMode: 'manual', legacyMode: 'v1' }, false] + ]); + }); + + it('hoists simple object-path whens to object-level conditionals', () => { + + const schema = Joi.object({ + settings: Joi.object({ + mode: Joi.string() + }), + payload: Joi.any().when('settings.mode', { is: 'numeric', then: Joi.number(), otherwise: Joi.string() }) + }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + settings: { + type: 'object', + properties: { + mode: { type: 'string', minLength: 1 } + }, + additionalProperties: false + }, + payload: {} + }, + additionalProperties: false, + if: { + type: 'object', + properties: { + settings: { + type: 'object', + properties: { + mode: { const: 'numeric' } + }, + required: ['mode'] + } + }, + required: ['settings'] + }, + then: { + properties: { + payload: { type: 'number' } + } + }, + else: { + properties: { + payload: { type: 'string', minLength: 1 } + } + } + }); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ + [{ settings: { mode: 'numeric' }, payload: 1 }, true], + [{ settings: { mode: 'label' }, payload: 'hi' }, true], + [{ settings: {}, payload: 'hi' }, true], + [{ payload: 'hi' }, true], + [{ settings: { mode: 'numeric' }, payload: 'oops' }, false], + [{ settings: { mode: 'label' }, payload: 42 }, false], + [{ payload: 42 }, false] + ]); + }); + + it('keeps fixed array-index whens on the child as anyOf', () => { + + const schema = Joi.object({ + items: Joi.array().items(Joi.object({ + kind: Joi.string() + })), + payload: Joi.any().when('items.0.kind', { is: 'numeric', then: Joi.number(), otherwise: Joi.string() }) + }); + + // Fixed array indices are representable with `prefixItems`, but + // large refs like `items.9999.kind` would require enormous + // conditional scaffolding. Keep the lossy `anyOf` fallback until + // we decide on an index cap or another strategy. + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + items: { + type: 'array', + items: { + type: 'object', + properties: { + kind: { type: 'string', minLength: 1 } + }, + additionalProperties: false + } + }, + payload: { + anyOf: [ + { type: 'number' }, + { type: 'string', minLength: 1 } + ] + } + }, + additionalProperties: false + }); + }); + + it('keeps malformed object-path whens on the child as anyOf', () => { + + const refs = [ + Object.assign(Joi.ref('mode'), { path: [] }), + Object.assign(Joi.ref('mode'), { path: [0] }), + Joi.ref('settings..mode') + ]; + + for (const ref of refs) { + const schema = Joi.object({ + settings: Joi.object({ + mode: Joi.string() + }), + payload: Joi.any().when(ref, { is: 'numeric', then: Joi.number(), otherwise: Joi.string() }) + }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + settings: { + type: 'object', + properties: { + mode: { type: 'string', minLength: 1 } + }, + additionalProperties: false + }, + payload: { + anyOf: [ + { type: 'number' }, + { type: 'string', minLength: 1 } + ] + } + }, + additionalProperties: false + }); + } + }); + + it('keeps schema-condition whens on the child as anyOf', () => { + + const schema = Joi.object({ + type: Joi.string(), + value: Joi.any().when( + Joi.object({ type: Joi.valid('num') }).unknown(), + { + then: Joi.number(), + otherwise: Joi.string() + } + ) + }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + type: { type: 'string', minLength: 1 }, + value: { + anyOf: [ + { type: 'number' }, + { type: 'string', minLength: 1 } + ] + } + }, + additionalProperties: false + }); + }); + + it('keeps switch whens on the child as anyOf', () => { + + const schema = Joi.object({ + type: Joi.string(), + value: Joi.any().when('type', { + switch: [ + { is: 'num', then: Joi.number() }, + { is: 'str', then: Joi.string() } + ], + otherwise: Joi.boolean() + }) + }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + type: { type: 'string', minLength: 1 }, + value: { + anyOf: [ + { type: 'number' }, { type: 'string', minLength: 1 }, - {}, - { type: 'number' } + { type: 'boolean' } ] } }, additionalProperties: false }); }); + + it('keeps adjusted refs on the child as anyOf', () => { + + const schema = Joi.object({ + type: Joi.string(), + value: Joi.any().when(Joi.ref('type', { + adjust: (value) => value + }), { is: 'num', then: Joi.number(), otherwise: Joi.string() }) + }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + type: { type: 'string', minLength: 1 }, + value: { + anyOf: [ + { type: 'number' }, + { type: 'string', minLength: 1 } + ] + } + }, + additionalProperties: false + }); + }); + + it('keeps non-sibling refs on the child as anyOf', () => { + + const refs = [ + Joi.ref('$type'), + Joi.ref('.type'), + Joi.ref('type', { map: [['num', 'num']] }), + Joi.ref('type', { iterables: true }), + Joi.in('type') + ]; + + for (const ref of refs) { + const schema = Joi.object({ + type: Joi.any(), + value: Joi.any().when(ref, { is: 'num', then: Joi.number(), otherwise: Joi.string() }) + }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + type: {}, + value: { + anyOf: [ + { type: 'number' }, + { type: 'string', minLength: 1 } + ] + } + }, + additionalProperties: false + }); + } + }); + + it('keeps non-literal is predicates on the child as anyOf', () => { + + const schema = Joi.object({ + type: Joi.any(), + value: Joi.any().when('type', { is: Joi.number().greater(10), then: Joi.number(), otherwise: Joi.string() }) + }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + type: {}, + value: { + anyOf: [ + { type: 'number' }, + { type: 'string', minLength: 1 } + ] + } + }, + additionalProperties: false + }); + }); + + it('keeps multi-valued literal predicates on the child as anyOf', () => { + + const schema = Joi.object({ + type: Joi.any(), + value: Joi.any().when('type', { is: Joi.valid(1, 2), then: Joi.number(), otherwise: Joi.string() }) + }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + type: {}, + value: { + anyOf: [ + { type: 'number' }, + { type: 'string', minLength: 1 } + ] + } + }, + additionalProperties: false + }); + }); + + it('keeps non-hoistable any predicates on the child as anyOf', () => { + + const predicates = [ + Joi.any(), + Joi.any().custom((value) => value), + Joi.valid('num').custom((value) => value), + Joi.valid('num').invalid('other'), + Joi.valid(Symbol('x')), + Joi.valid(Buffer.alloc(0)) + ]; + + for (const predicate of predicates) { + const schema = Joi.object({ + type: Joi.any(), + value: Joi.any().when('type', { is: predicate, then: Joi.number(), otherwise: Joi.string() }) + }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + type: {}, + value: { + anyOf: [ + { type: 'number' }, + { type: 'string', minLength: 1 } + ] + } + }, + additionalProperties: false + }); + } + }); + + it('hoists null literal whens to object-level conditionals', () => { + + const schema = Joi.object({ + type: Joi.any(), + value: Joi.any().when('type', { is: null, then: Joi.number(), otherwise: Joi.string() }) + }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + type: {}, + value: {} + }, + additionalProperties: false, + if: { + type: 'object', + properties: { + type: { const: null } + }, + required: ['type'] + }, + then: { + properties: { + value: { type: 'number' } + } + }, + else: { + properties: { + value: { type: 'string', minLength: 1 } + } + } + }); + }); }); describe('symbol', () => { From 70e8c4193ec091348db90e4e81e29dce86e77b9a Mon Sep 17 00:00:00 2001 From: Hafez Date: Sun, 19 Apr 2026 13:28:42 +0200 Subject: [PATCH 33/39] fix(json-schema): hoist simple when conditionals --- lib/types/keys.js | 263 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 241 insertions(+), 22 deletions(-) diff --git a/lib/types/keys.js b/lib/types/keys.js index 3d39d830..1adc3cc4 100755 --- a/lib/types/keys.js +++ b/lib/types/keys.js @@ -50,6 +50,7 @@ module.exports = Any.extend({ jsonSchema(schema, res, mode, options) { const prefs = options.prefs || {}; + const hoistedConditionals = []; res.type = 'object'; @@ -61,32 +62,18 @@ module.exports = Any.extend({ const required = []; for (const child of schema.$_terms.keys) { - const childPrefs = child.schema._preferences - ? Common.preferences(prefs, child.schema._preferences) - : prefs; - const presence = child.schema._flags.presence || childPrefs?.presence; - const jsonSchema = child.schema.$_jsonSchema(mode, { ...options, ignorePresence: true }); - - if (child.schema._flags.id) { - options.$defs[child.schema._flags.id] = jsonSchema; - } - - if (presence === 'forbidden') { - res.properties[child.key] = false; - continue; - } + const hoistedWhens = internals.hoistedWhens(child.schema, child.key, mode, options, prefs); + const childSchema = hoistedWhens ? hoistedWhens.base : child.schema; + const childJsonSchema = internals.childJsonSchema(childSchema, mode, options, prefs); - if (mode === 'output' && child.schema._flags.result === 'strip') { - res.properties[child.key] = false; - continue; + if (childJsonSchema.required) { + required.push(child.key); } - res.properties[child.key] = jsonSchema; + res.properties[child.key] = childJsonSchema.schema; - if (presence === 'required' || - (mode === 'output' && child.schema._flags.default !== undefined && !childPrefs?.noDefaults)) { - - required.push(child.key); + if (hoistedWhens) { + hoistedConditionals.push(...hoistedWhens.conditionals); } } @@ -129,6 +116,10 @@ module.exports = Any.extend({ } } + for (const conditional of hoistedConditionals) { + internals.appendConditionalSchema(res, conditional); + } + // Handle 'additionalProperties' based on unknown keys flag and preferences if (res.additionalProperties === undefined) { @@ -790,6 +781,234 @@ internals.mergeDependentSchemaProperties = function (res, key, properties) { }; +internals.childJsonSchema = function (schema, mode, options, prefs) { + + const childPrefs = schema._preferences + ? Common.preferences(prefs, schema._preferences) + : prefs; + const presence = schema._flags.presence || childPrefs?.presence; + const jsonSchema = schema.$_jsonSchema(mode, { ...options, ignorePresence: true }); + + if (schema._flags.id) { + options.$defs[schema._flags.id] = jsonSchema; + } + + if (presence === 'forbidden' || + (mode === 'output' && schema._flags.result === 'strip')) { + + return { + schema: false, + required: false + }; + } + + return { + schema: jsonSchema, + required: presence === 'required' || + (mode === 'output' && schema._flags.default !== undefined && !childPrefs?.noDefaults) + }; +}; + + +internals.hoistedWhens = function (schema, key, mode, options, prefs) { + + if (!schema.$_terms.whens?.length) { + return null; + } + + const conditionals = []; + const base = schema.clone(); + base.$_terms.whens = null; + + for (const when of schema.$_terms.whens) { + const conditional = internals.hoistedWhen(base, key, when, mode, options, prefs); + if (!conditional) { + return null; + } + + conditionals.push(conditional); + } + + return { base, conditionals }; +}; + + +internals.hoistedWhen = function (base, key, when, mode, options, prefs) { + + if (!when.ref || + when.switch) { + + return null; + } + + const ref = when.ref; + const path = internals.hoistableRefPath(ref); + if (!path) { + + return null; + } + + const literal = internals.literalWhenValue(when.is); + if (!literal.found) { + return null; + } + + const conditional = { + if: internals.hoistedWhenCondition(path, literal.value) + }; + + conditional.then = when.then && internals.hoistedWhenBranch(base.concat(when.then), key, mode, options, prefs); + conditional.else = when.otherwise && internals.hoistedWhenBranch(base.concat(when.otherwise), key, mode, options, prefs); + + if (conditional.then === undefined) { + delete conditional.then; + } + + if (conditional.else === undefined) { + delete conditional.else; + } + + return conditional; +}; + + +internals.hoistableRefPath = function (ref) { + + if (ref.type !== 'value' || + ref.ancestor !== 1 || + !ref.path.length || + ref.adjust !== null || + ref.map !== null || + ref.iterables !== null || + ref.in !== false) { + + return null; + } + + for (const segment of ref.path) { + if (typeof segment !== 'string' || + !segment.length || + /^\d+$/.test(segment)) { + + return null; + } + } + + return ref.path; +}; + + +internals.hoistedWhenCondition = function (path, value) { + + let schema = { + const: value + }; + + for (let i = path.length - 1; i >= 0; --i) { + schema = { + type: 'object', + properties: { + [path[i]]: schema + }, + required: [path[i]] + }; + } + + return schema; +}; + + +internals.hoistedWhenBranch = function (schema, key, mode, options, prefs) { + + const child = internals.childJsonSchema(schema, mode, options, prefs); + const branch = { + properties: { + [key]: child.schema + } + }; + + if (child.required) { + branch.required = [key]; + } + + return branch; +}; + + +internals.literalWhenValue = function (schema) { + + const isAnyType = schema.type === 'any'; + const isOnly = schema._flags.only; + const hasRules = !!schema._rules.length; + const hasInvalids = !!schema._invalids; + + if (!isAnyType) { + return { found: false }; + } + + if (!isOnly) { + return { found: false }; + } + + if (hasRules) { + return { found: false }; + } + + if (hasInvalids) { + return { found: false }; + } + + const values = Array.from(schema._valids._values).filter((value) => typeof value !== 'symbol'); + if (values.length !== 1) { + return { found: false }; + } + + const value = values[0]; + return value === null || ['string', 'number', 'boolean'].includes(typeof value) ? { found: true, value } : { found: false }; +}; + + +internals.appendConditionalSchema = function (res, conditional) { + + if (res.allOf) { + res.allOf.push(conditional); + return; + } + + const hasIf = res.if !== undefined; + + if (hasIf) { + internals.promoteConditional(res, conditional); + return; + } + + Object.assign(res, conditional); +}; + + +internals.takeConditionalSchema = function (res) { + + const conditional = {}; + for (const key of ['if', 'then', 'else']) { + if (res[key] !== undefined) { + conditional[key] = res[key]; + delete res[key]; + } + } + + return conditional; +}; + + +internals.promoteConditional = function (res, conditional) { + + res.allOf = [ + internals.takeConditionalSchema(res), + conditional + ]; +}; + + internals.appendCompositeKeyword = function (res, keyword, value) { if (res.allOf) { From 4da6c303339aace1440e8efd27e391c12eff654f Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 22 Apr 2026 18:24:17 +0200 Subject: [PATCH 34/39] test(json-schema): cover literal switch hoisting --- test/json-schema.js | 130 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 123 insertions(+), 7 deletions(-) diff --git a/test/json-schema.js b/test/json-schema.js index 1b4e60c9..8bbc4e25 100644 --- a/test/json-schema.js +++ b/test/json-schema.js @@ -4812,12 +4812,7 @@ describe('jsonSchema', () => { type: 'object', properties: { mode: { type: 'string', minLength: 1 }, - payload: { - anyOf: [ - { type: 'number' }, - { type: 'string', minLength: 1 } - ] - } + payload: {} }, required: ['mode'], additionalProperties: false, @@ -5218,7 +5213,64 @@ describe('jsonSchema', () => { }); }); - it('keeps switch whens on the child as anyOf', () => { + it('hoists literal switch whens without otherwise to nested object-level conditionals', () => { + + const schema = Joi.object({ + kind: Joi.string(), + value: Joi.any().when('kind', { + switch: [ + { is: 'a', then: Joi.string() }, + { is: 'b', then: Joi.number() } + ] + }) + }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + kind: { type: 'string', minLength: 1 }, + value: {} + }, + additionalProperties: false, + if: { + type: 'object', + properties: { + kind: { const: 'a' } + }, + required: ['kind'] + }, + then: { + properties: { + value: { type: 'string', minLength: 1 } + } + }, + else: { + if: { + type: 'object', + properties: { + kind: { const: 'b' } + }, + required: ['kind'] + }, + then: { + properties: { + value: { type: 'number' } + } + } + } + }); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ + [{ kind: 'a', value: 'hi' }, true], + [{ kind: 'b', value: 42 }, true], + [{ kind: 'c' }, true], + [{ kind: 'c', value: { nested: true } }, true], + [{ kind: 'a', value: 42 }, false], + [{ kind: 'b', value: 'hi' }, false] + ]); + }); + + it('hoists literal switch whens with otherwise to nested object-level conditionals', () => { const schema = Joi.object({ type: Joi.string(), @@ -5235,6 +5287,70 @@ describe('jsonSchema', () => { type: 'object', properties: { type: { type: 'string', minLength: 1 }, + value: {} + }, + additionalProperties: false, + if: { + type: 'object', + properties: { + type: { const: 'num' } + }, + required: ['type'] + }, + then: { + properties: { + value: { type: 'number' } + } + }, + else: { + if: { + type: 'object', + properties: { + type: { const: 'str' } + }, + required: ['type'] + }, + then: { + properties: { + value: { type: 'string', minLength: 1 } + } + }, + else: { + properties: { + value: { type: 'boolean' } + } + } + } + }); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), [ + [{ type: 'num', value: 1 }, true], + [{ type: 'str', value: 'hi' }, true], + [{ type: 'other', value: true }, true], + [{ type: 'other' }, true], + [{ type: 'num', value: 'oops' }, false], + [{ type: 'str', value: 42 }, false], + [{ type: 'other', value: 'hi' }, false] + ]); + }); + + it('keeps non-literal switch whens on the child as anyOf', () => { + + const schema = Joi.object({ + type: Joi.number(), + value: Joi.any().when('type', { + switch: [ + { is: Joi.number().greater(10), then: Joi.number() }, + { is: 5, then: Joi.string() } + ], + otherwise: Joi.boolean() + }) + }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + type: { type: 'number' }, value: { anyOf: [ { type: 'number' }, From 66fd8181aa62088d74030ab82889138f63e53cd9 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 22 Apr 2026 18:24:23 +0200 Subject: [PATCH 35/39] fix(json-schema): hoist literal switch conditionals --- lib/types/keys.js | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/lib/types/keys.js b/lib/types/keys.js index 1adc3cc4..c3782cb2 100755 --- a/lib/types/keys.js +++ b/lib/types/keys.js @@ -835,8 +835,7 @@ internals.hoistedWhens = function (schema, key, mode, options, prefs) { internals.hoistedWhen = function (base, key, when, mode, options, prefs) { - if (!when.ref || - when.switch) { + if (!when.ref) { return null; } @@ -848,6 +847,10 @@ internals.hoistedWhen = function (base, key, when, mode, options, prefs) { return null; } + if (when.switch) { + return internals.hoistedSwitchWhen(base, key, when, path, mode, options, prefs); + } + const literal = internals.literalWhenValue(when.is); if (!literal.found) { return null; @@ -872,6 +875,37 @@ internals.hoistedWhen = function (base, key, when, mode, options, prefs) { }; +internals.hoistedSwitchWhen = function (base, key, when, path, mode, options, prefs) { + + let conditional; + + for (let i = when.switch.length - 1; i >= 0; --i) { + const item = when.switch[i]; + const literal = internals.literalWhenValue(item.is); + if (!literal.found) { + return null; + } + + const nested = { + if: internals.hoistedWhenCondition(path, literal.value), + then: internals.hoistedWhenBranch(base.concat(item.then), key, mode, options, prefs) + }; + + const otherwise = item.otherwise && internals.hoistedWhenBranch(base.concat(item.otherwise), key, mode, options, prefs); + if (otherwise !== undefined) { + nested.else = otherwise; + } + else if (conditional !== undefined) { + nested.else = conditional; + } + + conditional = nested; + } + + return conditional; +}; + + internals.hoistableRefPath = function (ref) { if (ref.type !== 'value' || From a0cfbb4054f2cc7883e16ff7997b30eed7504a4d Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 22 Apr 2026 18:55:26 +0200 Subject: [PATCH 36/39] refactor(json-schema): extract conversion modules --- lib/base.js | 688 +------------------------------- lib/json-schema/alternatives.js | 14 + lib/json-schema/base.js | 208 ++++++++++ lib/json-schema/binary.js | 25 ++ lib/json-schema/common.js | 451 +++++++++++++++++++++ lib/json-schema/conditions.js | 98 +++++ lib/json-schema/object.js | 326 +++++++++++++++ lib/types/alternatives.js | 25 +- lib/types/binary.js | 27 +- lib/types/keys.js | 394 +----------------- 10 files changed, 1134 insertions(+), 1122 deletions(-) create mode 100644 lib/json-schema/alternatives.js create mode 100644 lib/json-schema/base.js create mode 100644 lib/json-schema/binary.js create mode 100644 lib/json-schema/common.js create mode 100644 lib/json-schema/conditions.js create mode 100644 lib/json-schema/object.js diff --git a/lib/base.js b/lib/base.js index f0802142..9c911332 100644 --- a/lib/base.js +++ b/lib/base.js @@ -10,47 +10,14 @@ const Extend = require('./extend'); const Manifest = require('./manifest'); const Messages = require('./messages'); const Modify = require('./modify'); +const JsonSchema = require('./json-schema/base'); const Ref = require('./ref'); const Trace = require('./trace'); const Validator = require('./validator'); const Values = require('./values'); -const internals = { - standardTypes: new Set(['string', 'number', 'integer', 'boolean', 'object', 'array', 'null']), - jsonSchemaTarget: 'draft-2020-12', - primitiveTypes: new Set(['string', 'number', 'boolean']), - // Keywords copied through from user-provided meta() values. - metaPassthroughKeywords: new Set([ - '$comment', - 'contentEncoding', - 'contentMediaType', - 'contentSchema', - 'deprecated', - 'examples', - 'format', - 'readOnly', - 'title', - 'writeOnly' - ]), - // Keywords preserved when exclusive valids have to fall back to enum-only - // output. This intentionally excludes validating keywords such as format. - onlyFallbackAnnotationKeywords: new Set([ - '$comment', - '$defs', - 'contentEncoding', - 'contentMediaType', - 'contentSchema', - 'default', - 'deprecated', - 'description', - 'examples', - 'readOnly', - 'title', - 'writeOnly' - ]), - nullSchema: () => ({ type: 'null' }) -}; +const internals = {}; internals.Base = class { @@ -98,248 +65,7 @@ internals.Base = class { $_jsonSchema(mode, options = {}) { - if (options.target !== undefined && - options.target !== internals.jsonSchemaTarget) { - - throw new Error(`Unsupported JSON Schema target: ${options.target}`); - } - - const rootCall = !options.$defs; - const defs = options.$defs ?? {}; - - // Merge parent prefs with this schema's prefs (explicit takes precedence) - - const prefs = this._preferences - ? Common.preferences(options.prefs, this._preferences) - : options.prefs; - const presence = this._flags.presence || prefs?.presence; - - if (presence === 'forbidden' && !options.ignorePresence) { - return false; - } - - let schema = {}; - - const jsonSchemaType = internals.jsonSchemaType(this); - const isTypeAny = this.type === 'any'; - const isOnly = this._flags.only; - - const rawValids = this._valids && Array.from(this._valids._values); - const valids = rawValids && internals.jsonSchemaValues(this, rawValids); - const onlyValues = valids && valids.filter((v) => typeof v !== 'symbol'); - const nonNullValids = valids && valids.filter((v) => v !== null); - const rawAllowedValues = rawValids && rawValids.filter((v) => typeof v !== 'symbol' && v !== null); - let typesOverlap = true; - - // If 'only' is set, check if the allowed values' types overlap with the schema type - - if (rawValids && isOnly && !isTypeAny) { - const comparableValues = rawValids.filter((v) => typeof v !== 'symbol' && v !== null); - if (!comparableValues.length) { - typesOverlap = true; - } - else { - const types = new Set(comparableValues.map((v) => typeof v)); - const type = jsonSchemaType || this.type; - typesOverlap = types.has(type) || (type === 'date' && types.has('object')); - } - } - - // Set the JSON Schema 'type' if it's a standard type and there's an overlap - - if (!isTypeAny && typesOverlap && jsonSchemaType) { - schema.type = jsonSchemaType; - } - - if (this._flags.description) { - schema.description = this._flags.description; - } - - if (this._flags.default !== undefined && typeof this._flags.default !== 'function') { - const defaultValue = internals.jsonSchemaDefaultValue(this, this._flags.default); - if (defaultValue !== undefined) { - schema.default = defaultValue; - } - } - - // Apply type-specific JSON Schema conversion - - const subOptions = { ...options, $defs: defs, prefs }; - if (this._definition.jsonSchema && typesOverlap) { - schema = this._definition.jsonSchema(this, schema, mode, subOptions); - } - - // Apply rule-specific JSON Schema conversions - - for (const rule of this._rules) { - const definition = this._definition.rules[rule.name]; - if (definition.jsonSchema && typesOverlap && !rule._resolve.length) { - schema = definition.jsonSchema(rule, schema, isOnly, mode, subOptions); - } - } - - // Handle shared schemas - - if (this.$_terms.shared) { - for (const shared of this.$_terms.shared) { - defs[shared._flags.id] = shared.$_jsonSchema(mode, subOptions); - } - } - - internals.applyExamples(this, schema, this.$_terms.examples); - internals.applyMetas(this, schema, this.$_terms.metas); - - if (rootCall && Object.keys(defs).length) { - schema.$defs = defs; - } - - // Handle allowed values (valids) - - if (this._valids) { - - const values = isOnly ? onlyValues : nonNullValids.filter((v) => typeof v !== 'symbol'); - if (values.length) { - if (this._flags.only) { - if (!(values.length === 1 && values[0] === null)) { - if (typeof schema !== 'boolean') { - schema.enum = values; - - const types = internals.onlyTypes(values); - - if (types) { - schema.type = types.length === 1 ? types[0] : types; - } - } - } - } - else { - // If values are allowed but not exclusive, add them via 'anyOf' when - // the base schema would otherwise reject them. - - const extras = []; - if (!isTypeAny) { - const base = internals.onlyBaseClone(this); - const schemaTypes = new Set(internals.schemaTypes(schema) || []); - schemaTypes.add(jsonSchemaType || this.type); - - for (const rawValue of rawAllowedValues) { - const representations = internals.jsonSchemaRepresentations(this, rawValue); - const candidates = []; - - for (const value of representations) { - const valueType = internals.jsonSchemaValueType(value); - if (valueType === null || - !schemaTypes.has(valueType)) { - - extras.push(value); - continue; - } - - candidates.push(value); - } - - if (candidates.length && - base.validate(rawValue, prefs).error) { - - extras.push(...candidates); - } - } - } - - const uniqueExtras = internals.uniqueJsonSchemaValues(extras); - if (uniqueExtras.length) { - if (!schema.anyOf) { - schema = { - anyOf: [schema] - }; - } - - schema.anyOf.push({ enum: uniqueExtras }); - } - } - } - } - - // Handle disallowed values (invalids) - - if (this._invalids) { - const invalids = internals.jsonSchemaValues(this, Array.from(this._invalids._values) - .filter((v) => typeof v !== 'symbol')) - .filter((v) => v !== null || internals.schemaCanMatchNull(schema)); - - if (invalids.length) { - schema = internals.appendCompositeKeyword(schema, 'not', { enum: invalids }); - } - } - - // Handle 'null' if it's an allowed value - - if (!isOnly && this._valids && this._valids.has(null) && !isTypeAny) { - if (schema.type) { - schema.type = internals.appendType(schema.type, 'null'); - } - else if (schema.anyOf) { - schema.anyOf.unshift(internals.nullSchema()); - } - else { - schema = { - anyOf: [ - internals.nullSchema(), - schema - ] - }; - } - } - - // Handle conditionals (whens) by generating multiple possible schemas combined with 'anyOf' - - if (this.$_terms.whens) { - - const base = this.clone(); - base.$_terms.whens = null; - - const matches = []; - for (const when of this.$_terms.whens) { - const tests = when.is ? [when] : when.switch; - for (let i = 0; i < tests.length; ++i) { - const test = tests[i]; - if (test.then) { - matches.push(base.concat(test.then).$_jsonSchema(mode, subOptions)); - } - - if (test.otherwise) { - matches.push(base.concat(test.otherwise).$_jsonSchema(mode, subOptions)); - } - - if (!test.then || (i === tests.length - 1 && !test.otherwise)) { - matches.push(base.$_jsonSchema(mode, subOptions)); - } - } - } - - const results = []; - for (const match of matches) { - if (!results.some((r) => deepEqual(r, match))) { - results.push(match); - } - } - - return { anyOf: results }; - } - - if (isOnly && - rawValids && - rawValids.some((v) => typeof v !== 'symbol') && - !onlyValues.length) { - - return false; - } - - if (isOnly && onlyValues && onlyValues.length) { - schema = internals.finalizeOnlySchema(this, schema, onlyValues, mode, subOptions); - } - - return schema; + return JsonSchema.convert(this, mode, options); } // Rules @@ -1406,414 +1132,6 @@ internals.Base = class { }; -internals.appendCompositeKeyword = function (schema, keyword, value) { - - if (typeof schema === 'boolean') { - return schema ? { [keyword]: value } : false; - } - - if (schema.allOf) { - if (schema[keyword] !== undefined) { - schema.allOf.push({ [keyword]: schema[keyword] }); - delete schema[keyword]; - } - - schema.allOf.push({ [keyword]: value }); - return schema; - } - - if (schema[keyword] === undefined) { - schema[keyword] = value; - return schema; - } - - schema.allOf = [ - { [keyword]: schema[keyword] }, - { [keyword]: value } - ]; - - delete schema[keyword]; - - return schema; -}; - - -internals.finalizeOnlySchema = function (source, schema, values, mode, options) { - - let base = internals.onlyCanRetainBaseSchema(source, values, options.prefs) - ? internals.onlyBaseSchema(source, mode, options) - : internals.onlyFallbackAnnotations(schema); - - if (typeof base === 'boolean') { - base = {}; - } - - if (schema.$defs) { - base.$defs = schema.$defs; - } - - if (values.length === 1 && - values[0] === null) { - - base.type = 'null'; - delete base.enum; - return base; - } - - base.enum = values; - - const types = internals.onlyTypes(values); - if (types) { - base.type = types.length === 1 ? types[0] : types; - } - else { - delete base.type; - } - - return base; -}; - - -internals.appendType = function (type, addition) { - - const types = Array.isArray(type) ? type.slice() : [type]; - - if (!types.includes(addition)) { - types.push(addition); - } - - return types.length === 1 ? types[0] : types; -}; - - -internals.onlyCanRetainBaseSchema = function (source, values, prefs) { - - const base = internals.onlyBaseClone(source); - const baseSchema = base.$_jsonSchema('input', { prefs, $defs: {} }); - const baseTypes = internals.schemaTypes(baseSchema); - let checked = false; - - for (const value of values) { - if (value === null) { - continue; - } - - const valueType = internals.jsonSchemaValueType(value); - if (valueType === null) { - return false; - } - - if (baseTypes && - !baseTypes.has(valueType)) { - - continue; - } - - checked = true; - - if (base.validate(value, prefs).error) { - return false; - } - } - - return checked || !baseTypes; -}; - - -internals.onlyBaseSchema = function (source, mode, options) { - - return internals.onlyBaseClone(source).$_jsonSchema(mode, options); -}; - - -internals.onlyBaseClone = function (source) { - - const base = source.clone(); - base._valids = null; - base._invalids = null; - delete base._flags.only; - return base; -}; - - -internals.onlyFallbackAnnotations = function (schema) { - - if (typeof schema === 'boolean') { - return {}; - } - - const annotations = {}; - for (const key of internals.onlyFallbackAnnotationKeywords) { - if (schema[key] !== undefined) { - annotations[key] = schema[key]; - } - } - - return annotations; -}; - - -internals.onlyTypes = function (values) { - - const types = values.map(internals.jsonSchemaValueType); - if (types.includes(null)) { - return null; - } - - return [...new Set(types)]; -}; - - -internals.jsonSchemaValues = function (source, values) { - - return internals.uniqueJsonSchemaValues(values.flatMap((value) => internals.jsonSchemaRepresentations(source, value))); -}; - - -internals.jsonSchemaRepresentations = function (source, value) { - - if (source.type === 'date') { - if (!(value instanceof Date)) { - return []; - } - - return internals.jsonSchemaDateValues(source, value); - } - - if (value instanceof Date) { - return []; - } - - return [value]; -}; - - -internals.jsonSchemaDateValues = function (source, value) { - - const format = source._flags.format; - if (format === 'javascript') { - return [value.getTime()]; - } - - if (format === 'unix') { - return [value.getTime() / 1000]; - } - - if (format === 'iso') { - return [value.toISOString()]; - } - - return [value.toISOString(), value.getTime()]; -}; - - -internals.jsonSchemaValueType = function (value) { - - if (value === null) { - return 'null'; - } - - const type = typeof value; - return internals.primitiveTypes.has(type) ? type : null; -}; - - -internals.schemaTypes = function (schema) { - - if (typeof schema === 'boolean' || - schema.type === undefined) { - - return null; - } - - const types = Array.isArray(schema.type) ? schema.type : [schema.type]; - return new Set(types); -}; - - -internals.applyExamples = function (source, schema, examples) { - - if (!examples) { - - return; - } - - schema.examples = internals.mergeExamples(schema.examples, internals.jsonSchemaAnnotationValue(source, examples)); -}; - -internals.applyMetas = function (source, schema, metas = []) { - - if (!metas.length) { - - return; - } - - for (const meta of metas) { - if (!meta || - typeof meta !== 'object' || - Array.isArray(meta)) { - - continue; - } - - for (const [key, value] of Object.entries(meta)) { - if (key === 'examples') { - const merged = internals.mergeExamples(schema.examples, internals.jsonSchemaAnnotationValue(source, value)); - if (merged !== undefined) { - schema.examples = merged; - } - - continue; - } - - if (!internals.metaPassthroughKeywords.has(key) || - value === undefined || - schema[key] !== undefined) { - - continue; - } - - schema[key] = internals.jsonSchemaAnnotationValue(source, value); - } - } -}; - - -internals.mergeExamples = function (existing, next) { - - if (!Array.isArray(next) || - !next.length) { - - return existing; - } - - const merged = existing ? [...existing] : []; - for (const example of next) { - if (!merged.some((item) => deepEqual(item, example))) { - merged.push(example); - } - } - - return merged; -}; - - -internals.uniqueJsonSchemaValues = function (values) { - - return internals.mergeExamples([], values); -}; - - -internals.jsonSchemaAnnotationValue = function (source, value) { - - if (value instanceof Date) { - return internals.jsonSchemaAnnotationDateValue(source, value); - } - - if (Array.isArray(value)) { - return value.map((item) => internals.jsonSchemaAnnotationValue(source, item)); - } - - if (internals.isPlainObject(value)) { - const copy = {}; - for (const [key, item] of Object.entries(value)) { - copy[key] = internals.jsonSchemaAnnotationValue(source, item); - } - - return copy; - } - - return value; -}; - - -internals.jsonSchemaDefaultValue = function (source, value) { - - if (source?.type === 'date' && - value === 'now') { - - return undefined; - } - - return internals.jsonSchemaAnnotationValue(source, value); -}; - - -internals.jsonSchemaAnnotationDateValue = function (source, value) { - - if (source?.type === 'date') { - const format = source._flags.format; - if (format === 'javascript') { - return value.getTime(); - } - - if (format === 'unix') { - return value.getTime() / 1000; - } - } - - return value.toISOString(); -}; - - -internals.isPlainObject = function (value) { - - if (!value || - typeof value !== 'object') { - - return false; - } - - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -}; - - -internals.schemaCanMatchNull = function (schema) { - - if (schema === false) { - return false; - } - - if (schema === true) { - return true; - } - - if (schema.enum) { - return schema.enum.includes(null); - } - - if (schema.type !== undefined) { - const types = Array.isArray(schema.type) ? schema.type : [schema.type]; - return types.includes('null'); - } - - if (schema.anyOf) { - return schema.anyOf.some(internals.schemaCanMatchNull); - } - - if (schema.oneOf) { - return schema.oneOf.some(internals.schemaCanMatchNull); - } - - if (schema.allOf) { - return schema.allOf.every(internals.schemaCanMatchNull); - } - - return true; -}; - - -internals.jsonSchemaType = function (schema) { - - if (internals.standardTypes.has(schema.type)) { - return schema.type; - } - - return schema._definition?.jsonSchemaType || null; -}; - - internals.Base.prototype[Common.symbols.any] = { version: Common.version, compile: Compile.compile, diff --git a/lib/json-schema/alternatives.js b/lib/json-schema/alternatives.js new file mode 100644 index 00000000..a8e0d899 --- /dev/null +++ b/lib/json-schema/alternatives.js @@ -0,0 +1,14 @@ +'use strict'; + +const Conditions = require('./conditions'); + + +exports.matches = function (schema, mode, options) { + + const matches = []; + for (const match of schema.$_terms.matches) { + matches.push(...Conditions.matchSchemas(match, mode, options)); + } + + return matches; +}; diff --git a/lib/json-schema/base.js b/lib/json-schema/base.js new file mode 100644 index 00000000..fb9eb825 --- /dev/null +++ b/lib/json-schema/base.js @@ -0,0 +1,208 @@ +'use strict'; + +const Common = require('../common'); +const Helpers = require('./common'); +const Conditions = require('./conditions'); + + +exports.convert = function (source, mode, options = {}) { + + if (options.target !== undefined && + options.target !== Helpers.target) { + + throw new Error(`Unsupported JSON Schema target: ${options.target}`); + } + + const rootCall = !options.$defs; + const defs = options.$defs ?? {}; + + const prefs = source._preferences + ? Common.preferences(options.prefs, source._preferences) + : options.prefs; + const presence = source._flags.presence || prefs?.presence; + + if (presence === 'forbidden' && !options.ignorePresence) { + return false; + } + + let schema = {}; + + const jsonSchemaType = Helpers.jsonSchemaType(source); + const isTypeAny = source.type === 'any'; + const isOnly = source._flags.only; + + const rawValids = source._valids && Array.from(source._valids._values); + const valids = rawValids && Helpers.jsonSchemaValues(source, rawValids); + const onlyValues = valids && valids.filter((value) => typeof value !== 'symbol'); + const nonNullValids = valids && valids.filter((value) => value !== null); + const rawAllowedValues = rawValids && rawValids.filter((value) => typeof value !== 'symbol' && value !== null); + let typesOverlap = true; + + if (rawValids && isOnly && !isTypeAny) { + const comparableValues = rawValids.filter((value) => typeof value !== 'symbol' && value !== null); + if (comparableValues.length) { + const types = new Set(comparableValues.map((value) => typeof value)); + const type = jsonSchemaType || source.type; + typesOverlap = types.has(type) || (type === 'date' && types.has('object')); + } + } + + if (!isTypeAny && typesOverlap && jsonSchemaType) { + schema.type = jsonSchemaType; + } + + if (source._flags.description) { + schema.description = source._flags.description; + } + + if (source._flags.default !== undefined && typeof source._flags.default !== 'function') { + const defaultValue = Helpers.jsonSchemaDefaultValue(source, source._flags.default); + if (defaultValue !== undefined) { + schema.default = defaultValue; + } + } + + const subOptions = { ...options, $defs: defs, prefs }; + + if (source._definition.jsonSchema && typesOverlap) { + schema = source._definition.jsonSchema(source, schema, mode, subOptions); + } + + for (const rule of source._rules) { + const definition = source._definition.rules[rule.name]; + if (definition.jsonSchema && typesOverlap && !rule._resolve.length) { + schema = definition.jsonSchema(rule, schema, isOnly, mode, subOptions); + } + } + + if (source.$_terms.shared) { + for (const shared of source.$_terms.shared) { + defs[shared._flags.id] = shared.$_jsonSchema(mode, subOptions); + } + } + + Helpers.applyExamples(source, schema, source.$_terms.examples); + Helpers.applyMetas(source, schema, source.$_terms.metas); + + if (rootCall && Object.keys(defs).length) { + schema.$defs = defs; + } + + if (source._valids) { + const values = isOnly ? onlyValues : nonNullValids.filter((value) => typeof value !== 'symbol'); + if (values.length) { + if (source._flags.only) { + if (!(values.length === 1 && values[0] === null) && + typeof schema !== 'boolean') { + + schema.enum = values; + + const types = Helpers.onlyTypes(values); + if (types) { + schema.type = types.length === 1 ? types[0] : types; + } + } + } + else { + const extras = []; + if (!isTypeAny) { + const base = internals.onlyBaseClone(source); + const schemaTypes = new Set(Helpers.schemaTypes(schema) || []); + schemaTypes.add(jsonSchemaType || source.type); + + for (const rawValue of rawAllowedValues) { + const representations = Helpers.jsonSchemaRepresentations(source, rawValue); + const candidates = []; + + for (const value of representations) { + const valueType = Helpers.jsonSchemaValueType(value); + if (valueType === null || + !schemaTypes.has(valueType)) { + + extras.push(value); + continue; + } + + candidates.push(value); + } + + if (candidates.length && + base.validate(rawValue, prefs).error) { + + extras.push(...candidates); + } + } + } + + const uniqueExtras = Helpers.uniqueJsonSchemaValues(extras); + if (uniqueExtras.length) { + if (!schema.anyOf) { + schema = { + anyOf: [schema] + }; + } + + schema.anyOf.push({ enum: uniqueExtras }); + } + } + } + } + + if (source._invalids) { + const invalids = Helpers.jsonSchemaValues(source, Array.from(source._invalids._values) + .filter((value) => typeof value !== 'symbol')) + .filter((value) => value !== null || Helpers.schemaCanMatchNull(schema)); + + if (invalids.length) { + schema = Helpers.appendCompositeKeyword(schema, 'not', { enum: invalids }); + } + } + + if (!isOnly && source._valids && source._valids.has(null) && !isTypeAny) { + if (schema.type) { + schema.type = Helpers.appendType(schema.type, 'null'); + } + else if (schema.anyOf) { + schema.anyOf.unshift(Helpers.nullSchema()); + } + else { + schema = { + anyOf: [ + Helpers.nullSchema(), + schema + ] + }; + } + } + + if (source.$_terms.whens) { + return { anyOf: Conditions.expandWhenSchemas(source, mode, subOptions) }; + } + + if (isOnly && + rawValids && + rawValids.some((value) => typeof value !== 'symbol') && + !onlyValues.length) { + + return false; + } + + if (isOnly && onlyValues && onlyValues.length) { + schema = Helpers.finalizeOnlySchema(source, schema, onlyValues, mode, subOptions); + } + + return schema; +}; + + +const internals = {}; + + +internals.onlyBaseClone = function (source) { + + const base = source.clone(); + base._valids = null; + base._invalids = null; + delete base._flags.only; + return base; +}; diff --git a/lib/json-schema/binary.js b/lib/json-schema/binary.js new file mode 100644 index 00000000..7412d2fc --- /dev/null +++ b/lib/json-schema/binary.js @@ -0,0 +1,25 @@ +'use strict'; + + +exports.contentEncoding = function (encoding) { + + // JSON Schema's contentEncoding annotation follows RFC 4648 / MIME transfer + // encoding names, not Node's full Buffer encoding namespace. Map only the + // binary transfer encodings Joi can express here and omit charset-style + // encodings such as utf8/latin1 that have no honest contentEncoding value. + if (!encoding) { + return undefined; + } + + if (encoding === 'hex') { + return 'base16'; + } + + if (encoding === 'base64' || + encoding === 'base64url') { + + return encoding; + } + + return undefined; +}; diff --git a/lib/json-schema/common.js b/lib/json-schema/common.js new file mode 100644 index 00000000..ea2c7027 --- /dev/null +++ b/lib/json-schema/common.js @@ -0,0 +1,451 @@ +'use strict'; + +const { deepEqual } = require('@hapi/hoek'); + + +const internals = { + standardTypes: new Set(['string', 'number', 'integer', 'boolean', 'object', 'array', 'null']), + primitiveTypes: new Set(['string', 'number', 'boolean']), + metaPassthroughKeywords: new Set([ + '$comment', + 'contentEncoding', + 'contentMediaType', + 'contentSchema', + 'deprecated', + 'examples', + 'format', + 'readOnly', + 'title', + 'writeOnly' + ]), + onlyFallbackAnnotationKeywords: new Set([ + '$comment', + '$defs', + 'contentEncoding', + 'contentMediaType', + 'contentSchema', + 'default', + 'deprecated', + 'description', + 'examples', + 'readOnly', + 'title', + 'writeOnly' + ]) +}; + + +exports.target = 'draft-2020-12'; + + +exports.nullSchema = function () { + + return { type: 'null' }; +}; + + +exports.appendCompositeKeyword = function (schema, keyword, value) { + + if (typeof schema === 'boolean') { + return schema ? { [keyword]: value } : false; + } + + if (schema.allOf) { + if (schema[keyword] !== undefined) { + schema.allOf.push({ [keyword]: schema[keyword] }); + delete schema[keyword]; + } + + schema.allOf.push({ [keyword]: value }); + return schema; + } + + if (schema[keyword] === undefined) { + schema[keyword] = value; + return schema; + } + + schema.allOf = [ + { [keyword]: schema[keyword] }, + { [keyword]: value } + ]; + + delete schema[keyword]; + + return schema; +}; + + +exports.finalizeOnlySchema = function (source, schema, values, mode, options) { + + let base = exports.onlyCanRetainBaseSchema(source, values, options.prefs) + ? internals.onlyBaseSchema(source, mode, options) + : internals.onlyFallbackAnnotations(schema); + + if (typeof base === 'boolean') { + base = {}; + } + + if (schema.$defs) { + base.$defs = schema.$defs; + } + + if (values.length === 1 && + values[0] === null) { + + base.type = 'null'; + delete base.enum; + return base; + } + + base.enum = values; + + const types = exports.onlyTypes(values); + if (types) { + base.type = types.length === 1 ? types[0] : types; + } + else { + delete base.type; + } + + return base; +}; + + +exports.appendType = function (type, addition) { + + const types = Array.isArray(type) ? type.slice() : [type]; + + if (!types.includes(addition)) { + types.push(addition); + } + + return types.length === 1 ? types[0] : types; +}; + + +exports.onlyCanRetainBaseSchema = function (source, values, prefs) { + + const base = internals.onlyBaseClone(source); + const baseSchema = base.$_jsonSchema('input', { prefs, $defs: {} }); + const baseTypes = exports.schemaTypes(baseSchema); + let checked = false; + + for (const value of values) { + if (value === null) { + continue; + } + + const valueType = exports.jsonSchemaValueType(value); + if (valueType === null) { + return false; + } + + if (baseTypes && + !baseTypes.has(valueType)) { + + continue; + } + + checked = true; + + if (base.validate(value, prefs).error) { + return false; + } + } + + return checked || !baseTypes; +}; + + +exports.onlyTypes = function (values) { + + const types = values.map(exports.jsonSchemaValueType); + if (types.includes(null)) { + return null; + } + + return [...new Set(types)]; +}; + + +exports.jsonSchemaValues = function (source, values) { + + return exports.uniqueJsonSchemaValues(values.flatMap((value) => exports.jsonSchemaRepresentations(source, value))); +}; + + +exports.jsonSchemaRepresentations = function (source, value) { + + if (source.type === 'date') { + if (!(value instanceof Date)) { + return []; + } + + return internals.jsonSchemaDateValues(source, value); + } + + if (value instanceof Date) { + return []; + } + + return [value]; +}; + + +exports.jsonSchemaValueType = function (value) { + + if (value === null) { + return 'null'; + } + + const type = typeof value; + return internals.primitiveTypes.has(type) ? type : null; +}; + + +exports.schemaTypes = function (schema) { + + if (typeof schema === 'boolean' || + schema.type === undefined) { + + return null; + } + + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + return new Set(types); +}; + + +exports.applyExamples = function (source, schema, examples) { + + if (!examples) { + return; + } + + schema.examples = internals.mergeExamples(schema.examples, exports.jsonSchemaAnnotationValue(source, examples)); +}; + + +exports.applyMetas = function (source, schema, metas = []) { + + if (!metas.length) { + return; + } + + for (const meta of metas) { + if (!meta || + typeof meta !== 'object' || + Array.isArray(meta)) { + + continue; + } + + for (const [key, value] of Object.entries(meta)) { + if (key === 'examples') { + const merged = internals.mergeExamples(schema.examples, exports.jsonSchemaAnnotationValue(source, value)); + if (merged !== undefined) { + schema.examples = merged; + } + + continue; + } + + if (!internals.metaPassthroughKeywords.has(key) || + value === undefined || + schema[key] !== undefined) { + + continue; + } + + schema[key] = exports.jsonSchemaAnnotationValue(source, value); + } + } +}; + + +exports.uniqueJsonSchemaValues = function (values) { + + return internals.mergeExamples([], values); +}; + + +exports.jsonSchemaAnnotationValue = function (source, value) { + + if (value instanceof Date) { + return internals.jsonSchemaAnnotationDateValue(source, value); + } + + if (Array.isArray(value)) { + return value.map((item) => exports.jsonSchemaAnnotationValue(source, item)); + } + + if (internals.isPlainObject(value)) { + const copy = {}; + for (const [key, item] of Object.entries(value)) { + copy[key] = exports.jsonSchemaAnnotationValue(source, item); + } + + return copy; + } + + return value; +}; + + +exports.jsonSchemaDefaultValue = function (source, value) { + + if (source?.type === 'date' && + value === 'now') { + + return undefined; + } + + return exports.jsonSchemaAnnotationValue(source, value); +}; + + +exports.schemaCanMatchNull = function (schema) { + + if (schema === false) { + return false; + } + + if (schema === true) { + return true; + } + + if (schema.enum) { + return schema.enum.includes(null); + } + + if (schema.type !== undefined) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + return types.includes('null'); + } + + if (schema.anyOf) { + return schema.anyOf.some(exports.schemaCanMatchNull); + } + + if (schema.oneOf) { + return schema.oneOf.some(exports.schemaCanMatchNull); + } + + if (schema.allOf) { + return schema.allOf.every(exports.schemaCanMatchNull); + } + + return true; +}; + + +exports.jsonSchemaType = function (schema) { + + if (internals.standardTypes.has(schema.type)) { + return schema.type; + } + + return schema._definition?.jsonSchemaType || null; +}; + + +internals.onlyBaseSchema = function (source, mode, options) { + + return internals.onlyBaseClone(source).$_jsonSchema(mode, options); +}; + + +internals.onlyBaseClone = function (source) { + + const base = source.clone(); + base._valids = null; + base._invalids = null; + delete base._flags.only; + return base; +}; + + +internals.onlyFallbackAnnotations = function (schema) { + + if (typeof schema === 'boolean') { + return {}; + } + + const annotations = {}; + for (const key of internals.onlyFallbackAnnotationKeywords) { + if (schema[key] !== undefined) { + annotations[key] = schema[key]; + } + } + + return annotations; +}; + + +internals.jsonSchemaDateValues = function (source, value) { + + const format = source._flags.format; + if (format === 'javascript') { + return [value.getTime()]; + } + + if (format === 'unix') { + return [value.getTime() / 1000]; + } + + if (format === 'iso') { + return [value.toISOString()]; + } + + return [value.toISOString(), value.getTime()]; +}; + + +internals.mergeExamples = function (existing, next) { + + if (!Array.isArray(next) || + !next.length) { + + return existing; + } + + const merged = existing ? [...existing] : []; + for (const example of next) { + if (!merged.some((item) => deepEqual(item, example))) { + merged.push(example); + } + } + + return merged; +}; + + +internals.jsonSchemaAnnotationDateValue = function (source, value) { + + if (source?.type === 'date') { + const format = source._flags.format; + if (format === 'javascript') { + return value.getTime(); + } + + if (format === 'unix') { + return value.getTime() / 1000; + } + } + + return value.toISOString(); +}; + + +internals.isPlainObject = function (value) { + + if (!value || + typeof value !== 'object') { + + return false; + } + + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; diff --git a/lib/json-schema/conditions.js b/lib/json-schema/conditions.js new file mode 100644 index 00000000..d4451033 --- /dev/null +++ b/lib/json-schema/conditions.js @@ -0,0 +1,98 @@ +'use strict'; + +const { deepEqual } = require('@hapi/hoek'); + + +exports.expandWhenSchemas = function (schema, mode, options) { + + const base = schema.clone(); + base.$_terms.whens = null; + + const matches = []; + for (const when of schema.$_terms.whens) { + const tests = exports.tests(when); + for (let i = 0; i < tests.length; ++i) { + const test = tests[i]; + if (test.then) { + matches.push(base.concat(test.then).$_jsonSchema(mode, options)); + } + + if (test.otherwise) { + matches.push(base.concat(test.otherwise).$_jsonSchema(mode, options)); + } + + if (!test.then || (i === tests.length - 1 && !test.otherwise)) { + matches.push(base.$_jsonSchema(mode, options)); + } + } + } + + return exports.uniqueSchemas(matches); +}; + + +exports.matchSchemas = function (match, mode, options) { + + if (match.schema) { + return [match.schema.$_jsonSchema(mode, options)]; + } + + const matches = []; + for (const test of exports.tests(match)) { + if (test.then) { + matches.push(test.then.$_jsonSchema(mode, options)); + } + + if (test.otherwise) { + matches.push(test.otherwise.$_jsonSchema(mode, options)); + } + } + + return matches; +}; + + +exports.tests = function (when) { + + return when.is ? [when] : when.switch; +}; + + +exports.literalValue = function (schema) { + + const isAnyType = schema.type === 'any'; + const isOnly = schema._flags.only; + const hasRules = !!schema._rules.length; + const hasInvalids = !!schema._invalids; + + if (!isAnyType || + !isOnly || + hasRules || + hasInvalids) { + + return { found: false }; + } + + const values = Array.from(schema._valids._values).filter((value) => typeof value !== 'symbol'); + if (values.length !== 1) { + return { found: false }; + } + + const value = values[0]; + return value === null || ['string', 'number', 'boolean'].includes(typeof value) + ? { found: true, value } + : { found: false }; +}; + + +exports.uniqueSchemas = function (schemas) { + + const unique = []; + for (const schema of schemas) { + if (!unique.some((candidate) => deepEqual(candidate, schema))) { + unique.push(schema); + } + } + + return unique; +}; diff --git a/lib/json-schema/object.js b/lib/json-schema/object.js new file mode 100644 index 00000000..249465c6 --- /dev/null +++ b/lib/json-schema/object.js @@ -0,0 +1,326 @@ +'use strict'; + +const Common = require('../common'); +const Conditions = require('./conditions'); +const Helpers = require('./common'); + + +exports.depJsonSchema = { + + with(dep, res) { + + internals.mergeDependentRequired(res, dep.key.key, dep.paths); + }, + + without(dep, res) { + + const prohibited = {}; + for (const peer of dep.paths) { + prohibited[peer] = false; + } + + internals.mergeDependentSchemaProperties(res, dep.key.key, prohibited); + }, + + and(dep, res) { + + for (const peer of dep.paths) { + internals.mergeDependentRequired(res, peer, dep.paths.filter((path) => path !== peer)); + } + }, + + nand(dep, res) { + + const props = {}; + for (const peer of dep.paths) { + props[peer] = true; + } + + Helpers.appendCompositeKeyword(res, 'not', { properties: props, required: dep.paths }); + }, + + or(dep, res) { + + const branches = dep.paths.map((peer) => ({ properties: { [peer]: true }, required: [peer] })); + Helpers.appendCompositeKeyword(res, 'anyOf', branches); + }, + + xor(dep, res) { + + const branches = dep.paths.map((peer) => ({ properties: { [peer]: true }, required: [peer] })); + Helpers.appendCompositeKeyword(res, 'oneOf', branches); + }, + + oxor(dep, res) { + + const branches = dep.paths.map((peer) => ({ properties: { [peer]: true }, required: [peer] })); + Helpers.appendCompositeKeyword(res, 'oneOf', [ + { not: { anyOf: branches } }, + ...branches + ]); + } +}; + + +exports.child = function (schema, mode, options, prefs) { + + const childPrefs = schema._preferences + ? Common.preferences(prefs, schema._preferences) + : prefs; + const presence = schema._flags.presence || childPrefs?.presence; + const jsonSchema = schema.$_jsonSchema(mode, { ...options, ignorePresence: true }); + + if (schema._flags.id) { + options.$defs[schema._flags.id] = jsonSchema; + } + + if (presence === 'forbidden' || + (mode === 'output' && schema._flags.result === 'strip')) { + + return { + schema: false, + required: false + }; + } + + return { + schema: jsonSchema, + required: presence === 'required' || + (mode === 'output' && schema._flags.default !== undefined && !childPrefs?.noDefaults) + }; +}; + + +exports.hoistedWhens = function (schema, key, mode, options, prefs) { + + if (!schema.$_terms.whens?.length) { + return null; + } + + const conditionals = []; + const base = schema.clone(); + base.$_terms.whens = null; + + for (const when of schema.$_terms.whens) { + const conditional = internals.hoistedWhen(base, key, when, mode, options, prefs); + if (!conditional) { + return null; + } + + conditionals.push(conditional); + } + + return { base, conditionals }; +}; + + +exports.appendConditional = function (res, conditional) { + + if (res.allOf) { + res.allOf.push(conditional); + return; + } + + if (res.if !== undefined) { + internals.promoteConditional(res, conditional); + return; + } + + Object.assign(res, conditional); +}; + + +const internals = {}; + + +internals.mergeDependentRequired = function (res, key, peers) { + + res.dependentRequired = res.dependentRequired || {}; + res.dependentRequired[key] = internals.mergeUniqueItems(res.dependentRequired[key], peers); +}; + + +internals.mergeDependentSchemaProperties = function (res, key, properties) { + + res.dependentSchemas = res.dependentSchemas || {}; + + const existing = res.dependentSchemas[key]; + if (!existing) { + res.dependentSchemas[key] = { properties }; + return; + } + + existing.properties = { + ...existing.properties, + ...properties + }; +}; + + +internals.hoistedWhen = function (base, key, when, mode, options, prefs) { + + if (!when.ref) { + return null; + } + + const path = internals.hoistableRefPath(when.ref); + if (!path) { + return null; + } + + if (when.switch) { + return internals.hoistedSwitchWhen(base, key, when, path, mode, options, prefs); + } + + const literal = Conditions.literalValue(when.is); + if (!literal.found) { + return null; + } + + const conditional = { + if: internals.hoistedWhenCondition(path, literal.value) + }; + + conditional.then = when.then && internals.hoistedWhenBranch(base.concat(when.then), key, mode, options, prefs); + conditional.else = when.otherwise && internals.hoistedWhenBranch(base.concat(when.otherwise), key, mode, options, prefs); + + if (conditional.then === undefined) { + delete conditional.then; + } + + if (conditional.else === undefined) { + delete conditional.else; + } + + return conditional; +}; + + +internals.hoistedSwitchWhen = function (base, key, when, path, mode, options, prefs) { + + let conditional; + + for (let i = when.switch.length - 1; i >= 0; --i) { + const item = when.switch[i]; + const literal = Conditions.literalValue(item.is); + if (!literal.found) { + return null; + } + + const nested = { + if: internals.hoistedWhenCondition(path, literal.value), + then: internals.hoistedWhenBranch(base.concat(item.then), key, mode, options, prefs) + }; + + const otherwise = item.otherwise && internals.hoistedWhenBranch(base.concat(item.otherwise), key, mode, options, prefs); + if (otherwise !== undefined) { + nested.else = otherwise; + } + else if (conditional !== undefined) { + nested.else = conditional; + } + + conditional = nested; + } + + return conditional; +}; + + +internals.hoistableRefPath = function (ref) { + + if (ref.type !== 'value' || + ref.ancestor !== 1 || + !ref.path.length || + ref.adjust !== null || + ref.map !== null || + ref.iterables !== null || + ref.in !== false) { + + return null; + } + + for (const segment of ref.path) { + if (typeof segment !== 'string' || + !segment.length || + /^\d+$/.test(segment)) { + + return null; + } + } + + return ref.path; +}; + + +internals.hoistedWhenCondition = function (path, value) { + + let schema = { + const: value + }; + + for (let i = path.length - 1; i >= 0; --i) { + schema = { + type: 'object', + properties: { + [path[i]]: schema + }, + required: [path[i]] + }; + } + + return schema; +}; + + +internals.hoistedWhenBranch = function (schema, key, mode, options, prefs) { + + const child = exports.child(schema, mode, options, prefs); + const branch = { + properties: { + [key]: child.schema + } + }; + + if (child.required) { + branch.required = [key]; + } + + return branch; +}; + + +internals.takeConditional = function (res) { + + const conditional = {}; + for (const key of ['if', 'then', 'else']) { + if (res[key] !== undefined) { + conditional[key] = res[key]; + delete res[key]; + } + } + + return conditional; +}; + + +internals.promoteConditional = function (res, conditional) { + + res.allOf = [ + internals.takeConditional(res), + conditional + ]; +}; + + +internals.mergeUniqueItems = function (existing = [], next = []) { + + const items = [...existing]; + for (const value of next) { + if (!items.includes(value)) { + items.push(value); + } + } + + return items; +}; diff --git a/lib/types/alternatives.js b/lib/types/alternatives.js index 09471557..43809bbe 100755 --- a/lib/types/alternatives.js +++ b/lib/types/alternatives.js @@ -6,6 +6,7 @@ const Any = require('./any'); const Common = require('../common'); const Compile = require('../compile'); const Errors = require('../errors'); +const JsonAlternatives = require('../json-schema/alternatives'); const Ref = require('../ref'); @@ -148,29 +149,7 @@ module.exports = Any.extend({ jsonSchema(schema, res, mode, options) { - const matches = []; - - // Collect all alternative schemas from 'matches' term - - for (const match of schema.$_terms.matches) { - if (match.schema) { - matches.push(match.schema.$_jsonSchema(mode, options)); - } - else { - // Handle conditional matches (when/switch) - - const tests = match.is ? [match] : match.switch; - for (const test of tests) { - if (test.then) { - matches.push(test.then.$_jsonSchema(mode, options)); - } - - if (test.otherwise) { - matches.push(test.otherwise.$_jsonSchema(mode, options)); - } - } - } - } + const matches = JsonAlternatives.matches(schema, mode, options); if (matches.length) { delete res.type; diff --git a/lib/types/binary.js b/lib/types/binary.js index 15be6c6f..0ab5da6b 100755 --- a/lib/types/binary.js +++ b/lib/types/binary.js @@ -4,6 +4,7 @@ const { assert } = require('@hapi/hoek'); const Any = require('./any'); const Common = require('../common'); +const JsonBinary = require('../json-schema/binary'); const internals = {}; @@ -37,7 +38,7 @@ module.exports = Any.extend({ res.type = 'string'; - const contentEncoding = internals.contentEncoding(schema._flags.encoding); + const contentEncoding = JsonBinary.contentEncoding(schema._flags.encoding); if (contentEncoding) { res.contentEncoding = contentEncoding; } @@ -126,27 +127,3 @@ module.exports = Any.extend({ 'binary.min': '{{#label}} must be at least {{#limit}} bytes' } }); - - -internals.contentEncoding = function (encoding) { - - // JSON Schema's contentEncoding annotation follows RFC 4648 / MIME transfer - // encoding names, not Node's full Buffer encoding namespace. Map only the - // binary transfer encodings Joi can express here and omit charset-style - // encodings such as utf8/latin1 that have no honest contentEncoding value. - if (!encoding) { - return undefined; - } - - if (encoding === 'hex') { - return 'base16'; - } - - if (encoding === 'base64' || - encoding === 'base64url') { - - return encoding; - } - - return undefined; -}; diff --git a/lib/types/keys.js b/lib/types/keys.js index c3782cb2..131984c6 100755 --- a/lib/types/keys.js +++ b/lib/types/keys.js @@ -7,6 +7,7 @@ const Any = require('./any'); const Common = require('../common'); const Compile = require('../compile'); const Errors = require('../errors'); +const JsonObject = require('../json-schema/object'); const Ref = require('../ref'); const Template = require('../template'); @@ -62,9 +63,9 @@ module.exports = Any.extend({ const required = []; for (const child of schema.$_terms.keys) { - const hoistedWhens = internals.hoistedWhens(child.schema, child.key, mode, options, prefs); + const hoistedWhens = JsonObject.hoistedWhens(child.schema, child.key, mode, options, prefs); const childSchema = hoistedWhens ? hoistedWhens.base : child.schema; - const childJsonSchema = internals.childJsonSchema(childSchema, mode, options, prefs); + const childJsonSchema = JsonObject.child(childSchema, mode, options, prefs); if (childJsonSchema.required) { required.push(child.key); @@ -112,12 +113,12 @@ module.exports = Any.extend({ if (schema.$_terms.dependencies) { for (const dep of schema.$_terms.dependencies) { - internals.depJsonSchema[dep.rel]?.(dep, res); + JsonObject.depJsonSchema[dep.rel]?.(dep, res); } } for (const conditional of hoistedConditionals) { - internals.appendConditionalSchema(res, conditional); + JsonObject.appendConditional(res, conditional); } // Handle 'additionalProperties' based on unknown keys flag and preferences @@ -697,391 +698,6 @@ module.exports = Any.extend({ } }); - -// Helpers - -internals.depJsonSchema = { - - with(dep, res) { - - internals.mergeDependentRequired(res, dep.key.key, dep.paths); - }, - - without(dep, res) { - - const prohibited = {}; - for (const peer of dep.paths) { - prohibited[peer] = false; - } - - internals.mergeDependentSchemaProperties(res, dep.key.key, prohibited); - }, - - and(dep, res) { - - for (const peer of dep.paths) { - internals.mergeDependentRequired(res, peer, dep.paths.filter((path) => path !== peer)); - } - }, - - nand(dep, res) { - - const props = {}; - for (const peer of dep.paths) { - props[peer] = true; - } - - internals.appendCompositeKeyword(res, 'not', { properties: props, required: dep.paths }); - }, - - or(dep, res) { - - const branches = dep.paths.map((peer) => ({ properties: { [peer]: true }, required: [peer] })); - internals.appendCompositeKeyword(res, 'anyOf', branches); - }, - - xor(dep, res) { - - const branches = dep.paths.map((peer) => ({ properties: { [peer]: true }, required: [peer] })); - internals.appendCompositeKeyword(res, 'oneOf', branches); - }, - - oxor(dep, res) { - - const branches = dep.paths.map((peer) => ({ properties: { [peer]: true }, required: [peer] })); - internals.appendCompositeKeyword(res, 'oneOf', [ - { not: { anyOf: branches } }, - ...branches - ]); - } -}; - - -internals.mergeDependentRequired = function (res, key, peers) { - - res.dependentRequired = res.dependentRequired || {}; - res.dependentRequired[key] = internals.mergeUniqueItems(res.dependentRequired[key], peers); -}; - - -internals.mergeDependentSchemaProperties = function (res, key, properties) { - - res.dependentSchemas = res.dependentSchemas || {}; - - const existing = res.dependentSchemas[key]; - if (!existing) { - res.dependentSchemas[key] = { properties }; - return; - } - - existing.properties = { - ...existing.properties, - ...properties - }; -}; - - -internals.childJsonSchema = function (schema, mode, options, prefs) { - - const childPrefs = schema._preferences - ? Common.preferences(prefs, schema._preferences) - : prefs; - const presence = schema._flags.presence || childPrefs?.presence; - const jsonSchema = schema.$_jsonSchema(mode, { ...options, ignorePresence: true }); - - if (schema._flags.id) { - options.$defs[schema._flags.id] = jsonSchema; - } - - if (presence === 'forbidden' || - (mode === 'output' && schema._flags.result === 'strip')) { - - return { - schema: false, - required: false - }; - } - - return { - schema: jsonSchema, - required: presence === 'required' || - (mode === 'output' && schema._flags.default !== undefined && !childPrefs?.noDefaults) - }; -}; - - -internals.hoistedWhens = function (schema, key, mode, options, prefs) { - - if (!schema.$_terms.whens?.length) { - return null; - } - - const conditionals = []; - const base = schema.clone(); - base.$_terms.whens = null; - - for (const when of schema.$_terms.whens) { - const conditional = internals.hoistedWhen(base, key, when, mode, options, prefs); - if (!conditional) { - return null; - } - - conditionals.push(conditional); - } - - return { base, conditionals }; -}; - - -internals.hoistedWhen = function (base, key, when, mode, options, prefs) { - - if (!when.ref) { - - return null; - } - - const ref = when.ref; - const path = internals.hoistableRefPath(ref); - if (!path) { - - return null; - } - - if (when.switch) { - return internals.hoistedSwitchWhen(base, key, when, path, mode, options, prefs); - } - - const literal = internals.literalWhenValue(when.is); - if (!literal.found) { - return null; - } - - const conditional = { - if: internals.hoistedWhenCondition(path, literal.value) - }; - - conditional.then = when.then && internals.hoistedWhenBranch(base.concat(when.then), key, mode, options, prefs); - conditional.else = when.otherwise && internals.hoistedWhenBranch(base.concat(when.otherwise), key, mode, options, prefs); - - if (conditional.then === undefined) { - delete conditional.then; - } - - if (conditional.else === undefined) { - delete conditional.else; - } - - return conditional; -}; - - -internals.hoistedSwitchWhen = function (base, key, when, path, mode, options, prefs) { - - let conditional; - - for (let i = when.switch.length - 1; i >= 0; --i) { - const item = when.switch[i]; - const literal = internals.literalWhenValue(item.is); - if (!literal.found) { - return null; - } - - const nested = { - if: internals.hoistedWhenCondition(path, literal.value), - then: internals.hoistedWhenBranch(base.concat(item.then), key, mode, options, prefs) - }; - - const otherwise = item.otherwise && internals.hoistedWhenBranch(base.concat(item.otherwise), key, mode, options, prefs); - if (otherwise !== undefined) { - nested.else = otherwise; - } - else if (conditional !== undefined) { - nested.else = conditional; - } - - conditional = nested; - } - - return conditional; -}; - - -internals.hoistableRefPath = function (ref) { - - if (ref.type !== 'value' || - ref.ancestor !== 1 || - !ref.path.length || - ref.adjust !== null || - ref.map !== null || - ref.iterables !== null || - ref.in !== false) { - - return null; - } - - for (const segment of ref.path) { - if (typeof segment !== 'string' || - !segment.length || - /^\d+$/.test(segment)) { - - return null; - } - } - - return ref.path; -}; - - -internals.hoistedWhenCondition = function (path, value) { - - let schema = { - const: value - }; - - for (let i = path.length - 1; i >= 0; --i) { - schema = { - type: 'object', - properties: { - [path[i]]: schema - }, - required: [path[i]] - }; - } - - return schema; -}; - - -internals.hoistedWhenBranch = function (schema, key, mode, options, prefs) { - - const child = internals.childJsonSchema(schema, mode, options, prefs); - const branch = { - properties: { - [key]: child.schema - } - }; - - if (child.required) { - branch.required = [key]; - } - - return branch; -}; - - -internals.literalWhenValue = function (schema) { - - const isAnyType = schema.type === 'any'; - const isOnly = schema._flags.only; - const hasRules = !!schema._rules.length; - const hasInvalids = !!schema._invalids; - - if (!isAnyType) { - return { found: false }; - } - - if (!isOnly) { - return { found: false }; - } - - if (hasRules) { - return { found: false }; - } - - if (hasInvalids) { - return { found: false }; - } - - const values = Array.from(schema._valids._values).filter((value) => typeof value !== 'symbol'); - if (values.length !== 1) { - return { found: false }; - } - - const value = values[0]; - return value === null || ['string', 'number', 'boolean'].includes(typeof value) ? { found: true, value } : { found: false }; -}; - - -internals.appendConditionalSchema = function (res, conditional) { - - if (res.allOf) { - res.allOf.push(conditional); - return; - } - - const hasIf = res.if !== undefined; - - if (hasIf) { - internals.promoteConditional(res, conditional); - return; - } - - Object.assign(res, conditional); -}; - - -internals.takeConditionalSchema = function (res) { - - const conditional = {}; - for (const key of ['if', 'then', 'else']) { - if (res[key] !== undefined) { - conditional[key] = res[key]; - delete res[key]; - } - } - - return conditional; -}; - - -internals.promoteConditional = function (res, conditional) { - - res.allOf = [ - internals.takeConditionalSchema(res), - conditional - ]; -}; - - -internals.appendCompositeKeyword = function (res, keyword, value) { - - if (res.allOf) { - if (res[keyword] !== undefined) { - res.allOf.push({ [keyword]: res[keyword] }); - delete res[keyword]; - } - - res.allOf.push({ [keyword]: value }); - return; - } - - if (res[keyword] === undefined) { - res[keyword] = value; - return; - } - - res.allOf = [ - { [keyword]: res[keyword] }, - { [keyword]: value } - ]; - - delete res[keyword]; -}; - - -internals.mergeUniqueItems = function (existing = [], next = []) { - - const items = [...existing]; - for (const value of next) { - if (!items.includes(value)) { - items.push(value); - } - } - - return items; -}; - - internals.clone = function (value, prefs) { // Object From d5ef17ba89eddcecf50f90abb46c45614f4085c1 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 22 Apr 2026 19:22:36 +0200 Subject: [PATCH 37/39] refactor(json-schema): extract type-specific helpers --- lib/common.js | 6 - lib/json-schema/array.js | 101 ++++++++++++ lib/json-schema/date.js | 57 +++++++ lib/json-schema/string.js | 305 ++++++++++++++++++++++++++++++++++++ lib/types/array.js | 112 +------------- lib/types/date.js | 62 +------- lib/types/keys.js | 3 + lib/types/string.js | 315 ++------------------------------------ 8 files changed, 486 insertions(+), 475 deletions(-) create mode 100644 lib/json-schema/array.js create mode 100644 lib/json-schema/date.js create mode 100644 lib/json-schema/string.js diff --git a/lib/common.js b/lib/common.js index 16061e1f..7f065320 100755 --- a/lib/common.js +++ b/lib/common.js @@ -124,12 +124,6 @@ exports.isIsoDate = function (date) { }; -exports.isoDatePattern = function () { - - return internals.isoDate.source.replace(/\\:/g, ':'); -}; - - exports.isNumber = function (value) { return typeof value === 'number' && !isNaN(value); diff --git a/lib/json-schema/array.js b/lib/json-schema/array.js new file mode 100644 index 00000000..2a872dbe --- /dev/null +++ b/lib/json-schema/array.js @@ -0,0 +1,101 @@ +'use strict'; + + +exports.emit = function (schema, res, mode, options) { + + const ordered = schema.$_terms.ordered; + + if (ordered.length) { + res.prefixItems = ordered.map((item) => item.$_jsonSchema(mode, options)); + } + + if (schema.$_terms.items.length) { + const items = internals.itemsSchema(schema, mode, options); + + if (ordered.length) { + res.unevaluatedItems = items; + internals.setOrderedMinItems(res, ordered); + } + else { + res.items = items; + } + } + else if (ordered.length) { + res.unevaluatedItems = false; + internals.setOrderedMinItems(res, ordered); + res.maxItems = ordered.length; + } + + const contains = []; + for (const rule of schema._rules) { + if (rule.name === 'has' && + !internals.hasReferences(rule.args.schema)) { + + contains.push(rule.args.schema.$_jsonSchema(mode, options)); + } + } + + if (contains.length) { + if (contains.length === 1) { + res.contains = contains[0]; + } + else { + res.allOf = contains.map((item) => ({ contains: item })); + } + } + + if (schema._flags.single && + schema.$_terms.items.length) { + + res = { + anyOf: [ + res, + internals.itemsSchema(schema, mode, options) + ] + }; + } + + return res; +}; + + +const internals = {}; + + +internals.itemsSchema = function (schema, mode, options) { + + if (schema.$_terms.items.length === 1) { + return schema.$_terms.items[0].$_jsonSchema(mode, options); + } + + return { + anyOf: schema.$_terms.items.map((item) => item.$_jsonSchema(mode, options)) + }; +}; + + +internals.setOrderedMinItems = function (res, ordered) { + + const minItems = internals.orderedMinItems(ordered); + if (minItems) { + res.minItems = minItems; + } +}; + + +internals.orderedMinItems = function (ordered) { + + for (let i = ordered.length - 1; i >= 0; --i) { + if (ordered[i]._flags.presence === 'required') { + return i + 1; + } + } + + return 0; +}; + + +internals.hasReferences = function (schema) { + + return !!schema._refs.refs.length; +}; diff --git a/lib/json-schema/date.js b/lib/json-schema/date.js new file mode 100644 index 00000000..44de86ac --- /dev/null +++ b/lib/json-schema/date.js @@ -0,0 +1,57 @@ +'use strict'; + + +const internals = { + isoDate: /^(?:[-+]\d{2})?(?:\d{4}(?!\d{2}\b))(?:(-?)(?:(?:0[1-9]|1[0-2])(?:\1(?:[12]\d|0[1-9]|3[01]))?|W(?:[0-4]\d|5[0-2])(?:-?[1-7])?|(?:00[1-9]|0[1-9]\d|[12]\d{2}|3(?:[0-5]\d|6[1-6])))(?![T]$|[T][\d]+Z$)(?:[T\s](?:(?:(?:[01]\d|2[0-3])(?:(:?)[0-5]\d)?|24\:?00)(?:[.,]\d+(?!:))?)(?:\2[0-5]\d(?:[.,]\d+)?)?(?:[Z]|(?:[+-])(?:[01]\d|2[0-3])(?::?[0-5]\d)?)?)?)?$/, + maxJs: 100e6 * 24 * 60 * 60 * 1000, // 100 million days in ms (ECMA-262 §21.4.1.1) + maxUnix: 100e6 * 24 * 60 * 60 // 100 million days in seconds +}; + + +exports.emit = function (schema, res) { + + const format = schema._flags.format; + + if (format === 'javascript') { + res.type = 'number'; + res.minimum = -internals.maxJs; + res.maximum = internals.maxJs; + return res; + } + + if (format === 'unix') { + res.type = 'number'; + res.minimum = -internals.maxUnix; + res.maximum = internals.maxUnix; + return res; + } + + if (format === 'iso') { + res.type = 'string'; + res.pattern = exports.isoPattern(); + return res; + } + + res.type = ['string', 'number']; + res.format = 'date-time'; + res.minimum = -internals.maxJs; + res.maximum = internals.maxJs; + + return res; +}; + + +exports.appendConstraint = function (res, name, date) { + + if (date instanceof Date) { + res['x-constraint'] = { ...res['x-constraint'], [name]: date.toISOString() }; + } + + return res; +}; + + +exports.isoPattern = function () { + + return internals.isoDate.source.replace(/\\:/g, ':'); +}; diff --git a/lib/json-schema/string.js b/lib/json-schema/string.js new file mode 100644 index 00000000..a3aabb26 --- /dev/null +++ b/lib/json-schema/string.js @@ -0,0 +1,305 @@ +'use strict'; + +const Url = require('url'); +const { ipRegex } = require('@hapi/address'); + + +exports.applyBase = function (schema, res) { + + const noEmpty = !schema._valids?.has('') && !schema._flags.only; + if (!noEmpty) { + return res; + } + + const min = schema.$_getRule('min'); + const length = schema.$_getRule('length'); + + if ((!min || min._resolve.length || min.args.limit > 0) && + (!length || length._resolve.length || length.args.limit > 0)) { + + res.minLength = 1; + } + + return res; +}; + + +exports.appendPattern = function (res, pattern) { + + if (res.allOf) { + const existingPattern = res.pattern; + delete res.pattern; + res.allOf.push(...[existingPattern].filter((value) => value !== undefined).map((value) => ({ pattern: value })), { pattern }); + return res; + } + + if (res.pattern === undefined) { + res.pattern = pattern; + return res; + } + + res.allOf = [ + { pattern: res.pattern }, + { pattern } + ]; + + delete res.pattern; + return res; +}; + + +exports.base64Pattern = function (options, base64Regex) { + + return base64Regex[options.paddingRequired][options.urlSafe].source; +}; + + +exports.dataUriPattern = function (options, base64Regex) { + + const mediaType = 'data:[\\w+.-]+\\/[\\w+.-]+;'; + const base64 = internals.unanchoredPattern(base64Regex[options.paddingRequired].false); + + return `^${mediaType}(?:base64,${base64}|(?!base64,).*)$`; +}; + + +exports.domainPattern = function (options, minDomainSegments = 2) { + + const min = (options.minDomainSegments || minDomainSegments) - 1; + const max = options.maxDomainSegments !== undefined ? options.maxDomainSegments - 1 : ''; + const fqdn = options.allowFullyQualified ? '\\.?' : ''; + const totalLength = '(?=.{1,256}$)'; + const labelLength = '(?=[^.]{1,63}\\.)'; + const tldLength = `(?=[^.]{1,63}${options.allowFullyQualified ? '(?:\\.?$)' : '$'})`; + const label = internals.domainSegmentPattern({ + allowUnicode: options.allowUnicode !== false, + allowUnderscore: options.allowUnderscore + }); + const tld = internals.domainTldPattern(options); + const denied = internals.domainDeniedTldLookahead(options); + + return `^${totalLength}(?:${labelLength}${label}\\.){${min},${max}}${denied}${tldLength}${tld}${fqdn}$`; +}; + + +exports.hexPattern = function (options, mode) { + + const digits = '[0-9A-Fa-f]+'; + const bytes = '(?:[0-9A-Fa-f]{2})+'; + + if (!options.byteAligned) { + if (options.prefix === true) { + return '^0[xX][0-9A-Fa-f]+$'; + } + + if (options.prefix === 'optional') { + return '^(?:0[xX])?[0-9A-Fa-f]+$'; + } + + return '^[0-9A-Fa-f]+$'; + } + + if (options.prefix === true) { + return `^0[xX]${bytes}$`; + } + + if (options.prefix === 'optional') { + if (mode === 'output') { + return `^(?:${bytes}|0[xX]${bytes})$`; + } + + return `^(?:${digits}|0[xX]${bytes})$`; + } + + if (mode === 'output') { + return `^${bytes}$`; + } + + return '^[0-9A-Fa-f]+$'; +}; + + +exports.hostnamePattern = function (minDomainSegments = 2) { + + const hostname = internals.unanchoredPattern(exports.domainPattern({ minDomainSegments: 1, tlds: false }, minDomainSegments)); + const ip = internals.unanchoredPattern(exports.ipPattern({ cidr: 'forbidden' })); + + return `^(?:${hostname}|${ip})$`; +}; + + +exports.ipPattern = function (options) { + + return ipRegex(options).regex.source.replace(/\[\\w-\\\./g, '[\\w.\\-'); +}; + + +const internals = {}; + + +internals.unanchoredPattern = function (regex) { + + const pattern = typeof regex === 'string' ? regex : regex.source; + return pattern.replace(/^\^/, '').replace(/\$$/, ''); +}; + + +internals.domainSegmentPattern = function ({ allowUnicode, allowUnderscore, tld = false }) { + + const nonAscii = allowUnicode ? '\\u0080-\\u{10FFFF}' : ''; + const start = `[${tld ? 'A-Za-z' : `A-Za-z0-9${allowUnderscore ? '_' : ''}`}${nonAscii}]`; + const body = `[A-Za-z0-9${nonAscii}-]`; + const end = `[A-Za-z0-9${nonAscii}]`; + + return `${start}(?:${body}*${end})?`; +}; + + +internals.domainTldPattern = function (options = {}) { + + const allow = internals.tldPatternValues(options.tlds && options.tlds.allow, options.allowUnicode !== false); + if (allow !== null) { + if (!allow.length) { + return '(?!)'; + } + + return `(?:${allow.join('|')})`; + } + + return internals.domainSegmentPattern({ + allowUnicode: options.allowUnicode !== false, + tld: true + }); +}; + + +internals.domainDeniedTldLookahead = function (options = {}) { + + const deny = internals.tldPatternValues(options.tlds && options.tlds.deny, options.allowUnicode !== false); + if (!deny || + !deny.length) { + + return ''; + } + + const denied = deny.join('|'); + const suffix = options.allowFullyQualified ? '\\.?$' : '$'; + return `(?!(?:${denied})${suffix})`; +}; + + +internals.tldValues = function (values) { + + if (values instanceof Set) { + return [...values]; + } + + return null; +}; + + +internals.tldPatternValues = function (values, allowUnicode) { + + const tlds = internals.tldValues(values); + if (!tlds) { + return null; + } + + const patterns = new Set(); + for (const tld of tlds) { + const canonical = internals.domainTldValue(tld); + if (!canonical) { + continue; + } + + patterns.add(internals.caseInsensitiveLiteral(canonical)); + + if (!allowUnicode) { + continue; + } + + const unicode = Url.domainToUnicode(canonical).normalize('NFC'); + if (unicode && + unicode !== canonical) { + + for (const variant of internals.unicodeTldVariants(canonical, unicode)) { + patterns.add(internals.caseInsensitiveLiteral(variant)); + } + } + } + + return [...patterns].sort(); +}; + + +internals.unicodeTldVariants = function (canonical, unicode) { + + const variants = new Set([ + unicode, + unicode.normalize('NFD'), + unicode.toLowerCase().normalize('NFC'), + unicode.toUpperCase().normalize('NFC') + ]); + + const valid = []; + for (const variant of variants) { + if (Url.domainToASCII(variant).toLowerCase() === canonical) { + valid.push(variant); + } + } + + return valid; +}; + + +internals.domainTldValue = function (value) { + + if (/[^\x00-\x7f]/.test(value)) { + return null; + } + + const lower = value.toLowerCase(); + if (value !== lower) { + return null; + } + + return lower; +}; + + +internals.caseInsensitiveLiteral = function (value) { + + const pattern = []; + for (const char of value) { + const lower = char.toLowerCase(); + const upper = char.toUpperCase(); + + if (lower.length !== 1 || + upper.length !== 1) { + + pattern.push(internals.regexLiteral(char)); + continue; + } + + if (lower === upper) { + pattern.push(internals.regexLiteral(char)); + continue; + } + + pattern.push(`[${internals.regexClassLiteral(lower)}${internals.regexClassLiteral(upper)}]`); + } + + return pattern.join(''); +}; + + +internals.regexClassLiteral = function (value) { + + return value.replace(/[\\\]\[\^-]/g, '\\$&'); +}; + + +internals.regexLiteral = function (value) { + + return value.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&'); +}; diff --git a/lib/types/array.js b/lib/types/array.js index ba559253..6dd890a8 100755 --- a/lib/types/array.js +++ b/lib/types/array.js @@ -5,6 +5,7 @@ const { assert, deepEqual, reach } = require('@hapi/hoek'); const Any = require('./any'); const Common = require('../common'); const Compile = require('../compile'); +const JsonArray = require('../json-schema/array'); const internals = {}; @@ -70,85 +71,7 @@ module.exports = Any.extend({ jsonSchema(schema, res, mode, options) { - const ordered = schema.$_terms.ordered; - - // Handle ordered items (tuple-like) using 'prefixItems' - - if (ordered.length) { - res.prefixItems = ordered.map((item) => item.$_jsonSchema(mode, options)); - } - - if (schema.$_terms.items.length) { - let items; - if (schema.$_terms.items.length === 1) { - items = schema.$_terms.items[0].$_jsonSchema(mode, options); - } - else { - items = { - anyOf: schema.$_terms.items.map((item) => item.$_jsonSchema(mode, options)) - }; - } - - // If there are ordered items, remaining items are 'unevaluatedItems' - - if (ordered.length) { - res.unevaluatedItems = items; - internals.setOrderedMinItems(res, ordered); - } - else { - res.items = items; - } - } - else if (ordered.length) { - // No additional items allowed beyond the ordered ones - - res.unevaluatedItems = false; - internals.setOrderedMinItems(res, ordered); - res.maxItems = ordered.length; - } - - // Map 'has' rules to 'contains' in JSON Schema - - const contains = []; - for (const rule of schema._rules) { - if (rule.name === 'has' && - !internals.hasReferences(rule.args.schema)) { - - contains.push(rule.args.schema.$_jsonSchema(mode, options)); - } - } - - if (contains.length) { - if (contains.length === 1) { - res.contains = contains[0]; - } - else { - res.allOf = contains.map((item) => ({ contains: item })); - } - } - - if (schema._flags.single && - schema.$_terms.items.length) { - - let items; - if (schema.$_terms.items.length === 1) { - items = schema.$_terms.items[0].$_jsonSchema(mode, options); - } - else { - items = { - anyOf: schema.$_terms.items.map((item) => item.$_jsonSchema(mode, options)) - }; - } - - res = { - anyOf: [ - res, - items - ] - }; - } - - return res; + return JsonArray.emit(schema, res, mode, options); }, rules: { @@ -770,37 +693,6 @@ module.exports = Any.extend({ }); -// Helpers - -internals.setOrderedMinItems = function (res, ordered) { - - // Ordered items are optional by default; the array only needs to reach the - // last explicitly required position. - const minItems = internals.orderedMinItems(ordered); - if (minItems) { - res.minItems = minItems; - } -}; - - -internals.orderedMinItems = function (ordered) { - - for (let i = ordered.length - 1; i >= 0; --i) { - if (ordered[i]._flags.presence === 'required') { - return i + 1; - } - } - - return 0; -}; - - -internals.hasReferences = function (schema) { - - return !!schema._refs.refs.length; -}; - - internals.fillMissedErrors = function (schema, errors, requireds, value, state, prefs) { const knownMisses = []; diff --git a/lib/types/date.js b/lib/types/date.js index eb050589..704391b0 100755 --- a/lib/types/date.js +++ b/lib/types/date.js @@ -4,13 +4,12 @@ const { assert } = require('@hapi/hoek'); const Any = require('./any'); const Common = require('../common'); +const JsonDate = require('../json-schema/date'); const Template = require('../template'); const internals = { - formats: ['iso', 'javascript', 'unix'], - maxJs: 100e6 * 24 * 60 * 60 * 1000, // 100 million days in ms (ECMA-262 §21.4.1.1) - maxUnix: 100e6 * 24 * 60 * 60 // 100 million days in seconds + formats: ['iso', 'javascript', 'unix'] }; @@ -54,34 +53,7 @@ module.exports = Any.extend({ jsonSchema(schema, res, mode, options) { - const format = schema._flags.format; - - if (format === 'javascript') { - res.type = 'number'; - res.minimum = -internals.maxJs; - res.maximum = internals.maxJs; - return res; - } - - if (format === 'unix') { - res.type = 'number'; - res.minimum = -internals.maxUnix; - res.maximum = internals.maxUnix; - return res; - } - - if (format === 'iso') { - res.type = 'string'; - res.pattern = Common.isoDatePattern(); - return res; - } - - res.type = ['string', 'number']; - res.format = 'date-time'; - res.minimum = -internals.maxJs; - res.maximum = internals.maxJs; - - return res; + return JsonDate.emit(schema, res); }, rules: { @@ -127,12 +99,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - const date = rule.args.date; - if (date instanceof Date) { - res['x-constraint'] = { ...res['x-constraint'], greater: date.toISOString() }; - } - - return res; + return JsonDate.appendConstraint(res, 'greater', rule.args.date); } }, @@ -150,12 +117,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - const date = rule.args.date; - if (date instanceof Date) { - res['x-constraint'] = { ...res['x-constraint'], less: date.toISOString() }; - } - - return res; + return JsonDate.appendConstraint(res, 'less', rule.args.date); } }, @@ -166,12 +128,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - const date = rule.args.date; - if (date instanceof Date) { - res['x-constraint'] = { ...res['x-constraint'], max: date.toISOString() }; - } - - return res; + return JsonDate.appendConstraint(res, 'max', rule.args.date); } }, @@ -182,12 +139,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - const date = rule.args.date; - if (date instanceof Date) { - res['x-constraint'] = { ...res['x-constraint'], min: date.toISOString() }; - } - - return res; + return JsonDate.appendConstraint(res, 'min', rule.args.date); } }, diff --git a/lib/types/keys.js b/lib/types/keys.js index 131984c6..d686b00b 100755 --- a/lib/types/keys.js +++ b/lib/types/keys.js @@ -698,6 +698,9 @@ module.exports = Any.extend({ } }); + +// Helpers + internals.clone = function (value, prefs) { // Object diff --git a/lib/types/string.js b/lib/types/string.js index 91a93cd9..7a1753ab 100755 --- a/lib/types/string.js +++ b/lib/types/string.js @@ -1,12 +1,12 @@ 'use strict'; -const Url = require('url'); const { assert, escapeRegex } = require('@hapi/hoek'); const { isDomainValid, isEmailValid, ipRegex, uriRegex } = require('@hapi/address'); const Tlds = require('@hapi/tlds'); const Any = require('./any'); const Common = require('../common'); +const JsonString = require('../json-schema/string'); const internals = { @@ -148,19 +148,7 @@ module.exports = Any.extend({ jsonSchema(schema, res, mode, options) { - const noEmpty = !schema._valids?.has('') && !schema._flags.only; - if (noEmpty) { - const min = schema.$_getRule('min'); - const length = schema.$_getRule('length'); - - if ((!min || min._resolve.length || min.args.limit > 0) && - (!length || length._resolve.length || length.args.limit > 0)) { - - res.minLength = 1; - } - } - - return res; + return JsonString.applyBase(schema, res); }, rules: { @@ -180,7 +168,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - return internals.appendPattern(res, '^[a-zA-Z0-9]+$'); + return JsonString.appendPattern(res, '^[a-zA-Z0-9]+$'); } }, @@ -206,7 +194,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - return internals.appendPattern(res, internals.base64JsonSchemaPattern(rule.args.options)); + return JsonString.appendPattern(res, JsonString.base64Pattern(rule.args.options, internals.base64Regex)); } }, @@ -290,7 +278,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - return internals.appendPattern(res, internals.dataUriJsonSchemaPattern(rule.args.options)); + return JsonString.appendPattern(res, JsonString.dataUriPattern(rule.args.options, internals.base64Regex)); } }, @@ -317,7 +305,7 @@ module.exports = Any.extend({ // Not using format: 'hostname' because it accepts single-label names (e.g. 'localhost') // while Joi's domain() requires at least 2 segments by default - return internals.appendPattern(res, internals.domainJsonSchemaPattern(rule.address)); + return JsonString.appendPattern(res, JsonString.domainPattern(rule.address, internals.minDomainSegments)); } }, @@ -481,7 +469,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res, isOnly, mode) { - return internals.appendPattern(res, internals.hexJsonSchemaPattern(rule.args.options, mode)); + return JsonString.appendPattern(res, JsonString.hexPattern(rule.args.options, mode)); } }, @@ -502,7 +490,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - return internals.appendPattern(res, internals.hostnameJsonSchemaPattern()); + return JsonString.appendPattern(res, JsonString.hostnamePattern(internals.minDomainSegments)); } }, @@ -536,7 +524,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - return internals.appendPattern(res, internals.ipJsonSchemaPattern(rule.args.options)); + return JsonString.appendPattern(res, JsonString.ipPattern(rule.args.options)); } }, @@ -694,7 +682,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - return internals.appendPattern(res, rule.args.regex.source); + return JsonString.appendPattern(res, rule.args.regex.source); }, args: ['regex', 'options'], multi: true @@ -736,7 +724,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - return internals.appendPattern(res, '^[A-Za-z0-9_]+$'); + return JsonString.appendPattern(res, '^[A-Za-z0-9_]+$'); } }, @@ -958,287 +946,6 @@ internals.validateTlds = function (set, source) { }; -internals.base64JsonSchemaPattern = function (options) { - - return internals.base64Regex[options.paddingRequired][options.urlSafe].source; -}; - - -internals.appendPattern = function (res, pattern) { - - if (res.allOf) { - const existingPattern = res.pattern; - delete res.pattern; - res.allOf.push(...[existingPattern].filter((value) => value !== undefined).map((value) => ({ pattern: value })), { pattern }); - return res; - } - - if (res.pattern === undefined) { - res.pattern = pattern; - return res; - } - - res.allOf = [ - { pattern: res.pattern }, - { pattern } - ]; - - delete res.pattern; - return res; -}; - - -internals.hostnameJsonSchemaPattern = function () { - - const hostname = internals.unanchoredPattern(internals.domainJsonSchemaPattern({ minDomainSegments: 1, tlds: false })); - const ip = internals.unanchoredPattern(internals.ipJsonSchemaPattern({ cidr: 'forbidden' })); - - return `^(?:${hostname}|${ip})$`; -}; - - -internals.ipJsonSchemaPattern = function (options) { - - return ipRegex(options).regex.source.replace(/\[\\w-\\\./g, '[\\w.\\-'); -}; - - -internals.dataUriJsonSchemaPattern = function (options) { - - const mediaType = 'data:[\\w+.-]+\\/[\\w+.-]+;'; - const base64 = internals.unanchoredPattern(internals.base64Regex[options.paddingRequired].false); - - return `^${mediaType}(?:base64,${base64}|(?!base64,).*)$`; -}; - - -internals.hexJsonSchemaPattern = function (options, mode) { - - const digits = '[0-9A-Fa-f]+'; - const bytes = '(?:[0-9A-Fa-f]{2})+'; - - if (!options.byteAligned) { - if (options.prefix === true) { - return '^0[xX][0-9A-Fa-f]+$'; - } - - if (options.prefix === 'optional') { - return '^(?:0[xX])?[0-9A-Fa-f]+$'; - } - - return '^[0-9A-Fa-f]+$'; - } - - if (options.prefix === true) { - return `^0[xX]${bytes}$`; - } - - if (options.prefix === 'optional') { - if (mode === 'output') { - return `^(?:${bytes}|0[xX]${bytes})$`; - } - - return `^(?:${digits}|0[xX]${bytes})$`; - } - - if (mode === 'output') { - return `^${bytes}$`; - } - - return '^[0-9A-Fa-f]+$'; -}; - - -internals.unanchoredPattern = function (regex) { - - const pattern = typeof regex === 'string' ? regex : regex.source; - return pattern.replace(/^\^/, '').replace(/\$$/, ''); -}; - - -internals.domainJsonSchemaPattern = function (options) { - - const settings = internals.addressOptions(options); - - const min = (settings.minDomainSegments || internals.minDomainSegments) - 1; - const max = settings.maxDomainSegments !== undefined ? settings.maxDomainSegments - 1 : ''; - const fqdn = settings.allowFullyQualified ? '\\.?' : ''; - const totalLength = '(?=.{1,256}$)'; - const labelLength = '(?=[^.]{1,63}\\.)'; - const tldLength = `(?=[^.]{1,63}${settings.allowFullyQualified ? '(?:\\.?$)' : '$'})`; - const label = internals.domainSegmentPattern({ - allowUnicode: settings.allowUnicode !== false, - allowUnderscore: settings.allowUnderscore - }); - const tld = internals.domainTldPattern(settings); - const denied = internals.domainDeniedTldLookahead(settings); - - return `^${totalLength}(?:${labelLength}${label}\\.){${min},${max}}${denied}${tldLength}${tld}${fqdn}$`; -}; - - -internals.domainSegmentPattern = function ({ allowUnicode, allowUnderscore, tld = false }) { - - const nonAscii = allowUnicode ? '\\u0080-\\u{10FFFF}' : ''; - const start = `[${tld ? 'A-Za-z' : `A-Za-z0-9${allowUnderscore ? '_' : ''}`}${nonAscii}]`; - const body = `[A-Za-z0-9${nonAscii}-]`; - const end = `[A-Za-z0-9${nonAscii}]`; - - return `${start}(?:${body}*${end})?`; -}; - - -internals.domainTldPattern = function (options = {}) { - - const allow = internals.tldPatternValues(options.tlds && options.tlds.allow, options.allowUnicode !== false); - if (allow !== null) { - if (!allow.length) { - return '(?!)'; - } - - return `(?:${allow.join('|')})`; - } - - return internals.domainSegmentPattern({ - allowUnicode: options.allowUnicode !== false, - tld: true - }); -}; - - -internals.domainDeniedTldLookahead = function (options = {}) { - - const deny = internals.tldPatternValues(options.tlds && options.tlds.deny, options.allowUnicode !== false); - if (!deny || - !deny.length) { - - return ''; - } - - const denied = deny.join('|'); - const suffix = options.allowFullyQualified ? '\\.?$' : '$'; - return `(?!(?:${denied})${suffix})`; -}; - -internals.tldValues = function (values) { - - if (values instanceof Set) { - return [...values]; - } - - return null; -}; - - -internals.tldPatternValues = function (values, allowUnicode) { - - const tlds = internals.tldValues(values); - if (!tlds) { - return null; - } - - const patterns = new Set(); - for (const tld of tlds) { - const canonical = internals.domainTldValue(tld); - if (!canonical) { - continue; - } - - patterns.add(internals.caseInsensitiveLiteral(canonical)); - - if (!allowUnicode) { - continue; - } - - const unicode = Url.domainToUnicode(canonical).normalize('NFC'); - if (unicode && - unicode !== canonical) { - - for (const variant of internals.unicodeTldVariants(canonical, unicode)) { - patterns.add(internals.caseInsensitiveLiteral(variant)); - } - } - } - - return [...patterns].sort(); -}; - - -internals.unicodeTldVariants = function (canonical, unicode) { - - const variants = new Set([ - unicode, - unicode.normalize('NFD'), - unicode.toLowerCase().normalize('NFC'), - unicode.toUpperCase().normalize('NFC') - ]); - - const valid = []; - for (const variant of variants) { - if (Url.domainToASCII(variant).toLowerCase() === canonical) { - valid.push(variant); - } - } - - return valid; -}; - - -internals.domainTldValue = function (value) { - - if (/[^\x00-\x7f]/.test(value)) { - - return null; - } - - const lower = value.toLowerCase(); - if (value !== lower) { - return null; - } - - return lower; -}; - - -internals.caseInsensitiveLiteral = function (value) { - - const pattern = []; - for (const char of value) { - const lower = char.toLowerCase(); - const upper = char.toUpperCase(); - - if (lower.length !== 1 || - upper.length !== 1) { - - pattern.push(internals.regexLiteral(char)); - continue; - } - - if (lower === upper) { - - pattern.push(internals.regexLiteral(char)); - continue; - } - - pattern.push(`[${internals.regexClassLiteral(lower)}${internals.regexClassLiteral(upper)}]`); - } - - return pattern.join(''); -}; - - -internals.regexClassLiteral = function (value) { - - return value.replace(/[\\\]\[\^-]/g, '\\$&'); -}; - - -internals.regexLiteral = function (value) { - - return value.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&'); -}; - - internals.isoDate = function (value) { if (!Common.isIsoDate(value)) { From f4b06aa04f8c5117684340aedcde9f13b5b87efa Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 22 Apr 2026 19:37:01 +0200 Subject: [PATCH 38/39] refactor(json-schema): restore extracted comments --- lib/json-schema/alternatives.js | 3 +++ lib/json-schema/array.js | 10 ++++++++++ lib/json-schema/base.js | 20 ++++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/lib/json-schema/alternatives.js b/lib/json-schema/alternatives.js index a8e0d899..b569ac6d 100644 --- a/lib/json-schema/alternatives.js +++ b/lib/json-schema/alternatives.js @@ -6,6 +6,9 @@ const Conditions = require('./conditions'); exports.matches = function (schema, mode, options) { const matches = []; + + // Collect all alternative schemas from 'matches' term + for (const match of schema.$_terms.matches) { matches.push(...Conditions.matchSchemas(match, mode, options)); } diff --git a/lib/json-schema/array.js b/lib/json-schema/array.js index 2a872dbe..f2115d01 100644 --- a/lib/json-schema/array.js +++ b/lib/json-schema/array.js @@ -5,6 +5,8 @@ exports.emit = function (schema, res, mode, options) { const ordered = schema.$_terms.ordered; + // Handle ordered items (tuple-like) using 'prefixItems' + if (ordered.length) { res.prefixItems = ordered.map((item) => item.$_jsonSchema(mode, options)); } @@ -12,6 +14,8 @@ exports.emit = function (schema, res, mode, options) { if (schema.$_terms.items.length) { const items = internals.itemsSchema(schema, mode, options); + // If there are ordered items, remaining items are 'unevaluatedItems' + if (ordered.length) { res.unevaluatedItems = items; internals.setOrderedMinItems(res, ordered); @@ -21,11 +25,15 @@ exports.emit = function (schema, res, mode, options) { } } else if (ordered.length) { + // No additional items allowed beyond the ordered ones + res.unevaluatedItems = false; internals.setOrderedMinItems(res, ordered); res.maxItems = ordered.length; } + // Map 'has' rules to 'contains' in JSON Schema + const contains = []; for (const rule of schema._rules) { if (rule.name === 'has' && @@ -76,6 +84,8 @@ internals.itemsSchema = function (schema, mode, options) { internals.setOrderedMinItems = function (res, ordered) { + // Ordered items are optional by default; the array only needs to reach the + // last explicitly required position. const minItems = internals.orderedMinItems(ordered); if (minItems) { res.minItems = minItems; diff --git a/lib/json-schema/base.js b/lib/json-schema/base.js index fb9eb825..f6a22cf0 100644 --- a/lib/json-schema/base.js +++ b/lib/json-schema/base.js @@ -38,6 +38,8 @@ exports.convert = function (source, mode, options = {}) { const rawAllowedValues = rawValids && rawValids.filter((value) => typeof value !== 'symbol' && value !== null); let typesOverlap = true; + // If 'only' is set, check if the allowed values' types overlap with the schema type + if (rawValids && isOnly && !isTypeAny) { const comparableValues = rawValids.filter((value) => typeof value !== 'symbol' && value !== null); if (comparableValues.length) { @@ -47,6 +49,8 @@ exports.convert = function (source, mode, options = {}) { } } + // Set the JSON Schema 'type' if it's a standard type and there's an overlap + if (!isTypeAny && typesOverlap && jsonSchemaType) { schema.type = jsonSchemaType; } @@ -64,10 +68,14 @@ exports.convert = function (source, mode, options = {}) { const subOptions = { ...options, $defs: defs, prefs }; + // Apply type-specific JSON Schema conversion + if (source._definition.jsonSchema && typesOverlap) { schema = source._definition.jsonSchema(source, schema, mode, subOptions); } + // Apply rule-specific JSON Schema conversions + for (const rule of source._rules) { const definition = source._definition.rules[rule.name]; if (definition.jsonSchema && typesOverlap && !rule._resolve.length) { @@ -75,6 +83,8 @@ exports.convert = function (source, mode, options = {}) { } } + // Handle shared schemas + if (source.$_terms.shared) { for (const shared of source.$_terms.shared) { defs[shared._flags.id] = shared.$_jsonSchema(mode, subOptions); @@ -88,6 +98,8 @@ exports.convert = function (source, mode, options = {}) { schema.$defs = defs; } + // Handle allowed values (valids) + if (source._valids) { const values = isOnly ? onlyValues : nonNullValids.filter((value) => typeof value !== 'symbol'); if (values.length) { @@ -126,6 +138,8 @@ exports.convert = function (source, mode, options = {}) { candidates.push(value); } + // If values are allowed but not exclusive, add them via + // 'anyOf' when the base schema would otherwise reject them. if (candidates.length && base.validate(rawValue, prefs).error) { @@ -148,6 +162,8 @@ exports.convert = function (source, mode, options = {}) { } } + // Handle disallowed values (invalids) + if (source._invalids) { const invalids = Helpers.jsonSchemaValues(source, Array.from(source._invalids._values) .filter((value) => typeof value !== 'symbol')) @@ -158,6 +174,8 @@ exports.convert = function (source, mode, options = {}) { } } + // Handle 'null' if it's an allowed value + if (!isOnly && source._valids && source._valids.has(null) && !isTypeAny) { if (schema.type) { schema.type = Helpers.appendType(schema.type, 'null'); @@ -175,6 +193,8 @@ exports.convert = function (source, mode, options = {}) { } } + // Handle conditionals (whens) by generating multiple possible schemas + // combined with 'anyOf' if (source.$_terms.whens) { return { anyOf: Conditions.expandWhenSchemas(source, mode, subOptions) }; } From 077f1baa1312e3404d9ab1aa90e18df176c0bfae Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 22 Apr 2026 19:39:57 +0200 Subject: [PATCH 39/39] refactor(json-schema): preserve moved comments --- lib/json-schema/conditions.js | 2 ++ lib/types/array.js | 2 ++ 2 files changed, 4 insertions(+) diff --git a/lib/json-schema/conditions.js b/lib/json-schema/conditions.js index d4451033..0f53bea8 100644 --- a/lib/json-schema/conditions.js +++ b/lib/json-schema/conditions.js @@ -37,6 +37,8 @@ exports.matchSchemas = function (match, mode, options) { return [match.schema.$_jsonSchema(mode, options)]; } + // Handle conditional matches (when/switch) + const matches = []; for (const test of exports.tests(match)) { if (test.then) { diff --git a/lib/types/array.js b/lib/types/array.js index 6dd890a8..4f1aa374 100755 --- a/lib/types/array.js +++ b/lib/types/array.js @@ -693,6 +693,8 @@ module.exports = Any.extend({ }); +// Helpers + internals.fillMissedErrors = function (schema, errors, requireds, value, state, prefs) { const knownMisses = [];