diff --git a/lib/base.js b/lib/base.js index dd822b65..9c911332 100644 --- a/lib/base.js +++ b/lib/base.js @@ -10,18 +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']), - nullSchema: () => ({ type: 'null' }) -}; +const internals = {}; internals.Base = class { @@ -69,164 +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 ?? {}; - - let schema = {}; - - const isTypeAny = this.type === 'any'; - const isOnly = this._flags.only; - - const valids = this._valids && Array.from(this._valids._values).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)); - typesOverlap = types.has(this.type) || (this.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 (this._flags.description) { - schema.description = this._flags.description; - } - - if (this._flags.default !== undefined && typeof this._flags.default !== 'function') { - schema.default = this._flags.default; - } - - // Apply type-specific JSON Schema conversion - - const subOptions = { ...options, $defs: defs }; - 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) { - 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); - } - } - - if (rootCall && Object.keys(defs).length) { - schema.$defs = defs; - } - - // Handle allowed values (valids) - - if (this._valids) { - - const values = valids.filter((v) => typeof v !== 'symbol'); - if (values.length) { - if (this._flags.only) { - schema.enum = values; - - const list = Common.intersect(new Set(values.map((v) => typeof v)), internals.primitiveTypes); - - if (list.size) { - const types = [...list]; - schema.type = types.length === 1 ? types[0] : types; - } - } - else { - // If values are allowed but not exclusive, add them via 'anyOf' if they differ from the main type - - const otherTypes = values.filter((v) => typeof v !== this.type || isTypeAny); - if (otherTypes.length && !(isTypeAny && !isOnly)) { - if (!schema.anyOf) { - schema = { - anyOf: [schema] - }; - } - - schema.anyOf.push({ enum: otherTypes }); - } - } - } - } - - // 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']; - } - 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 }; - } - - return schema; + return JsonSchema.convert(this, mode, options); } // Rules diff --git a/lib/extend.js b/lib/extend.js index b0d42229..ebf3362a 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,16 @@ internals.build = function (child, parent) { }; +internals.standardType = function (schema) { + + if (internals.standardTypes.has(schema.type)) { + return schema.type; + } + + return schema._definition?.jsonSchemaType || null; +}; + + internals.coerce = function (child, parent) { if (!child || diff --git a/lib/json-schema/alternatives.js b/lib/json-schema/alternatives.js new file mode 100644 index 00000000..b569ac6d --- /dev/null +++ b/lib/json-schema/alternatives.js @@ -0,0 +1,17 @@ +'use strict'; + +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)); + } + + return matches; +}; diff --git a/lib/json-schema/array.js b/lib/json-schema/array.js new file mode 100644 index 00000000..f2115d01 --- /dev/null +++ b/lib/json-schema/array.js @@ -0,0 +1,111 @@ +'use strict'; + + +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)); + } + + 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); + } + 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) { + + 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) { + + // 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; +}; diff --git a/lib/json-schema/base.js b/lib/json-schema/base.js new file mode 100644 index 00000000..f6a22cf0 --- /dev/null +++ b/lib/json-schema/base.js @@ -0,0 +1,228 @@ +'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 '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) { + const types = new Set(comparableValues.map((value) => typeof value)); + const type = jsonSchemaType || source.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 (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 }; + + // 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) { + schema = definition.jsonSchema(rule, schema, isOnly, mode, subOptions); + } + } + + // Handle shared schemas + + 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; + } + + // Handle allowed values (valids) + + 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 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) { + + extras.push(...candidates); + } + } + } + + const uniqueExtras = Helpers.uniqueJsonSchemaValues(extras); + if (uniqueExtras.length) { + if (!schema.anyOf) { + schema = { + anyOf: [schema] + }; + } + + schema.anyOf.push({ enum: uniqueExtras }); + } + } + } + } + + // Handle disallowed values (invalids) + + 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 }); + } + } + + // 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'); + } + else if (schema.anyOf) { + schema.anyOf.unshift(Helpers.nullSchema()); + } + else { + schema = { + anyOf: [ + Helpers.nullSchema(), + schema + ] + }; + } + } + + // Handle conditionals (whens) by generating multiple possible schemas + // combined with 'anyOf' + 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..0f53bea8 --- /dev/null +++ b/lib/json-schema/conditions.js @@ -0,0 +1,100 @@ +'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)]; + } + + // Handle conditional matches (when/switch) + + 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/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/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/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/alternatives.js b/lib/types/alternatives.js index 84f09f99..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; @@ -181,6 +160,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/array.js b/lib/types/array.js index 1d8c81b4..4f1aa374 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,83 +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; - res.minItems = ordered.length; - } - else { - res.items = items; - } - } - else if (ordered.length) { - // No additional items allowed beyond the ordered ones - - res.unevaluatedItems = false; - res.minItems = ordered.length; - res.maxItems = ordered.length; - } - - // Map 'has' rules to 'contains' in JSON Schema - - const contains = []; - for (const rule of schema._rules) { - if (rule.name === 'has') { - 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: { @@ -660,7 +585,9 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - res.uniqueItems = true; + if (!rule.args.comparator) { + res.uniqueItems = true; + } return res; }, diff --git a/lib/types/binary.js b/lib/types/binary.js index dbf1fd03..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 = {}; @@ -36,7 +37,11 @@ module.exports = Any.extend({ jsonSchema(schema, res, mode, options) { res.type = 'string'; - res.format = 'binary'; + + const contentEncoding = JsonBinary.contentEncoding(schema._flags.encoding); + if (contentEncoding) { + res.contentEncoding = contentEncoding; + } return res; }, diff --git a/lib/types/date.js b/lib/types/date.js index 306fa999..704391b0 100755 --- a/lib/types/date.js +++ b/lib/types/date.js @@ -4,6 +4,7 @@ const { assert } = require('@hapi/hoek'); const Any = require('./any'); const Common = require('../common'); +const JsonDate = require('../json-schema/date'); const Template = require('../template'); @@ -52,10 +53,7 @@ module.exports = Any.extend({ jsonSchema(schema, res, mode, options) { - res.type = 'string'; - res.format = 'date-time'; - - return res; + return JsonDate.emit(schema, res); }, rules: { @@ -101,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); } }, @@ -124,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); } }, @@ -140,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); } }, @@ -156,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 af281f03..d686b00b 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'); @@ -49,6 +50,9 @@ module.exports = Any.extend({ jsonSchema(schema, res, mode, options) { + const prefs = options.prefs || {}; + const hoistedConditionals = []; + res.type = 'object'; // Map Joi keys to JSON Schema 'properties' and 'required' @@ -59,13 +63,19 @@ module.exports = Any.extend({ const required = []; for (const child of schema.$_terms.keys) { - 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)) { + const hoistedWhens = JsonObject.hoistedWhens(child.schema, child.key, mode, options, prefs); + const childSchema = hoistedWhens ? hoistedWhens.base : child.schema; + const childJsonSchema = JsonObject.child(childSchema, mode, options, prefs); + if (childJsonSchema.required) { required.push(child.key); } + + res.properties[child.key] = childJsonSchema.schema; + + if (hoistedWhens) { + hoistedConditionals.push(...hoistedWhens.conditionals); + } } if (required.length) { @@ -99,13 +109,43 @@ module.exports = Any.extend({ } } - // Handle 'additionalProperties' based on unknown keys flag + // Handle dependencies + + if (schema.$_terms.dependencies) { + for (const dep of schema.$_terms.dependencies) { + JsonObject.depJsonSchema[dep.rel]?.(dep, res); + } + } + + for (const conditional of hoistedConditionals) { + JsonObject.appendConditional(res, conditional); + } + + // 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) { + const stripUnknownObjects = prefs.stripUnknown === true || !!prefs.stripUnknown.objects; + if (mode === 'output' && + stripUnknownObjects) { + + res.additionalProperties = false; + } + } + else if (!prefs.allowUnknown && + (schema.$_terms.keys || schema.$_terms.patterns || schema._flags.only)) { + + res.additionalProperties = false; + } + } } return res; 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..7a1753ab 100755 --- a/lib/types/string.js +++ b/lib/types/string.js @@ -6,9 +6,11 @@ const Tlds = require('@hapi/tlds'); const Any = require('./any'); const Common = require('../common'); +const JsonString = require('../json-schema/string'); 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 @@ -146,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.args.limit > 0) && - (!length || length.args.limit > 0)) { - - res.minLength = 1; - } - } - - return res; + return JsonString.applyBase(schema, res); }, rules: { @@ -175,6 +165,10 @@ module.exports = Any.extend({ } return helpers.error('string.alphanum'); + }, + jsonSchema(rule, res) { + + return JsonString.appendPattern(res, '^[a-zA-Z0-9]+$'); } }, @@ -200,8 +194,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - res.format = 'base64'; - return res; + return JsonString.appendPattern(res, JsonString.base64Pattern(rule.args.options, internals.base64Regex)); } }, @@ -285,8 +278,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - res.format = 'data-uri'; - return res; + return JsonString.appendPattern(res, JsonString.dataUriPattern(rule.args.options, internals.base64Regex)); } }, @@ -307,6 +299,13 @@ 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 + + return JsonString.appendPattern(res, JsonString.domainPattern(rule.address, internals.minDomainSegments)); } }, @@ -468,10 +467,9 @@ module.exports = Any.extend({ return value; }, - jsonSchema(rule, res) { + jsonSchema(rule, res, isOnly, mode) { - res.format = 'hex'; - return res; + return JsonString.appendPattern(res, JsonString.hexPattern(rule.args.options, mode)); } }, @@ -492,8 +490,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - res.format = 'hostname'; - return res; + return JsonString.appendPattern(res, JsonString.hostnamePattern(internals.minDomainSegments)); } }, @@ -527,15 +524,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 JsonString.appendPattern(res, JsonString.ipPattern(rule.args.options)); } }, @@ -693,8 +682,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - res.pattern = rule.args.regex.source; - return res; + return JsonString.appendPattern(res, rule.args.regex.source); }, args: ['regex', 'options'], multi: true @@ -736,8 +724,7 @@ module.exports = Any.extend({ }, jsonSchema(rule, res) { - res.format = 'token'; - return res; + return JsonString.appendPattern(res, '^[A-Za-z0-9_]+$'); } }, @@ -891,7 +878,7 @@ module.exports = Any.extend({ internals.addressOptions = function (options) { if (!options) { - return internals.tlds || options; // $lab:coverage:ignore$ + return { ...internals.tlds }; } // minDomainSegments 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 a95acdd2..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 = {}; @@ -9,28 +10,15 @@ const internals = {}; const { expect } = Code; -internals.ajvValidator = new Ajv({ +internals.ajvOptions = { strict: true, 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 + banana: true }, keywords: ['x-constraint', 'foo'], strictTuples: true -}); +}; exports.skip = Symbol('skip'); @@ -132,18 +120,43 @@ 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); + err.at = internals.thrownAt(); // Adjust error location to test + throw err; + } +}; + + +exports.validateJsonSchemaValues = function (schema, tests, ajvOptionsOverride) { + + try { + const validator = internals.ajv(ajvOptionsOverride); + const validate = validator.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); @@ -152,6 +165,18 @@ exports.validateJsonSchema = function (schema, expectedInput, expectedOutput) { } }; + +internals.ajv = function (options) { + + 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(); diff --git a/test/json-schema.js b/test/json-schema.js index b09db475..8bbc4e25 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'); @@ -128,8 +129,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 +149,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'] } ] }); }); @@ -201,6 +202,209 @@ describe('jsonSchema', () => { }); }); + it('represents examples', () => { + + Helper.validateJsonSchema(Joi.string().example('a').example('b'), { + type: 'string', + minLength: 1, + examples: ['a', 'b'] + }); + }); + + 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({ + 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('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({ + format: 'banana', + examples: ['b@example.com'] + }), { + type: 'string', + minLength: 1, + format: 'email', + examples: ['a@example.com', 'b@example.com'] + }); + }); + + 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'), { @@ -268,7 +472,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', () => { @@ -332,6 +536,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' }); @@ -354,7 +628,153 @@ 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], + [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', 'number'], + enum: [value.toISOString(), value.getTime()] + }); + 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', () => { @@ -364,7 +784,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', () => { @@ -382,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', () => { + + 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.validateJsonSchema(Joi.date().min('2020-01-01').valid(1), { type: 'number', enum: [1] }); + 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', () => { @@ -414,7 +912,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', () => { @@ -424,7 +922,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)', () => { @@ -434,12 +932,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', () => { @@ -449,7 +947,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', () => { @@ -479,86 +977,280 @@ 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); }); - }); - describe('alternatives', () => { + it('preserves annotations and shared defs when exclusive valids fall back to enum-only output', () => { - it('represents alternatives as anyOf', () => { + const schema = Joi.any() + .shared(Joi.string().id('shared')) + .description('annotated') + .valid({ a: 1 }); - Helper.validateJsonSchema(Joi.alternatives().try(Joi.string()), { anyOf: [{ minLength: 1, type: 'string' }] }); + Helper.validateJsonSchema(schema, { + $defs: { + shared: { type: 'string', minLength: 1 } + }, + description: 'annotated', + enum: [{ a: 1 }] + }); }); - it('represents multiple alternatives as anyOf', () => { + it('represents invalid values as not enum', () => { - Helper.validateJsonSchema(Joi.alternatives().try(Joi.string(), Joi.number()), { anyOf: [{ type: 'string', minLength: 1 }, { type: 'number' }] }); - }); + Helper.validateJsonSchema(Joi.string().invalid('foo', 'bar'), { + type: 'string', + minLength: 1, + not: { enum: ['foo', 'bar'] } + }); - it('represents alternatives with null as anyOf', () => { + Helper.validateJsonSchema(Joi.number().invalid(0), { + type: 'number', + not: { enum: [0] } + }); - Helper.validateJsonSchema(Joi.alternatives().try(Joi.string().allow(null), Joi.number()), { - anyOf: [ - { type: ['string', 'null'], minLength: 1 }, - { type: 'number' } - ] + Helper.validateJsonSchema(Joi.number().invalid(null), { + type: 'number' }); }); - it('represents alternatives with Joi.any() as anyOf', () => { + it('represents invalid(null) for unconstrained schemas', () => { - Helper.validateJsonSchema(Joi.alternatives().try(Joi.any()), { anyOf: [{}] }); - }); + const schema = Joi.any().invalid(null); + const tests = [ + [null, false, '"value" contains an invalid value'], + [1, true], + ['x', true], + [{ a: true }, true] + ]; - it('represents alternatives with mixed types as anyOf', () => { + Helper.validate(schema, tests); - Helper.validateJsonSchema(Joi.alternatives().try(Joi.string(), Joi.valid(1, true)), { - anyOf: [ - { type: 'string', minLength: 1 }, - { type: ['number', 'boolean'], enum: [1, true] } - ] + Helper.validateJsonSchema(schema, { + not: { enum: [null] } }); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); }); - it('represents alternatives with string and null as anyOf', () => { + it('drops generic Date object invalids from unconstrained schemas', () => { - Helper.validateJsonSchema(Joi.alternatives().try(Joi.string().allow(null)), { anyOf: [{ type: ['string', 'null'], minLength: 1 }] }); - }); + 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] + ]; - it('represents alternatives with object as anyOf', () => { + Helper.validate(schema, tests); - Helper.validateJsonSchema(Joi.alternatives().try(Joi.object()), { anyOf: [{ type: 'object' }] }); + Helper.validateJsonSchema(schema, {}); + + 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]), + [{ a: true }, true] + ]); }); - it('represents alternatives with symbol as anyOf', () => { + it('drops generic Date object invalids while preserving JSON invalids', () => { - Helper.validateJsonSchema(Joi.alternatives().try(Joi.symbol()), { anyOf: [{}] }); - }); + 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] + ]; - it('represents alternatives with conditional otherwise as anyOf', () => { + Helper.validate(schema, tests); - Helper.validateJsonSchema(Joi.alternatives().conditional('a', { is: 1, then: Joi.string(), otherwise: Joi.number() }), { - anyOf: [ - { type: 'string', minLength: 1 }, - { type: 'number' } - ] + 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('represents alternatives with switch as anyOf', () => { + it('filters invalid(null) according to whether the emitted schema can match null', () => { - Helper.validateJsonSchema(Joi.alternatives().conditional('a', { - switch: [ - { is: 1, then: Joi.string() } - ], - otherwise: Joi.number() - }), { - anyOf: [ + 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', () => { + + it('represents alternatives as anyOf', () => { + + Helper.validateJsonSchema(Joi.alternatives().try(Joi.string()), { anyOf: [{ minLength: 1, type: 'string' }] }); + }); + + it('represents multiple alternatives as anyOf', () => { + + Helper.validateJsonSchema(Joi.alternatives().try(Joi.string(), Joi.number()), { anyOf: [{ type: 'string', minLength: 1 }, { type: 'number' }] }); + }); + + it('represents alternatives with null as anyOf', () => { + + Helper.validateJsonSchema(Joi.alternatives().try(Joi.string().allow(null), Joi.number()), { + anyOf: [ + { type: ['string', 'null'], minLength: 1 }, + { type: 'number' } + ] + }); + }); + + it('represents alternatives with Joi.any() as anyOf', () => { + + Helper.validateJsonSchema(Joi.alternatives().try(Joi.any()), { anyOf: [{}] }); + }); + + it('represents alternatives with mixed types as anyOf', () => { + + Helper.validateJsonSchema(Joi.alternatives().try(Joi.string(), Joi.valid(1, true)), { + anyOf: [ + { type: 'string', minLength: 1 }, + { type: ['number', 'boolean'], enum: [1, true] } + ] + }); + }); + + it('represents alternatives with string and null as anyOf', () => { + + Helper.validateJsonSchema(Joi.alternatives().try(Joi.string().allow(null)), { anyOf: [{ type: ['string', 'null'], minLength: 1 }] }); + }); + + it('represents alternatives with object as anyOf', () => { + + Helper.validateJsonSchema(Joi.alternatives().try(Joi.object()), { anyOf: [{ type: 'object' }] }); + }); + + it('represents alternatives with symbol as anyOf', () => { + + Helper.validateJsonSchema(Joi.alternatives().try(Joi.symbol()), { anyOf: [{}] }); + }); + + it('represents alternatives with conditional otherwise as anyOf', () => { + + Helper.validateJsonSchema(Joi.alternatives().conditional('a', { is: 1, then: Joi.string(), otherwise: Joi.number() }), { + anyOf: [ + { type: 'string', minLength: 1 }, + { type: 'number' } + ] + }); + }); + + it('represents alternatives with switch as anyOf', () => { + + Helper.validateJsonSchema(Joi.alternatives().conditional('a', { + switch: [ + { is: 1, then: Joi.string() } + ], + otherwise: Joi.number() + }), { + anyOf: [ { type: 'string', minLength: 1 }, { type: 'number' } ] @@ -667,6 +1359,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(), {}); @@ -787,6 +1511,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()), { @@ -831,9 +1590,89 @@ describe('jsonSchema', () => { }); }); - it('represents array with prefixItems', () => { + 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. + 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(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', () => { - Helper.validateJsonSchema(Joi.array().ordered(Joi.string(), Joi.number()), { + 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 }, @@ -842,19 +1681,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', @@ -866,8 +1770,7 @@ describe('jsonSchema', () => { { type: 'number' }, { type: 'boolean' } ] - }, - minItems: 1 + } }); }); @@ -881,9 +1784,24 @@ 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()), { + Helper.validateJsonSchema(Joi.array().ordered(Joi.string().required()), { type: 'array', prefixItems: [{ type: 'string', minLength: 1 }], unevaluatedItems: false, @@ -950,6 +1868,7 @@ describe('jsonSchema', () => { type: 'array' }); }); + }); describe('binary', () => { @@ -958,7 +1877,6 @@ describe('jsonSchema', () => { Helper.validateJsonSchema(Joi.binary().min(10).max(100), { type: 'string', - format: 'binary', minLength: 10, maxLength: 100 }); @@ -966,7 +1884,42 @@ 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', + contentEncoding: 'base64', + 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 + }); }); }); @@ -980,11 +1933,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', () => { @@ -993,8 +1976,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(), @@ -1004,8 +1989,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(), @@ -1015,20 +2002,182 @@ 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 }); }); it('represents date with valid rule', () => { - Helper.validateJsonSchema(Joi.date().valid(new Date(1741708800000)), { - type: 'string', + const value = new Date(1741708800000); + 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', 'number'], format: 'date-time', - enum: [new Date(1741708800000)] + 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])); }); - }); + + 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', + pattern: expectedIsoDatePattern(), + 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), { + type: ['string', 'number'], + format: 'date-time', + minimum: -100e6 * 24 * 60 * 60 * 1000, + maximum: 100e6 * 24 * 60 * 60 * 1000 + }); + }); + + 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'], + [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', 'number'], + format: 'date-time', + 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])); + }); + + 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', () => { + + 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 + }); + 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', () => { + + 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 +2254,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,540 +2486,3047 @@ describe('jsonSchema', () => { }); }); - it('represents complex nested object', () => { - - const schema = Joi.object({ - user: Joi.object({ - id: Joi.number().integer().required(), - name: Joi.string().required() - }).required(), - tags: Joi.array().items(Joi.string()).default([]) - }); + it('represents forbidden keys as false schemas', () => { - const inputExpected = { + Helper.validateJsonSchema(Joi.object({ + a: Joi.string().required(), + secret: Joi.any().forbidden() + }), { type: 'object', properties: { - user: { - type: 'object', - properties: { - id: { type: 'integer' }, - name: { type: 'string', minLength: 1 } - }, - required: ['id', 'name'], - additionalProperties: false - }, - tags: { - type: 'array', - items: { type: 'string', minLength: 1 }, - default: [] - } + a: { type: 'string', minLength: 1 }, + secret: false }, - required: ['user'], + required: ['a'], additionalProperties: false - }; - - Helper.validateJsonSchema(schema, inputExpected, { - ...inputExpected, - required: ['tags', 'user'] }); }); - it('marks required properties based on default values', () => { + it('forbids declared forbidden keys while allowing unknown keys', () => { const schema = Joi.object({ - a: Joi.string().default('foo'), - b: Joi.alternatives().try( - Joi.string().default('bar'), - Joi.number() - ), - c: Joi.array().items(Joi.string().default('baz')), - d: Joi.array().ordered(Joi.number().default(1)), - e: Joi.alternatives().try( - Joi.array().items(Joi.number().default(2)) - ), - f: Joi.number() - }); + a: Joi.string().forbidden() + }).prefs({ allowUnknown: true }); + const tests = [ + [{}, true], + [{ a: 'x' }, false, '"a" is not allowed'], + [{ c: true }, true] + ]; - const output = schema['~standard'].jsonSchema.output(); - expect(output.required).to.equal(['a']); + Helper.validate(schema, tests); - const input = schema['~standard'].jsonSchema.input(); - expect(input.required).to.be.undefined(); - }); - }); + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + a: false + } + }); - describe('string', () => { + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); + }); - it('represents basic string', () => { + it('represents with() dependency as dependentRequired', () => { - Helper.validateJsonSchema(Joi.string().description('A string').default('foo'), { - type: 'string', - description: 'A string', - default: 'foo', - minLength: 1 + 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 string with constraints', () => { + it('represents with() dependency with multiple peers', () => { - Helper.validateJsonSchema(Joi.string().min(5).max(10).pattern(/^[a-z]+$/).email(), { - type: 'string', - minLength: 5, - maxLength: 10, - pattern: '^[a-z]+$', - format: 'email' + 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 string with valids', () => { + it('merges repeated with() dependencies for the same key', () => { - Helper.validateJsonSchema(Joi.string().valid('a', 'b'), { - type: 'string', - enum: ['a', 'b'] + 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'] } }); + }); - Helper.validateJsonSchema(Joi.string().valid(''), { - type: 'string', - enum: [''] - }); + it('represents multiple with() dependencies', () => { - const schemaNoValids = Joi.string().min(1); - schemaNoValids._valids = null; - Helper.validateJsonSchema(schemaNoValids, { - type: 'string', - minLength: 1 + 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'] } }); + }); - Helper.validateJsonSchema(Joi.string().allow(''), { - type: 'string' - }); + it('represents and() dependency as bidirectional dependentRequired', () => { - Helper.validateJsonSchema(Joi.string().min(5).valid('abcde'), { - type: 'string', - enum: ['abcde'], - minLength: 5 + 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.string().max(5).valid('abcde'), { - type: 'string', - enum: ['abcde'], - maxLength: 5 + 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'] } }); + }); - Helper.validateJsonSchema(Joi.string().length(5).valid('abcde'), { - type: 'string', - enum: ['abcde'], - minLength: 5, - maxLength: 5 + 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'] + } }); + }); - Helper.validateJsonSchema(Joi.string().pattern(/abc/).valid('abc'), { + 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({ + 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 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 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({ + 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 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('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({ + 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 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({ + 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 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({ + 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('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('represents stripped keys as false schemas 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 }, + 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({ + 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('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({ + user: Joi.object({ + id: Joi.number().integer().required(), + name: Joi.string().required() + }).required(), + tags: Joi.array().items(Joi.string()).default([]) + }); + + const inputExpected = { + type: 'object', + properties: { + user: { + type: 'object', + properties: { + id: { type: 'integer' }, + name: { type: 'string', minLength: 1 } + }, + required: ['id', 'name'], + additionalProperties: false + }, + tags: { + type: 'array', + items: { type: 'string', minLength: 1 }, + default: [] + } + }, + required: ['user'], + additionalProperties: false + }; + + Helper.validateJsonSchema(schema, inputExpected, { + ...inputExpected, + required: ['tags', 'user'] + }); + }); + + it('marks required properties based on default values', () => { + + const schema = Joi.object({ + a: Joi.string().default('foo'), + b: Joi.alternatives().try( + Joi.string().default('bar'), + Joi.number() + ), + c: Joi.array().items(Joi.string().default('baz')), + d: Joi.array().ordered(Joi.number().default(1)), + e: Joi.alternatives().try( + Joi.array().items(Joi.number().default(2)) + ), + f: Joi.number() + }); + + const output = schema['~standard'].jsonSchema.output(); + expect(output.required).to.equal(['a']); + + const input = schema['~standard'].jsonSchema.input(); + expect(input.required).to.be.undefined(); + }); + }); + + 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'), { + type: 'string', + description: 'A string', + default: 'foo', + minLength: 1 + }); + }); + + it('represents string with constraints', () => { + + Helper.validateJsonSchema(Joi.string().min(5).max(10).pattern(/^[a-z]+$/).email(), { + type: 'string', + minLength: 5, + maxLength: 10, + pattern: '^[a-z]+$', + format: 'email' + }); + }); + + it('represents string with valids', () => { + + Helper.validateJsonSchema(Joi.string().valid('a', 'b'), { + type: 'string', + minLength: 1, + enum: ['a', 'b'] + }); + + Helper.validateJsonSchema(Joi.string().valid(''), { + type: 'string', + enum: [''] + }); + + const schemaNoValids = Joi.string().min(1); + schemaNoValids._valids = null; + Helper.validateJsonSchema(schemaNoValids, { + type: 'string', + minLength: 1 + }); + + Helper.validateJsonSchema(Joi.string().allow(''), { + anyOf: [ + { type: 'string' }, + { enum: [''] } + ] + }); + + Helper.validateJsonSchema(Joi.string().min(5).valid('abcde'), { + type: 'string', + enum: ['abcde'], + minLength: 5 + }); + + Helper.validateJsonSchema(Joi.string().max(5).valid('abcde'), { + type: 'string', + enum: ['abcde'], + minLength: 1, + maxLength: 5 + }); + + Helper.validateJsonSchema(Joi.string().length(5).valid('abcde'), { + type: 'string', + enum: ['abcde'], + minLength: 5, + maxLength: 5 + }); + + 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', + pattern: expectedIpPattern(), + minLength: 1, + enum: ['127.0.0.1'] + }); + + Helper.validateJsonSchema(Joi.string().valid('a'), { enum: ['a'], type: 'string', minLength: 1 }); + }); + + it('represents string with formats', () => { + + Helper.validateJsonSchema(Joi.string().length(5).pattern(/foo/).email().hostname().uri().uuid().guid(), { + type: 'string', + minLength: 5, + maxLength: 5, + allOf: [ + { pattern: 'foo' }, + { pattern: expectedHostnamePattern() } + ], + format: 'uuid' + }); + + 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, 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, pattern: '^[A-Za-z0-9_]+$' }); + Helper.validateJsonSchema(Joi.string().domain({ tlds: false }), { + type: 'string', + minLength: 1, + 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', + minLength: 1, + 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({ minDomainSegments: 3, tlds: false }), { + type: 'string', + minLength: 1, + 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-\\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-\\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-\\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-\\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-\\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(); + 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], + ['๐ˆ.com', true], + ['๐Ÿฆ„.com', true], + ['example.ะšะžะœ', 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] + ]); + + 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] + ]); + + 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] + ]); + + // 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('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(); + 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(''), { + anyOf: [ + { type: 'string' }, + { enum: [''] } + ] + }); + Helper.validateJsonSchema(Joi.string().min(0), { type: 'string' }); + Helper.validateJsonSchema(Joi.string().length(0), { type: 'string', minLength: 0, maxLength: 0 }); + + Helper.validateJsonSchema(Joi.string().allow(1), { + anyOf: [ + { type: 'string', minLength: 1 }, + { enum: [1] } + ] + }); + + 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('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('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( + 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('represents root presence: forbidden preference as false', () => { + + const schema = Joi.object({ + a: Joi.string(), + b: Joi.number() + }).prefs({ presence: 'forbidden' }); + 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(), tests.map(([value, pass]) => [value, pass])); + }); + + 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 }); + 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(), tests.map(([value, pass]) => [value, pass])); + }); + + 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' }) + }); + 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', + properties: { + nested: false + }, + additionalProperties: 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', () => { + + const schema = Joi.object({ + nested: Joi.object({ + a: Joi.string() + }).prefs({ presence: 'forbidden' }).optional() + }); + const tests = [ + [{}, true], + [{ nested: {} }, true], + [{ nested: { a: 'x' } }, false, '"nested.a" is not allowed'] + ]; + + Helper.validate(schema, tests); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + nested: { + type: 'object', + properties: { + a: false + }, + additionalProperties: false + } + }, + additionalProperties: false + }); + + Helper.validateJsonSchemaValues(schema['~standard'].jsonSchema.input(), tests.map(([value, pass]) => [value, pass])); + }); + + 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', () => { + + expect(Joi.any().allow(null, {})['~standard'].jsonSchema.input({ target: 'draft-2020-12' })).to.equal({}); + }); + + it('errors on unsupported target', () => { + + const schema = Joi.any(); + const js = schema['~standard'].jsonSchema; + expect(() => js.input({ target: 'invalid' })).to.throw('Unsupported JSON Schema target: invalid'); + + const stringJs = Joi.string()['~standard'].jsonSchema; + expect(() => stringJs.input({ target: 'unsupported' })).to.throw('Unsupported JSON Schema target: unsupported'); + expect(() => stringJs.output({ target: 'unsupported' })).to.throw('Unsupported JSON Schema target: unsupported'); + }); + + it('accepts draft-2020-12 target', () => { + + const js = Joi.string()['~standard'].jsonSchema; + expect(js.input({ target: 'draft-2020-12' })).to.be.an.object(); + expect(js.output({ target: 'draft-2020-12' })).to.be.an.object(); + }); + }); + + describe('extensions', () => { + + it('represents custom rule jsonSchema', () => { + + const custom = Joi.extend({ + type: 'string', + base: Joi.string(), + rules: { + foo: { + method() { + + return this.$_addRule('foo'); + }, + jsonSchema(rule, res) { + + res.foo = true; + return res; + } + } + } + }); + + Helper.validateJsonSchema(custom.string().foo(), { + type: 'string', + minLength: 1, + foo: true + }); + }); + + it('represents custom type jsonSchema', () => { + + const custom = Joi.extend({ + type: 'banana', + base: Joi.any(), + jsonSchema(schema, res) { + + res.type = 'string'; + res.format = 'banana'; + return res; + } + }); + + Helper.validateJsonSchema(custom.banana(), { + type: 'string', + format: 'banana' + }); + }); + + 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({ + type: 'number', + base: Joi.number(), + rules: { + divisible: { + method(base) { + + return this.$_addRule({ name: 'divisible', args: { base } }); + }, + args: [ + { + name: 'base', + ref: true, + assert: (value) => typeof value === 'number' && value > 0, + message: 'must be a positive number' + } + ], + jsonSchema(rule, res) { + + res.multipleOf = rule.args.base; + return res; + } + } + } + }); + + Helper.validateJsonSchema(custom.number().divisible(5), { + type: 'number', + multipleOf: 5 + }); + }); + + it('represents custom type allow null jsonSchema', () => { + + const custom = Joi.extend({ + type: 'foo', + base: Joi.any(), + jsonSchema(schema, res) { + + res.description = 'foo'; + return res; + } + }); + + Helper.validateJsonSchema(custom.foo().allow(null), { + anyOf: [ + { type: 'null' }, + { description: 'foo' } + ] + }); + }); + + 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: ['abc'], - pattern: 'abc' + enum: ['a'] }); + }); - Helper.validateJsonSchema(Joi.string().email().valid('a@b.com'), { + it('normalizes retained boolean custom jsonSchema output before applying exclusive valids', () => { + + const custom = Joi.extend({ type: 'string', - enum: ['a@b.com'], - format: 'email' + base: Joi.any(), + jsonSchema() { + + return false; + } }); - Helper.validateJsonSchema(Joi.string().guid().valid('550e8400-e29b-41d4-a716-446655440000'), { + Helper.validateJsonSchema(custom.string().valid('a'), { type: 'string', - enum: ['550e8400-e29b-41d4-a716-446655440000'], - format: 'uuid' + enum: ['a'] + }); + }); + + it('inherits json schema type from custom string extensions', () => { + + const custom = Joi.extend({ + type: 'myString', + base: Joi.string() }); - Helper.validateJsonSchema(Joi.string().ip().valid('127.0.0.1'), { + Helper.validateJsonSchema(custom.myString(), { type: 'string', - format: 'ip', - enum: ['127.0.0.1'] + 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', () => { + + it('represents empty object for Joi.object()', () => { + + Helper.validateJsonSchema(Joi.object(), { + type: 'object' + }); + }); + + it('represents properties for Joi.object({ a: Joi.string() })', () => { + + Helper.validateJsonSchema(Joi.object({ a: Joi.string() }), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 } + }, + additionalProperties: false + }); + }); + + it('represents required for Joi.object({ a: Joi.required() })', () => { + + Helper.validateJsonSchema(Joi.object({ a: Joi.required() }), { + type: 'object', + properties: { + a: {} + }, + required: ['a'], + additionalProperties: false + }); + }); + + it('represents patternProperties for Joi.object().pattern(/a/, Joi.number())', () => { + + Helper.validateJsonSchema(Joi.object().pattern(/a/, Joi.number()), { + type: 'object', + patternProperties: { + a: { type: 'number' } + }, + additionalProperties: false + }); + }); + + it('represents additionalProperties: true for Joi.object().unknown(true)', () => { + + Helper.validateJsonSchema(Joi.object().unknown(true), { + type: 'object' + }); + }); + + it('represents additionalProperties: false for Joi.object().unknown(false)', () => { + + Helper.validateJsonSchema(Joi.object().unknown(false), { + type: 'object', + additionalProperties: false + }); + }); + + it('represents additionalProperties for Joi.object().pattern(Joi.any(), Joi.number())', () => { + + Helper.validateJsonSchema(Joi.object().pattern(Joi.any(), Joi.number()), { + type: 'object', + additionalProperties: { type: 'number' } + }); + }); + + it('represents patternProperties for Joi.object().pattern(Joi.string(), Joi.number())', () => { + + Helper.validateJsonSchema(Joi.object().pattern(Joi.string(), Joi.number()), { + type: 'object', + patternProperties: { + '.*': { type: 'number' } + }, + additionalProperties: false + }); + }); + + it('represents required for Joi.object({ a: Joi.string().default("foo") }) only in output mode', () => { + + const schema = Joi.object({ + a: Joi.string().default('foo') + }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + a: { type: 'string', minLength: 1, default: 'foo' } + }, + additionalProperties: false + }, { + type: 'object', + properties: { + a: { type: 'string', minLength: 1, default: 'foo' } + }, + required: ['a'], + additionalProperties: false + }); + }); + + it('represents required for Joi.object({ a: Joi.string().required() })', () => { + + const schema = Joi.object({ + a: Joi.string().required() + }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 } + }, + required: ['a'], + additionalProperties: false + }); + }); + + it('represents nested object properties', () => { + + const schema = Joi.object({ + a: Joi.object({ + b: Joi.string().required() + }).required() + }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + a: { + type: 'object', + properties: { + b: { type: 'string', minLength: 1 } + }, + required: ['b'], + additionalProperties: false + } + }, + required: ['a'], + additionalProperties: false }); - - Helper.validateJsonSchema(Joi.string().valid('a'), { enum: ['a'], type: 'string' }); }); - it('represents string with formats', () => { + it('represents nested array with items', () => { - Helper.validateJsonSchema(Joi.string().length(5).pattern(/foo/).email().hostname().uri().uuid().guid(), { - type: 'string', - minLength: 5, - maxLength: 5, - pattern: 'foo', - format: 'uuid' + const schema = Joi.object({ + a: Joi.array().items( + Joi.object({ + b: Joi.string().required() + }) + ) }); - 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().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().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(schema, { + type: 'object', + properties: { + a: { + type: 'array', + items: { + type: 'object', + properties: { + b: { type: 'string', minLength: 1 } + }, + required: ['b'], + additionalProperties: false + } + } + }, + additionalProperties: false + }); }); - it('represents string with various options', () => { + it('represents complex alternatives with multiple conditions', () => { - Helper.validateJsonSchema(Joi.string().alphanum(), { type: 'string', minLength: 1 }); - 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 }); + const schema = Joi.any().when('a', { is: 1, then: Joi.string() }).when('a', { is: 2, then: Joi.number() }); - Helper.validateJsonSchema(Joi.string().allow(1), { + Helper.validateJsonSchema(schema, { anyOf: [ { type: 'string', minLength: 1 }, - { enum: [1] } + {}, + { type: 'number' } ] }); - - Helper.validateJsonSchema(Joi.string().allow('a'), { type: 'string', minLength: 1 }); - }); - - it('represents nullable string', () => { - - Helper.validateJsonSchema(Joi.string().allow(null), { type: ['string', 'null'], minLength: 1 }); - }); - }); - - describe('target', () => { - - it('represents nullable schemas with draft-2020-12 target', () => { - - expect(Joi.any().allow(null, {})['~standard'].jsonSchema.input({ target: 'draft-2020-12' })).to.equal({}); }); - it('errors on unsupported target', () => { - - const schema = Joi.any(); - const js = schema['~standard'].jsonSchema; - expect(() => js.input({ target: 'invalid' })).to.throw('Unsupported JSON Schema target: invalid'); + it('hoists sibling literal whens to object-level conditionals', () => { - const stringJs = Joi.string()['~standard'].jsonSchema; - expect(() => stringJs.input({ target: 'unsupported' })).to.throw('Unsupported JSON Schema target: unsupported'); - expect(() => stringJs.output({ target: 'unsupported' })).to.throw('Unsupported JSON Schema target: unsupported'); - }); + const schema = Joi.object({ + mode: Joi.string().required(), + payload: Joi.any().when('mode', { is: 'numeric', then: Joi.number(), otherwise: Joi.string() }) + }); - it('accepts draft-2020-12 target', () => { + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + mode: { type: 'string', minLength: 1 }, + payload: {} + }, + 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 } + } + } + }); - const js = Joi.string()['~standard'].jsonSchema; - expect(js.input({ target: 'draft-2020-12' })).to.be.an.object(); - expect(js.output({ target: 'draft-2020-12' })).to.be.an.object(); + 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] + ]); }); - }); - - describe('extensions', () => { - it('represents custom rule jsonSchema', () => { - - const custom = Joi.extend({ - type: 'string', - base: Joi.string(), - rules: { - foo: { - method() { + it('hoists required branch presence to the object-level conditional', () => { - return this.$_addRule('foo'); - }, - jsonSchema(rule, res) { + const schema = Joi.object({ + mode: Joi.string().required(), + payload: Joi.any().when('mode', { is: 'needs-payload', then: Joi.required() }) + }); - res.foo = true; - return res; - } - } + 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.validateJsonSchema(custom.string().foo(), { - type: 'string', - minLength: 1, - foo: true - }); + 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('represents custom type jsonSchema', () => { + it('hoists sibling literal whens with passthrough matches and string fallbacks', () => { - const custom = Joi.extend({ - type: 'banana', - base: Joi.any(), - jsonSchema(schema, res) { + const schema = Joi.object({ + mode: Joi.string().required(), + payload: Joi.any().when('mode', { is: 'passthrough', otherwise: Joi.string() }) + }); - res.type = 'string'; - res.format = 'banana'; - return res; + 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.validateJsonSchema(custom.banana(), { - type: 'string', - format: 'banana' - }); + 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('represents custom rule with options jsonSchema', () => { + it('hoists multiple sibling literal whens as separate object-level conditionals', () => { - const custom = Joi.extend({ - type: 'number', - base: Joi.number(), - rules: { - divisible: { - method(base) { + const schema = Joi.object({ + mode: Joi.string(), + payload: Joi.any() + .when('mode', { is: 'text', then: Joi.string() }) + .when('mode', { is: 'count', then: Joi.number() }) + }); - return this.$_addRule({ name: 'divisible', args: { base } }); + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + mode: { type: 'string', minLength: 1 }, + payload: {} + }, + additionalProperties: false, + allOf: [ + { + if: { + type: 'object', + properties: { + mode: { const: 'text' } + }, + required: ['mode'] }, - args: [ - { - name: 'base', - ref: true, - assert: (value) => typeof value === 'number' && value > 0, - message: 'must be a positive number' + then: { + properties: { + payload: { type: 'string', minLength: 1 } + } + } + }, + { + if: { + type: 'object', + properties: { + mode: { const: 'count' } + }, + required: ['mode'] + }, + then: { + properties: { + payload: { type: 'number' } } - ], - jsonSchema(rule, res) { - - res.multipleOf = rule.args.base; - return res; } } - } + ] }); - Helper.validateJsonSchema(custom.number().divisible(5), { - type: 'number', - multipleOf: 5 - }); + 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('represents custom type allow null jsonSchema', () => { - - const custom = Joi.extend({ - type: 'foo', - base: Joi.any(), - jsonSchema(schema, res) { + it('appends hoisted conditionals to existing object allOf constraints', () => { - res.description = 'foo'; - return res; - } - }); + 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(custom.foo().allow(null), { - anyOf: [ - { type: 'null' }, - { description: 'foo' } + 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] + ]); }); - }); - describe('object', () => { + it('hoists simple object-path whens to object-level conditionals', () => { - it('represents empty object for Joi.object()', () => { + 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(Joi.object(), { - type: 'object' + 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('represents properties for Joi.object({ a: Joi.string() })', () => { + it('keeps fixed array-index whens on the child as anyOf', () => { - Helper.validateJsonSchema(Joi.object({ a: Joi.string() }), { + 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: { - a: { type: 'string', minLength: 1 } + 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('represents required for Joi.object({ a: Joi.required() })', () => { + it('keeps malformed object-path whens on the child as anyOf', () => { - Helper.validateJsonSchema(Joi.object({ a: Joi.required() }), { - type: 'object', - properties: { - a: {} - }, - required: ['a'], - additionalProperties: false - }); + 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('represents patternProperties for Joi.object().pattern(/a/, Joi.number())', () => { + it('keeps schema-condition whens on the child as anyOf', () => { - Helper.validateJsonSchema(Joi.object().pattern(/a/, Joi.number()), { + 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', - patternProperties: { - a: { type: 'number' } + properties: { + type: { type: 'string', minLength: 1 }, + value: { + anyOf: [ + { type: 'number' }, + { type: 'string', minLength: 1 } + ] + } }, additionalProperties: false }); }); - it('represents additionalProperties: true for Joi.object().unknown(true)', () => { + it('hoists literal switch whens without otherwise to nested object-level conditionals', () => { - Helper.validateJsonSchema(Joi.object().unknown(true), { - type: 'object' + const schema = Joi.object({ + kind: Joi.string(), + value: Joi.any().when('kind', { + switch: [ + { is: 'a', then: Joi.string() }, + { is: 'b', then: Joi.number() } + ] + }) }); - }); - - it('represents additionalProperties: false for Joi.object().unknown(false)', () => { - Helper.validateJsonSchema(Joi.object().unknown(false), { + Helper.validateJsonSchema(schema, { type: 'object', - additionalProperties: false + 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('represents additionalProperties for Joi.object().pattern(Joi.any(), Joi.number())', () => { + it('hoists literal switch whens with otherwise to nested object-level conditionals', () => { - Helper.validateJsonSchema(Joi.object().pattern(Joi.any(), Joi.number()), { - type: 'object', - additionalProperties: { type: 'number' } + 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() + }) }); - }); - - it('represents patternProperties for Joi.object().pattern(Joi.string(), Joi.number())', () => { - Helper.validateJsonSchema(Joi.object().pattern(Joi.string(), Joi.number()), { + Helper.validateJsonSchema(schema, { type: 'object', - patternProperties: { - '.*': { type: 'number' } + properties: { + type: { type: 'string', minLength: 1 }, + value: {} }, - additionalProperties: false + 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('represents required for Joi.object({ a: Joi.string().default("foo") }) only in output mode', () => { + it('keeps non-literal switch whens on the child as anyOf', () => { const schema = Joi.object({ - a: Joi.string().default('foo') + 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: { - a: { type: 'string', minLength: 1, default: 'foo' } - }, - additionalProperties: false - }, { - type: 'object', - properties: { - a: { type: 'string', minLength: 1, default: 'foo' } + type: { type: 'number' }, + value: { + anyOf: [ + { type: 'number' }, + { type: 'string', minLength: 1 }, + { type: 'boolean' } + ] + } }, - required: ['a'], additionalProperties: false }); }); - it('represents required for Joi.object({ a: Joi.string().required() })', () => { + it('keeps adjusted refs on the child as anyOf', () => { const schema = Joi.object({ - a: Joi.string().required() + 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: { - a: { type: 'string', minLength: 1 } + type: { type: 'string', minLength: 1 }, + value: { + anyOf: [ + { type: 'number' }, + { type: 'string', minLength: 1 } + ] + } }, - required: ['a'], additionalProperties: false }); }); - it('represents nested object properties', () => { + 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({ - a: Joi.object({ - b: Joi.string().required() - }).required() + 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: { - a: { - type: 'object', - properties: { - b: { type: 'string', minLength: 1 } - }, - required: ['b'], - additionalProperties: false + type: {}, + value: { + anyOf: [ + { type: 'number' }, + { type: 'string', minLength: 1 } + ] } }, - required: ['a'], additionalProperties: false }); }); - it('represents nested array with items', () => { + it('keeps multi-valued literal predicates on the child as anyOf', () => { const schema = Joi.object({ - a: Joi.array().items( - Joi.object({ - b: Joi.string().required() - }) - ) + 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: { - a: { - type: 'array', - items: { - type: 'object', - properties: { - b: { type: 'string', minLength: 1 } - }, - required: ['b'], - additionalProperties: false - } + type: {}, + value: { + anyOf: [ + { type: 'number' }, + { type: 'string', minLength: 1 } + ] } }, additionalProperties: false }); }); - it('represents complex alternatives with multiple conditions', () => { + it('keeps non-hoistable any predicates on the child as anyOf', () => { - const schema = Joi.any().when('a', { is: 1, then: Joi.string() }).when('a', { is: 2, then: Joi.number() }); + 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)) + ]; - Helper.validateJsonSchema(schema, { - anyOf: [ - { type: 'string', minLength: 1 }, - {}, - { type: 'number' } - ] - }); + 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('represents complex alternatives with multiple conditions on object', () => { + it('hoists null 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() }) + type: Joi.any(), + value: Joi.any().when('type', { is: null, then: Joi.number(), otherwise: Joi.string() }) }); Helper.validateJsonSchema(schema, { type: 'object', properties: { - a: {}, - b: { - anyOf: [ - { type: 'string', minLength: 1 }, - {}, - { type: 'number' } - ] + type: {}, + value: {} + }, + additionalProperties: false, + if: { + type: 'object', + properties: { + type: { const: null } + }, + required: ['type'] + }, + then: { + properties: { + value: { type: 'number' } } }, - additionalProperties: false + else: { + properties: { + value: { type: 'string', minLength: 1 } + } + } }); }); }); @@ -1824,6 +5538,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'); @@ -1963,6 +5687,70 @@ 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('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: { + a: false, + 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: { + a: false, + b: { $ref: '#/$defs/aSchema' } + }, + additionalProperties: false, + $defs: { + aSchema: { type: 'string', minLength: 1 } + } + }); + }); + it('represents Joi.link() with uninitialized schema', () => { Helper.validateJsonSchema(Joi.link(), {});