diff --git a/API.md b/API.md index b489884e..b2641609 100755 --- a/API.md +++ b/API.md @@ -1235,6 +1235,22 @@ try { catch (err) { } ``` +#### `any['~standard']` + +Provides compatibility with the [Standard Schema](https://github.com/standard-schema/spec) specification. It contains a `jsonSchema` object with the following methods: + +- `input([options])` - returns the JSON Schema for the input value (before conversion). +- `output([options])` - returns the JSON Schema for the output value (after conversion). + +Where `options` is an optional object with: +- `target` - the JSON Schema target version. Currently only supports `'draft-2020-12'`. Defaults to `'draft-2020-12'`. + +```js +const schema = Joi.string().min(5); +const jsonSchema = schema['~standard'].jsonSchema.input(); +// { type: 'string', minLength: 5 } +``` + #### `any.when([condition], options)` Adds conditions that are evaluated during validation and modify the schema before it is applied to the value, where: @@ -3205,6 +3221,13 @@ Where: - `errors`: Validation error(s) generated by `$_createError()` or `helpers.error()`. If `errors` is defined, validation will abort regardless of `abortEarly`. Refer to the validation process above for further information. +- `jsonSchema`: A function with signature `function (schema, res, mode, options) {}` that returns the JSON Schema for the type where: + - `schema`: The current schema instance. + - `res`: The current JSON Schema object. + - `mode`: Either `'input'` or `'output'`. + - `options`: The options passed to `jsonSchema.input()` or `jsonSchema.output()`. + + Must return the modified JSON Schema object. - `rules`: A hash of validation rule names and their implementation where: - `alias`: Aliases of the rule. Can be a string or an array of strings. - `args`: An array of argument names or an object that define the parameters the rule will accept where: @@ -3222,6 +3245,14 @@ Where: - `helpers`: [Validation helpers](#validation-helpers) - `args`: Resolved and validated arguments mapped by their names. - `rule`: The rule definitions passed to `$_addRule` left untouched. Useful if you need access to the raw arguments before validation. + - `jsonSchema`: A function with signature `function (rule, res, isOnly, mode, options) {}` that returns the JSON Schema for the rule where: + - `rule`: The rule object. + - `res`: The current JSON Schema object. + - `isOnly`: A boolean indicating if the schema has the `only` flag set. + - `mode`: Either `'input'` or `'output'`. + - `options`: The options passed to `jsonSchema.input()` or `jsonSchema.output()`. + + Must return the modified JSON Schema object. - `overrides`: A hash of method names and their overridden implementation. To refer to the parent method, use [`$_parent()`](#_parentmethod-args) ```js diff --git a/lib/base.js b/lib/base.js old mode 100755 new mode 100644 index ee61b0ec..dba23b31 --- a/lib/base.js +++ b/lib/base.js @@ -16,7 +16,12 @@ const Validator = require('./validator'); const Values = require('./values'); -const internals = {}; +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' }) +}; internals.Base = class { @@ -62,6 +67,168 @@ internals.Base = class { return Manifest.describe(this); } + $_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; + } + // Rules allow(...values) { @@ -1116,6 +1283,10 @@ internals.Base = class { } return mapToStandardError(result.error); + }, + jsonSchema: { + input: (options) => this.$_jsonSchema('input', options), + output: (options) => this.$_jsonSchema('output', options) } }; } diff --git a/lib/common.js b/lib/common.js index ff4f89ff..7f065320 100755 --- a/lib/common.js +++ b/lib/common.js @@ -99,6 +99,25 @@ exports.default = function (value, defaultValue) { }; +exports.intersect = function (set, other) { + + /* $lab:coverage:off$ */ + if (typeof set.intersection === 'function') { + return set.intersection(other); + } + + const result = new Set(); + for (const item of set) { + if (other.has(item)) { + result.add(item); + } + } + + return result; + /* $lab:coverage:on$ */ +}; + + exports.isIsoDate = function (date) { return internals.isoDate.test(date); diff --git a/lib/extend.js b/lib/extend.js index 9b7fa2eb..b0d42229 100755 --- a/lib/extend.js +++ b/lib/extend.js @@ -129,6 +129,12 @@ exports.type = function (from, options) { def.rules = rules; + // JSON Schema + + if (!def.jsonSchema) { + def.jsonSchema = parent.jsonSchema; + } + // Modifiers const modifiers = Object.assign({}, parent.modifiers); diff --git a/lib/index.d.ts b/lib/index.d.ts index 11cb5713..9904088f 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -8,7 +8,7 @@ // TypeScript Version: 2.8 // TODO express type of Schema in a type-parameter (.default, .valid, .example etc) -import type { StandardSchemaV1 } from "@standard-schema/spec"; +import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"; declare namespace Joi { type Types = @@ -1003,7 +1003,14 @@ declare namespace Joi { interface AnySchema extends SchemaInternals, - StandardSchemaV1 { + StandardSchemaV1, + StandardJSONSchemaV1 { + /** + * The Standard properties. + */ + readonly "~standard": StandardSchemaV1.Props & + StandardJSONSchemaV1.Props; + /** * Flags of current schema. */ diff --git a/lib/schemas.js b/lib/schemas.js index 17b9747e..cfd5e2fc 100755 --- a/lib/schemas.js +++ b/lib/schemas.js @@ -80,7 +80,8 @@ internals.rule = Joi.object({ manifest: Joi.boolean(), method: Joi.function().allow(false), multi: Joi.boolean(), - validate: Joi.function() + validate: Joi.function(), + jsonSchema: Joi.function() }); @@ -114,6 +115,7 @@ exports.extension = Joi.object({ prepare: Joi.function().maxArity(3), rebuild: Joi.function().arity(1), rules: Joi.object().pattern(internals.nameRx, internals.rule), + jsonSchema: Joi.function(), terms: Joi.object().pattern(internals.nameRx, Joi.object({ init: Joi.array().allow(null).required(), manifest: Joi.object().pattern(/.+/, [ diff --git a/lib/types/alternatives.js b/lib/types/alternatives.js index 35c102a7..84f09f99 100755 --- a/lib/types/alternatives.js +++ b/lib/types/alternatives.js @@ -146,6 +146,49 @@ module.exports = Any.extend({ return internals.errors(errors, helpers); }, + 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)); + } + } + } + } + + if (matches.length) { + delete res.type; + + // Map alternatives to 'anyOf' or 'oneOf' based on the match flag + + const matchMode = schema._flags.match ?? 'any'; + if (matchMode === 'one') { + res.oneOf = matches; + } + else { + res.anyOf = matches; + } + } + + return res; + }, + rules: { conditional: { diff --git a/lib/types/array.js b/lib/types/array.js index eda5a64a..1d8c81b4 100755 --- a/lib/types/array.js +++ b/lib/types/array.js @@ -68,6 +68,87 @@ module.exports = Any.extend({ return { value: value.slice() }; // Clone the array so that we don't modify the original }, + 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; + }, + rules: { has: { @@ -368,6 +449,13 @@ module.exports = Any.extend({ return helpers.error('array.' + name, { limit: args.limit, value }); }, + jsonSchema(rule, res) { + + res.minItems = rule.args.limit; + res.maxItems = rule.args.limit; + + return res; + }, args: [ { name: 'limit', @@ -382,6 +470,12 @@ module.exports = Any.extend({ method(limit) { return this.$_addRule({ name: 'max', method: 'length', args: { limit }, operator: '<=' }); + }, + jsonSchema(rule, res) { + + res.maxItems = rule.args.limit; + + return res; } }, @@ -389,6 +483,12 @@ module.exports = Any.extend({ method(limit) { return this.$_addRule({ name: 'min', method: 'length', args: { limit }, operator: '>=' }); + }, + jsonSchema(rule, res) { + + res.minItems = rule.args.limit; + + return res; } }, @@ -558,6 +658,12 @@ module.exports = Any.extend({ return value; }, + jsonSchema(rule, res) { + + res.uniqueItems = true; + + return res; + }, args: ['comparator', 'options'], multi: true } diff --git a/lib/types/binary.js b/lib/types/binary.js index 22ab5ba9..dbf1fd03 100755 --- a/lib/types/binary.js +++ b/lib/types/binary.js @@ -33,6 +33,14 @@ module.exports = Any.extend({ } }, + jsonSchema(schema, res, mode, options) { + + res.type = 'string'; + res.format = 'binary'; + + return res; + }, + rules: { encoding: { method(encoding) { @@ -56,6 +64,12 @@ module.exports = Any.extend({ return helpers.error('binary.' + name, { limit: args.limit, value }); }, + jsonSchema(rule, res) { + + res.minLength = rule.args.limit; + res.maxLength = rule.args.limit; + return res; + }, args: [ { name: 'limit', @@ -70,6 +84,11 @@ module.exports = Any.extend({ method(limit) { return this.$_addRule({ name: 'max', method: 'length', args: { limit }, operator: '<=' }); + }, + jsonSchema(rule, res) { + + res.maxLength = rule.args.limit; + return res; } }, @@ -77,6 +96,11 @@ module.exports = Any.extend({ method(limit) { return this.$_addRule({ name: 'min', method: 'length', args: { limit }, operator: '>=' }); + }, + jsonSchema(rule, res) { + + res.minLength = rule.args.limit; + return res; } } }, diff --git a/lib/types/date.js b/lib/types/date.js index 8b941596..306fa999 100755 --- a/lib/types/date.js +++ b/lib/types/date.js @@ -7,7 +7,9 @@ const Common = require('../common'); const Template = require('../template'); -const internals = {}; +const internals = { + formats: ['iso', 'javascript', 'unix'] +}; internals.isDate = function (value) { @@ -48,6 +50,14 @@ module.exports = Any.extend({ return { value, errors: error('date.format', { format }) }; }, + jsonSchema(schema, res, mode, options) { + + res.type = 'string'; + res.format = 'date-time'; + + return res; + }, + rules: { compare: { @@ -78,7 +88,7 @@ module.exports = Any.extend({ format: { method(format) { - assert(['iso', 'javascript', 'unix'].includes(format), 'Unknown date format', format); + assert(internals.formats.includes(format), 'Unknown date format', format); return this.$_setFlag('format', format); } @@ -88,6 +98,15 @@ module.exports = Any.extend({ method(date) { return this.$_addRule({ name: 'greater', method: 'compare', args: { date }, operator: '>' }); + }, + jsonSchema(rule, res) { + + const date = rule.args.date; + if (date instanceof Date) { + res['x-constraint'] = { ...res['x-constraint'], greater: date.toISOString() }; + } + + return res; } }, @@ -102,6 +121,15 @@ module.exports = Any.extend({ method(date) { return this.$_addRule({ name: 'less', method: 'compare', args: { date }, operator: '<' }); + }, + jsonSchema(rule, res) { + + const date = rule.args.date; + if (date instanceof Date) { + res['x-constraint'] = { ...res['x-constraint'], less: date.toISOString() }; + } + + return res; } }, @@ -109,6 +137,15 @@ module.exports = Any.extend({ method(date) { return this.$_addRule({ name: 'max', method: 'compare', args: { date }, operator: '<=' }); + }, + jsonSchema(rule, res) { + + const date = rule.args.date; + if (date instanceof Date) { + res['x-constraint'] = { ...res['x-constraint'], max: date.toISOString() }; + } + + return res; } }, @@ -116,6 +153,15 @@ module.exports = Any.extend({ method(date) { return this.$_addRule({ name: 'min', method: 'compare', args: { date }, operator: '>=' }); + }, + jsonSchema(rule, res) { + + const date = rule.args.date; + if (date instanceof Date) { + res['x-constraint'] = { ...res['x-constraint'], min: date.toISOString() }; + } + + return res; } }, diff --git a/lib/types/keys.js b/lib/types/keys.js index 2ef47e3d..af281f03 100755 --- a/lib/types/keys.js +++ b/lib/types/keys.js @@ -47,6 +47,70 @@ module.exports = Any.extend({ return schema.keys(keys); }, + jsonSchema(schema, res, mode, options) { + + res.type = 'object'; + + // Map Joi keys to JSON Schema 'properties' and 'required' + + if (schema.$_terms.keys) { + res.properties = {}; + + 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)) { + + required.push(child.key); + } + } + + if (required.length) { + res.required = required.sort(); + } + } + + // Map Joi patterns to JSON Schema 'patternProperties' or 'additionalProperties' + + if (schema.$_terms.patterns) { + const patternProperties = {}; + + for (const pattern of schema.$_terms.patterns) { + if (pattern.regex) { + patternProperties[pattern.regex.source] = pattern.rule.$_jsonSchema(mode, options); + } + else { + const isAny = pattern.schema.type === 'any'; + if (isAny) { + res.additionalProperties = pattern.rule.$_jsonSchema(mode, options); + } + else { + // Best effort for schema-based patterns that are not 'any' + patternProperties['.*'] = pattern.rule.$_jsonSchema(mode, options); + } + } + } + + if (Object.keys(patternProperties).length) { + res.patternProperties = patternProperties; + } + } + + // Handle 'additionalProperties' based on unknown keys flag + + 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) { + res.additionalProperties = false; + } + } + + return res; + }, + validate(value, { schema, error, state, prefs }) { if (!value || @@ -278,6 +342,12 @@ module.exports = Any.extend({ return helpers.error('object.' + name, { limit: args.limit, value }); }, + jsonSchema(rule, res) { + + res.minProperties = rule.args.limit; + res.maxProperties = rule.args.limit; + return res; + }, args: [ { name: 'limit', @@ -292,6 +362,11 @@ module.exports = Any.extend({ method(limit) { return this.$_addRule({ name: 'max', method: 'length', args: { limit }, operator: '<=' }); + }, + jsonSchema(rule, res) { + + res.maxProperties = rule.args.limit; + return res; } }, @@ -299,6 +374,11 @@ module.exports = Any.extend({ method(limit) { return this.$_addRule({ name: 'min', method: 'length', args: { limit }, operator: '>=' }); + }, + jsonSchema(rule, res) { + + res.minProperties = rule.args.limit; + return res; } }, diff --git a/lib/types/link.js b/lib/types/link.js index aff90629..60e7e935 100755 --- a/lib/types/link.js +++ b/lib/types/link.js @@ -29,6 +29,29 @@ module.exports = Any.extend({ return schema.ref(ref); }, + jsonSchema(schema, res, mode, options) { + + if (!schema.$_terms.link) { + return res; + } + + const { ref } = schema.$_terms.link[0]; + + if (ref.ancestor === 'root' || ref.ancestor > 0) { + res.$ref = `#/${ref.path.map((p) => `properties/${p}`).join('/')}`; + return res; + } + + if (ref.path.length === 1) { + res.$ref = `#/$defs/${ref.path[0]}`; + } + else { + res.$ref = `#/${ref.path.slice(1).map((p) => `properties/${p}`).join('/')}`; + } + + return res; + }, + validate(value, { schema, state, prefs }) { assert(schema.$_terms.link, 'Uninitialized link schema'); diff --git a/lib/types/number.js b/lib/types/number.js index 728872fa..b79c1299 100755 --- a/lib/types/number.js +++ b/lib/types/number.js @@ -136,6 +136,11 @@ module.exports = Any.extend({ method(limit) { return this.$_addRule({ name: 'greater', method: 'compare', args: { limit }, operator: '>' }); + }, + jsonSchema(rule, res) { + + res.exclusiveMinimum = rule.args.limit; + return res; } }, @@ -151,6 +156,12 @@ module.exports = Any.extend({ } return helpers.error('number.integer'); + }, + jsonSchema(rule, res) { + + res.type = 'integer'; + + return res; } }, @@ -158,6 +169,11 @@ module.exports = Any.extend({ method(limit) { return this.$_addRule({ name: 'less', method: 'compare', args: { limit }, operator: '<' }); + }, + jsonSchema(rule, res) { + + res.exclusiveMaximum = rule.args.limit; + return res; } }, @@ -165,6 +181,11 @@ module.exports = Any.extend({ method(limit) { return this.$_addRule({ name: 'max', method: 'compare', args: { limit }, operator: '<=' }); + }, + jsonSchema(rule, res) { + + res.maximum = rule.args.limit; + return res; } }, @@ -172,6 +193,11 @@ module.exports = Any.extend({ method(limit) { return this.$_addRule({ name: 'min', method: 'compare', args: { limit }, operator: '>=' }); + }, + jsonSchema(rule, res) { + + res.minimum = rule.args.limit; + return res; } }, @@ -203,6 +229,11 @@ module.exports = Any.extend({ value : helpers.error('number.multiple', { multiple: options.args.base, value }); }, + jsonSchema(rule, res) { + + res.multipleOf = rule.args.base; + return res; + }, args: [ { name: 'base', @@ -284,6 +315,11 @@ module.exports = Any.extend({ } return helpers.error(`number.${sign}`); + }, + jsonSchema(rule, res) { + + res['x-constraint'] = { ...res['x-constraint'], sign: rule.args.sign }; + return res; } }, diff --git a/lib/types/string.js b/lib/types/string.js index b0d31953..56dfe620 100755 --- a/lib/types/string.js +++ b/lib/types/string.js @@ -144,6 +144,23 @@ 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; + }, + rules: { alphanum: { @@ -180,6 +197,11 @@ module.exports = Any.extend({ } return helpers.error('string.base64'); + }, + jsonSchema(rule, res) { + + res.format = 'base64'; + return res; } }, @@ -260,6 +282,11 @@ module.exports = Any.extend({ } return helpers.error('string.dataUri'); + }, + jsonSchema(rule, res) { + + res.format = 'data-uri'; + return res; } }, @@ -309,6 +336,11 @@ module.exports = Any.extend({ } return helpers.error('string.email', { value, invalids }); + }, + jsonSchema(rule, res) { + + res.format = 'email'; + return res; } }, @@ -398,6 +430,11 @@ module.exports = Any.extend({ } return value; + }, + jsonSchema(rule, res) { + + res.format = 'uuid'; + return res; } }, @@ -430,6 +467,11 @@ module.exports = Any.extend({ } return value; + }, + jsonSchema(rule, res) { + + res.format = 'hex'; + return res; } }, @@ -447,6 +489,11 @@ module.exports = Any.extend({ } return helpers.error('string.hostname'); + }, + jsonSchema(rule, res) { + + res.format = 'hostname'; + return res; } }, @@ -477,6 +524,18 @@ module.exports = Any.extend({ } return helpers.error('string.ip', { value, cidr: options.cidr }); + }, + jsonSchema(rule, res) { + + const version = rule.args.options.version; + if (version && version.length === 1) { + res.format = version[0]; + } + else { + res.format = 'ip'; + } + + return res; } }, @@ -492,6 +551,11 @@ module.exports = Any.extend({ } return error('string.isoDate'); + }, + jsonSchema(rule, res) { + + res.format = 'date-time'; + return res; } }, @@ -507,6 +571,11 @@ module.exports = Any.extend({ } return helpers.error('string.isoDuration'); + }, + jsonSchema(rule, res) { + + res.format = 'duration'; + return res; } }, @@ -524,6 +593,12 @@ module.exports = Any.extend({ return helpers.error('string.' + name, { limit: args.limit, value, encoding }); }, + jsonSchema(rule, res) { + + res.minLength = rule.args.limit; + res.maxLength = rule.args.limit; + return res; + }, args: [ { name: 'limit', @@ -547,6 +622,11 @@ module.exports = Any.extend({ return internals.length(this, 'max', limit, '<=', encoding); }, + jsonSchema(rule, res) { + + res.maxLength = rule.args.limit; + return res; + }, args: ['limit', 'encoding'] }, @@ -555,6 +635,15 @@ module.exports = Any.extend({ return internals.length(this, 'min', limit, '>=', encoding); }, + jsonSchema(rule, res) { + + if (rule.args.limit > 0) { + + res.minLength = rule.args.limit; + } + + return res; + }, args: ['limit', 'encoding'] }, @@ -602,6 +691,11 @@ module.exports = Any.extend({ return helpers.error(errorCode, { name: options.name, regex, value }); }, + jsonSchema(rule, res) { + + res.pattern = rule.args.regex.source; + return res; + }, args: ['regex', 'options'], multi: true }, @@ -639,6 +733,11 @@ module.exports = Any.extend({ } return helpers.error('string.token'); + }, + jsonSchema(rule, res) { + + res.format = 'token'; + return res; } }, @@ -728,6 +827,11 @@ module.exports = Any.extend({ } return helpers.error('string.uri'); + }, + jsonSchema(rule, res) { + + res.format = 'uri'; + return res; } } }, diff --git a/lib/types/symbol.js b/lib/types/symbol.js index 55764abd..a2cc6584 100755 --- a/lib/types/symbol.js +++ b/lib/types/symbol.js @@ -95,6 +95,18 @@ module.exports = Any.extend({ } }, + jsonSchema(schema, json, mode, options) { + + const map = schema.$_terms.map; + if (!map.size) { + return {}; + } + + return { + anyOf: Array.from(map.keys()).map((key) => ({ const: key })) + }; + }, + messages: { 'symbol.base': '{{#label}} must be a symbol', 'symbol.map': '{{#label}} must be one of {{#map}}' diff --git a/package.json b/package.json index e16647d1..dc0bced8 100755 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", - "@standard-schema/spec": "^1.0.0" + "@standard-schema/spec": "^1.1.0" }, "devDependencies": { "@hapi/bourne": "^3.0.0", @@ -36,6 +36,7 @@ "@hapi/joi-legacy-test": "npm:@hapi/joi@15.x.x", "@hapi/lab": "^26.0.0", "@types/node": "^20.17.47", + "ajv": "^8.18.0", "typescript": "^5.8.3" }, "scripts": { diff --git a/test/common.js b/test/common.js index 5648a847..7d0e98e4 100755 --- a/test/common.js +++ b/test/common.js @@ -22,4 +22,21 @@ describe('Common', () => { expect(() => Common.assertOptions()).to.throw('Options must be of type object'); }); }); + + describe('intersect', () => { + + it('intersects two sets', () => { + + const a = new Set([1, 2, 3]); + const b = new Set([2, 3, 4]); + expect(Common.intersect(a, b)).to.equal(new Set([2, 3])); + }); + + it('returns empty set when no intersection', () => { + + const a = new Set([1, 2]); + const b = new Set([3, 4]); + expect(Common.intersect(a, b)).to.equal(new Set()); + }); + }); }); diff --git a/test/helper.js b/test/helper.js old mode 100755 new mode 100644 index c4f608e5..a95acdd2 --- a/test/helper.js +++ b/test/helper.js @@ -1,7 +1,7 @@ 'use strict'; const Code = require('@hapi/code'); - +const Ajv = require('ajv/dist/2020'); const internals = {}; @@ -9,6 +9,30 @@ const internals = {}; const { expect } = Code; +internals.ajvValidator = new Ajv({ + 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 + }, + keywords: ['x-constraint', 'foo'], + strictTuples: true +}); + + exports.skip = Symbol('skip'); @@ -108,11 +132,31 @@ exports.validate = function (schema, prefs, tests) { }; +exports.validateJsonSchema = function (schema, expectedInput, expectedOutput) { + + try { + const js = schema['~standard'].jsonSchema; + const input = js.input(); + expect(input).to.equal(expectedInput); + internals.ajvValidator.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); + } + catch (err) { + console.error(err.stack); + err.at = internals.thrownAt(); // Adjust error location to test + throw err; + } +}; + internals.thrownAt = function () { const error = new Error(); const frame = error.stack.replace(error.toString(), '').split('\n').slice(1).filter((line) => !line.includes(__filename))[0]; - const at = frame.match(/^\s*at \(?(.+)\:(\d+)\:(\d+)\)?$/); + const at = frame.match(/^\s*at \(?(.+):(\d+):(\d+)\)?$/); return { filename: at[1], line: at[2], diff --git a/test/index.ts b/test/index.ts index 3c2a0cec..15675d7d 100644 --- a/test/index.ts +++ b/test/index.ts @@ -1481,4 +1481,15 @@ expect.error(Joi.string('x')); } expect.error(result.value) } + + { + // Standard JSON Schema + const schema = Joi.string().min(5); + const js = schema['~standard'].jsonSchema; + const input = js.input({ target: 'draft-2020-12' }); + const output = js.output({ target: 'draft-2020-12' }); + + expect.type>(input); + expect.type>(output); + } } diff --git a/test/json-schema.js b/test/json-schema.js new file mode 100644 index 00000000..d84ffab9 --- /dev/null +++ b/test/json-schema.js @@ -0,0 +1,1972 @@ +'use strict'; + +const Joi = require('..'); +const Code = require('@hapi/code'); +const Lab = require('@hapi/lab'); +const Helper = require('./helper'); + + +const { describe, it } = exports.lab = Lab.script(); +const expect = Code.expect; + + +describe('jsonSchema', () => { + + describe('any', () => { + + it('represents empty schema', () => { + + Helper.validateJsonSchema(Joi.any(), {}); + }); + + it('represents conditional schema as anyOf', () => { + + Helper.validateJsonSchema(Joi.any().when('a', { is: 1, then: Joi.string(), otherwise: Joi.number() }), { + anyOf: [ + { type: 'string', minLength: 1 }, + { type: 'number' } + ] + }); + }); + + it('represents conditional schema with only then as anyOf', () => { + + Helper.validateJsonSchema(Joi.any().when('a', { is: 1, then: Joi.string() }), { + anyOf: [ + { type: 'string', minLength: 1 }, + {} + ] + }); + }); + + it('represents conditional schema with only otherwise as anyOf', () => { + + Helper.validateJsonSchema(Joi.any().when('a', { is: 1, otherwise: Joi.number() }), { + anyOf: [ + { type: 'number' }, + {} + ] + }); + }); + + it('represents multiple conditional schemas as anyOf', () => { + + Helper.validateJsonSchema(Joi.any() + .when('a', { is: 1, then: Joi.string() }) + .when('b', { is: 2, then: Joi.number() }), { + anyOf: [ + { type: 'string', minLength: 1 }, + {}, + { type: 'number' } + ] + }); + }); + + it('represents conditional schema with switch as anyOf', () => { + + Helper.validateJsonSchema(Joi.any().when('a', { + switch: [ + { is: 1, then: Joi.string() }, + { is: 2, then: Joi.number() } + ], + otherwise: Joi.boolean() + }), { + anyOf: [ + { type: 'string', minLength: 1 }, + { type: 'number' }, + { type: 'boolean' } + ] + }); + }); + + it('represents nested conditional schema with only then as anyOf', () => { + + Helper.validateJsonSchema(Joi.any().description('test').when('a', { is: 1, then: Joi.string() }), { + anyOf: [ + { + type: 'string', + description: 'test', + minLength: 1 + }, + { + description: 'test' + } + ] + }); + }); + + it('represents conditional schema with Joi.any() then as anyOf', () => { + + Helper.validateJsonSchema(Joi.any().when('a', { is: 1, then: Joi.any() }), { + anyOf: [ + {} + ] + }); + }); + + it('represents conditional schema with string then as anyOf', () => { + + Helper.validateJsonSchema(Joi.any().when('a', { is: 1, then: Joi.string() }), { + anyOf: [ + { type: 'string', minLength: 1 }, + {} + ] + }); + }); + + it('represents conditional schema with string min length then as anyOf', () => { + + Helper.validateJsonSchema(Joi.string().when('a', { is: 1, then: Joi.string().min(5) }), { + anyOf: [ + { type: 'string', minLength: 5 }, + { type: 'string', minLength: 1 } + ] + }); + }); + + it('represents conditional schema with description and valids then as anyOf', () => { + + 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' } + ] + }); + }); + + it('represents conditional schema with number otherwise as anyOf', () => { + + Helper.validateJsonSchema(Joi.any().when('a', { is: 1, otherwise: Joi.number() }), { + anyOf: [ + { type: 'number' }, + {} + ] + }); + }); + + it('represents conditional schema with string valid then as anyOf', () => { + + 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'] } + ] + }); + }); + + it('flattens nested anyOf from multiple sources', () => { + + Helper.validateJsonSchema(Joi.string().allow(1).when('a', { is: 1, then: Joi.string().min(10) }), { + anyOf: [ + { + anyOf: [ + { type: 'string', minLength: 10 }, + { enum: [1] } + ] + }, + { + anyOf: [ + { type: 'string', minLength: 1 }, + { enum: [1] } + ] + } + ] + }); + }); + + it('represents conditional schema with then Joi.any() as anyOf', () => { + + Helper.validateJsonSchema(Joi.any().when('a', { is: 1, then: Joi.any(), otherwise: Joi.number() }), { + anyOf: [ + {}, + { type: 'number' } + ] + }); + }); + + it('represents conditional schema with otherwise Joi.any() as anyOf', () => { + + Helper.validateJsonSchema(Joi.any().when('a', { is: 1, then: Joi.number(), otherwise: Joi.any() }), { + anyOf: [ + { type: 'number' }, + {} + ] + }); + }); + + it('represents description', () => { + + Helper.validateJsonSchema(Joi.any().description('foo').default('bar').default(() => 'baz'), { + description: 'foo' + }); + }); + + it('represents description with null allowed', () => { + + Helper.validateJsonSchema(Joi.allow(null).description('foobar'), { + description: 'foobar' + }); + }); + + it('represents description with null and number allowed', () => { + + Helper.validateJsonSchema(Joi.allow(null, 1).description('foobar'), { + description: 'foobar' + }); + }); + + it('represents string with number allowed as anyOf', () => { + + Helper.validateJsonSchema(Joi.string().allow(1), { + anyOf: [ + { type: 'string', minLength: 1 }, + { enum: [1] } + ] + }); + }); + + it('represents description for string with number allowed as anyOf', () => { + + Helper.validateJsonSchema(Joi.string().description('foo').allow(1), { + anyOf: [ + { type: 'string', minLength: 1, description: 'foo' }, + { enum: [1] } + ] + }); + }); + + it('represents number only with allowed value', () => { + + Helper.validateJsonSchema(Joi.any().allow(1).only(), { + type: 'number', + enum: [1] + }); + }); + + it('represents number with valid value', () => { + + Helper.validateJsonSchema(Joi.any().valid(1), { + type: 'number', + enum: [1] + }); + }); + + it('represents null only', () => { + + Helper.validateJsonSchema(Joi.allow(null), {}); + }); + + it('represents any null only', () => { + + Helper.validateJsonSchema(Joi.any().allow(null), {}); + }); + + it('avoids duplicate types when merging null', () => { + + Helper.validateJsonSchema(Joi.string().allow(null), { type: ['string', 'null'], minLength: 1 }); + }); + + it('avoids duplicate types when merging null and valid', () => { + + Helper.validateJsonSchema(Joi.string().valid('a').allow(null), { type: ['string', 'null'], enum: ['a'] }); + }); + + it('represents valids with multiple types', () => { + + Helper.validateJsonSchema(Joi.valid('a', 'b'), { enum: ['a', 'b'], type: 'string' }); + }); + + it('represents mixed string and number valids', () => { + + Helper.validateJsonSchema(Joi.valid('a', 1), { type: ['string', 'number'], enum: ['a', 1] }); + }); + + it('represents boolean valids', () => { + + Helper.validateJsonSchema(Joi.valid(true, false), { type: 'boolean', enum: [true, false] }); + }); + + it('represents object valids', () => { + + Helper.validateJsonSchema(Joi.valid({}), { enum: [{}] }); + }); + + it('represents single number valid', () => { + + Helper.validateJsonSchema(Joi.any().valid(1), { type: 'number', enum: [1] }); + }); + + it('represents multi-type valids', () => { + + Helper.validateJsonSchema(Joi.valid('a', 1, true), { type: ['string', 'number', 'boolean'], enum: ['a', 1, true] }); + }); + + it('represents any with allowed multi-type values', () => { + + Helper.validateJsonSchema(Joi.any().allow(null, 'a', 1), {}); + }); + + it('represents any with allowed string and number', () => { + + Helper.validateJsonSchema(Joi.any().allow('a', 1), {}); + }); + + it('represents any with allowed string', () => { + + Helper.validateJsonSchema(Joi.any().allow('a'), {}); + }); + + it('represents string with allowed multi-type values', () => { + + Helper.validateJsonSchema(Joi.string().allow(null, 1), { + anyOf: [ + { type: 'null' }, + { type: 'string', minLength: 1 }, + { enum: [1] } + ] + }); + }); + + it('represents string with allowed null and min length', () => { + + Helper.validateJsonSchema(Joi.string().allow(null).min(5), { type: ['string', 'null'], minLength: 5 }); + }); + + it('represents inferred types for valids', () => { + + Helper.validateJsonSchema(Joi.compile('foo'), { enum: ['foo'], type: 'string' }); + }); + + it('represents multiple number valids', () => { + + Helper.validateJsonSchema(Joi.valid(1, 2), { type: 'number', enum: [1, 2] }); + }); + + it('represents single boolean valid', () => { + + Helper.validateJsonSchema(Joi.valid(true), { type: 'boolean', enum: [true] }); + }); + + it('represents inferred mixed string and number valids', () => { + + Helper.validateJsonSchema(Joi.valid('foo', 1), { type: ['string', 'number'], enum: ['foo', 1] }); + }); + + it('represents inferred mixed string and null valids', () => { + + Helper.validateJsonSchema(Joi.valid('foo', null), { type: ['string', 'null'], enum: ['foo'] }); + }); + + it('represents string schema with number valid as number type', () => { + + Helper.validateJsonSchema(Joi.string().valid(1), { type: 'number', enum: [1] }); + }); + + it('represents string schema with mixed number and string valids', () => { + + Helper.validateJsonSchema(Joi.string().valid(1, 'a'), { type: ['number', 'string'], enum: [1, 'a'] }); + }); + + it('represents string schema with min length and number valid', () => { + + Helper.validateJsonSchema(Joi.string().min(5).valid(1), { type: 'number', enum: [1] }); + }); + + it('represents string schema with min length and string valid', () => { + + Helper.validateJsonSchema(Joi.string().min(5).valid('abcde'), { type: 'string', enum: ['abcde'], minLength: 5 }); + }); + + it('represents string schema with ip and number valid', () => { + + Helper.validateJsonSchema(Joi.string().ip().valid(1), { type: 'number', enum: [1] }); + }); + + it('represents date schema with number valid', () => { + + Helper.validateJsonSchema(Joi.date().valid(1), { type: 'number', enum: [1] }); + }); + + it('represents date schema with min and number valid', () => { + + Helper.validateJsonSchema(Joi.date().min('2020-01-01').valid(1), { type: 'number', enum: [1] }); + }); + + it('represents null only valid', () => { + + Helper.validateJsonSchema(Joi.valid(null), { type: 'null' }); + }); + + it('represents string schema with null valid', () => { + + Helper.validateJsonSchema(Joi.string().valid(null), { type: 'null' }); + }); + + it('represents string schema with object valid', () => { + + Helper.validateJsonSchema(Joi.string().valid({}), { enum: [{}] }); + }); + + it('represents string schema with multiple number valids', () => { + + Helper.validateJsonSchema(Joi.string().valid(1, 2), { type: 'number', enum: [1, 2] }); + }); + + 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'] }); + }); + + it('represents string schema with mixed number and boolean valids', () => { + + Helper.validateJsonSchema(Joi.string().valid(1, true), { type: ['number', 'boolean'], enum: [1, true] }); + }); + + 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] }); + }); + + it('represents string schema with mixed number and boolean valids (reordered)', () => { + + Helper.validateJsonSchema(Joi.string().valid(1, true, 2), { type: ['number', 'boolean'], enum: [1, true, 2] }); + }); + + it('represents string schema with mixed number and object valids', () => { + + Helper.validateJsonSchema(Joi.string().valid(1, {}), { type: 'number', enum: [1, {}] }); + }); + + it('represents string schema with multiple numbers and object valid', () => { + + Helper.validateJsonSchema(Joi.string().valid(1, 2, {}), { type: 'number', enum: [1, 2, {}] }); + }); + + it('represents string only with number valid', () => { + + Helper.validateJsonSchema(Joi.string().valid(1), { type: 'number', enum: [1] }); + }); + + it('represents string only with allowed string', () => { + + Helper.validateJsonSchema(Joi.string().valid('a'), { type: 'string', enum: ['a'] }); + }); + + it('represents empty any valids', () => { + + Helper.validateJsonSchema(Joi.any().valid(), {}); + }); + + it('represents empty any allowed', () => { + + Helper.validateJsonSchema(Joi.any().allow(), {}); + }); + + it('represents any only with null allowed', () => { + + Helper.validateJsonSchema(Joi.any().allow(null).only(), { type: 'null' }); + }); + + it('represents null compile only', () => { + + Helper.validateJsonSchema(Joi.compile(null).only(), { type: 'null' }); + }); + + it('represents valids with objects', () => { + + Helper.validateJsonSchema(Joi.valid({}), { enum: [{}] }); + }); + + it('represents valids with string and object', () => { + + Helper.validateJsonSchema(Joi.any().valid('a', {}), { type: 'string', enum: ['a', {}] }); + }); + + it('represents valids with string, number and object', () => { + + Helper.validateJsonSchema(Joi.any().valid('a', 1, {}), { type: ['string', 'number'], enum: ['a', 1, {}] }); + }); + }); + + 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' } + ] + }); + }); + + it('represents alternatives with otherwise only as anyOf', () => { + + Helper.validateJsonSchema(Joi.alternatives().conditional('a', { is: 1, otherwise: Joi.number() }), { + anyOf: [ + { type: 'number' } + ] + }); + }); + + it('represents alternatives with then Joi.any() as anyOf', () => { + + Helper.validateJsonSchema(Joi.alternatives().conditional('a', { is: 1, then: Joi.any(), otherwise: Joi.number() }), { + anyOf: [ + {}, + { type: 'number' } + ] + }); + }); + + it('represents alternatives with otherwise Joi.any() as anyOf', () => { + + Helper.validateJsonSchema(Joi.alternatives().conditional('a', { is: 1, then: Joi.number(), otherwise: Joi.any() }), { + anyOf: [ + { type: 'number' }, + {} + ] + }); + }); + + it('represents alternatives with when (not conditional) as anyOf', () => { + + Helper.validateJsonSchema(Joi.alternatives().when('a', { is: 1, then: Joi.alternatives().try(Joi.number()) }), { + anyOf: [ + { + anyOf: [ + { + type: 'number' + } + ] + }, + {} + ] + }); + }); + + it('represents alternatives try and when with description as anyOf', () => { + + Helper.validateJsonSchema(Joi.alternatives().try(Joi.boolean()).when('a', { is: 1, then: Joi.any().description('foo') }).description('bar'), { + anyOf: [ + { + description: 'foo', + anyOf: [ + { type: 'boolean' } + ] + }, + { + description: 'bar', + anyOf: [ + { type: 'boolean' } + ] + } + ] + }); + }); + + it('represents alternatives with when otherwise description as anyOf', () => { + + Helper.validateJsonSchema(Joi.alternatives().when('a', { is: 1, otherwise: Joi.any().description('a') }), { + anyOf: [ + { description: 'a' }, + {} + ] + }); + }); + + it('represents alternatives with when switch description as anyOf', () => { + + Helper.validateJsonSchema(Joi.alternatives().when('a', { switch: [{ is: 1, then: Joi.any().description('b') }] }), { + anyOf: [ + { description: 'b' }, + {} + ] + }); + }); + + it('flattens nested anyOf from try and when', () => { + + Helper.validateJsonSchema(Joi.alternatives() + .try(Joi.boolean()) + .conditional('a', { is: 1, then: Joi.string() }), { + anyOf: [ + { type: 'boolean' }, + { type: 'string', minLength: 1 } + ] + }); + }); + + it('represents match one as oneOf', () => { + + Helper.validateJsonSchema(Joi.alternatives().try(Joi.string(), Joi.number()).match('one'), { oneOf: [{ type: 'string', minLength: 1 }, { type: 'number' }] }); + }); + + it('represents empty schema when no alternatives provided', () => { + + Helper.validateJsonSchema(Joi.alternatives(), {}); + }); + + it('represents alternatives with ref conditional', () => { + + Helper.validateJsonSchema(Joi.alternatives().conditional(Joi.ref('a'), { is: 1, then: Joi.string() }), { + anyOf: [ + { type: 'string', minLength: 1 } + ] + }); + }); + + it('represents anyOf with description', () => { + + Helper.validateJsonSchema(Joi.alternatives().try(Joi.string(), Joi.any().description('foo')), { anyOf: [{ type: 'string', minLength: 1 }, { description: 'foo' }] }); + }); + + it('represents anyOf with single description', () => { + + Helper.validateJsonSchema(Joi.alternatives().try(Joi.any().description('foo')), { anyOf: [{ description: 'foo' }] }); + }); + + it('represents anyOf with allowed values', () => { + + Helper.validateJsonSchema(Joi.alternatives().try(Joi.string()).allow(1), { + anyOf: [ + { type: 'string', minLength: 1 }, + { enum: [1] } + ] + }); + }); + + it('represents anyOf with allowed null', () => { + + Helper.validateJsonSchema(Joi.alternatives().try(Joi.string()).allow(null), { + anyOf: [ + { type: 'null' }, + { type: 'string', minLength: 1 } + ] + }); + }); + }); + + describe('array', () => { + + it('represents basic array', () => { + + Helper.validateJsonSchema(Joi.array().min(1).max(5).unique(), { + type: 'array', + minItems: 1, + maxItems: 5, + uniqueItems: true + }); + + Helper.validateJsonSchema(Joi.array().min(0).max(0), { + type: 'array', + minItems: 0, + maxItems: 0 + }); + }); + + it('represents array without constraints when valid is used (min)', () => { + + Helper.validateJsonSchema(Joi.array().min(1).valid(true), { enum: [true], type: 'boolean' }); + }); + + it('represents array without constraints when valid is used (max)', () => { + + Helper.validateJsonSchema(Joi.array().max(1).valid(true), { enum: [true], type: 'boolean' }); + }); + + it('represents array without constraints when valid is used (length)', () => { + + Helper.validateJsonSchema(Joi.array().length(1).valid(true), { enum: [true], type: 'boolean' }); + }); + + it('represents array without constraints when valid is used (unique)', () => { + + Helper.validateJsonSchema(Joi.array().unique().valid(true), { enum: [true], type: 'boolean' }); + }); + + it('represents nullable array (min)', () => { + + Helper.validateJsonSchema(Joi.array().items(Joi.string()).min(2).allow(null), { + type: ['array', 'null'], + items: { type: 'string', minLength: 1 }, + minItems: 2 + }); + }); + + it('represents nullable array (max)', () => { + + Helper.validateJsonSchema(Joi.array().items(Joi.string()).max(2).allow(null), { + type: ['array', 'null'], + items: { type: 'string', minLength: 1 }, + maxItems: 2 + }); + }); + + it('represents nullable array (length)', () => { + + Helper.validateJsonSchema(Joi.array().items(Joi.string()).length(2).allow(null), { + type: ['array', 'null'], + items: { type: 'string', minLength: 1 }, + minItems: 2, + maxItems: 2 + }); + }); + + it('represents nullable array (unique)', () => { + + Helper.validateJsonSchema(Joi.array().items(Joi.string()).unique().allow(null), { + type: ['array', 'null'], + items: { type: 'string', minLength: 1 }, + uniqueItems: true + }); + }); + + it('represents array with multiple items types', () => { + + Helper.validateJsonSchema(Joi.array().items(Joi.string(), Joi.number()), { + type: 'array', + items: { + anyOf: [ + { type: 'string', minLength: 1 }, + { type: 'number' } + ] + } + }); + }); + + it('represents array with contains', () => { + + Helper.validateJsonSchema(Joi.array().has(Joi.string()), { + type: 'array', + contains: { + type: 'string', + minLength: 1 + } + }); + }); + + it('represents array with multiple contains', () => { + + Helper.validateJsonSchema(Joi.array().has(Joi.string()).has(Joi.number()), { + type: 'array', + allOf: [ + { + contains: { + type: 'string', + minLength: 1 + } + }, + { + contains: { + type: 'number' + } + } + ] + }); + }); + + it('represents array with prefixItems', () => { + + Helper.validateJsonSchema(Joi.array().ordered(Joi.string(), Joi.number()), { + type: 'array', + prefixItems: [ + { type: 'string', minLength: 1 }, + { type: 'number' } + ], + unevaluatedItems: false, + minItems: 2, + maxItems: 2 + }); + }); + + it('represents array with prefixItems and items', () => { + + expect((Joi.array().ordered(Joi.string()).items(Joi.number()))['~standard'].jsonSchema.input()).to.equal({ + type: 'array', + prefixItems: [ + { type: 'string', minLength: 1 } + ], + unevaluatedItems: { type: 'number' }, + minItems: 1 + }); + + expect((Joi.array().ordered(Joi.string()).items(Joi.number(), Joi.boolean()))['~standard'].jsonSchema.input()).to.equal({ + type: 'array', + prefixItems: [ + { type: 'string', minLength: 1 } + ], + unevaluatedItems: { + anyOf: [ + { type: 'number' }, + { type: 'boolean' } + ] + }, + minItems: 1 + }); + }); + + it('represents array with no items rule', () => { + + Helper.validateJsonSchema(Joi.array().items(), { type: 'array' }); + }); + + it('represents array with constraints', () => { + + Helper.validateJsonSchema(Joi.array().min(1).max(10).length(5).unique(), { type: 'array', minItems: 5, maxItems: 5, uniqueItems: true }); + }); + + it('omits items: false when unevaluatedItems is used', () => { + + Helper.validateJsonSchema(Joi.array().ordered(Joi.string()), { + type: 'array', + prefixItems: [{ type: 'string', minLength: 1 }], + unevaluatedItems: false, + minItems: 1, + maxItems: 1 + }); + }); + + it('represents array with single()', () => { + + Helper.validateJsonSchema(Joi.array().items(Joi.string()).single(), { + anyOf: [ + { + type: 'array', + items: { type: 'string', minLength: 1 } + }, + { type: 'string', minLength: 1 } + ] + }); + }); + + it('represents array with single() and multiple items', () => { + + Helper.validateJsonSchema(Joi.array().items(Joi.string(), Joi.number()).single(), { + anyOf: [ + { + type: 'array', + items: { + anyOf: [ + { type: 'string', minLength: 1 }, + { type: 'number' } + ] + } + }, + { + anyOf: [ + { type: 'string', minLength: 1 }, + { type: 'number' } + ] + } + ] + }); + }); + + + it('represents array with single() and items (for coverage)', () => { + + const schema = Joi.array().items(Joi.string()).single(); + const json = schema.$_jsonSchema(); + Code.expect(json).to.equal({ + anyOf: [ + { + type: 'array', + items: { type: 'string', minLength: 1 } + }, + { type: 'string', minLength: 1 } + ] + }); + }); + + it('represents array with single() and no items/ordered', () => { + + Helper.validateJsonSchema(Joi.array().single(), { + type: 'array' + }); + }); + }); + + describe('binary', () => { + + it('represents basic binary', () => { + + Helper.validateJsonSchema(Joi.binary().min(10).max(100), { + type: 'string', + format: 'binary', + minLength: 10, + maxLength: 100 + }); + }); + + it('represents binary with constraints', () => { + + Helper.validateJsonSchema(Joi.binary().min(1).max(10).length(5).custom(() => {}), { type: 'string', format: 'binary', minLength: 5, maxLength: 5 }); + }); + }); + + describe('boolean', () => { + + it('represents basic boolean', () => { + + Helper.validateJsonSchema(Joi.boolean(), { type: 'boolean' }); + }); + }); + + describe('date', () => { + + it('represents basic date', () => { + + const expected = { format: 'date-time', type: 'string' }; + Helper.validateJsonSchema(Joi.date(), expected); + Helper.validateJsonSchema(Joi.date().iso(), expected); + }); + + it('represents date with constraints', () => { + + const d1 = new Date(1741708800000); + const d2 = new Date(1741795200000); + + Helper.validateJsonSchema(Joi.date().min(d1).max(d2).greater(d1).less(d2), { + type: 'string', + format: 'date-time', + 'x-constraint': { + min: d1.toISOString(), + max: d2.toISOString(), + greater: d1.toISOString(), + less: d2.toISOString() + } + }); + + Helper.validateJsonSchema(Joi.date().min(d1).max(d2).greater(d1).less(d2).only(), { + type: 'string', + format: 'date-time', + 'x-constraint': { + greater: d1.toISOString(), + less: d2.toISOString(), + min: d1.toISOString(), + max: d2.toISOString() + } + }); + + Helper.validateJsonSchema(Joi.date().min('now').max('now').greater('now').less('now'), { + type: 'string', + format: 'date-time' + }); + }); + + it('represents date with valid rule', () => { + + Helper.validateJsonSchema(Joi.date().valid(new Date(1741708800000)), { + type: 'string', + format: 'date-time', + enum: [new Date(1741708800000)] + }); + }); + }); + + describe('number', () => { + + it('represents basic number', () => { + + Helper.validateJsonSchema(Joi.number().integer().min(1).max(100).multiple(5), { + type: 'integer', + minimum: 1, + maximum: 100, + multipleOf: 5 + }); + + Helper.validateJsonSchema(Joi.number().integer().only(), { + type: 'integer' + }); + + Helper.validateJsonSchema(Joi.number().multiple(5), { + type: 'number', + multipleOf: 5 + }); + + Helper.validateJsonSchema(Joi.number().min(0).max(0), { + type: 'number', + minimum: 0, + maximum: 0 + }); + }); + + it('represents number with exclusive constraints', () => { + + Helper.validateJsonSchema(Joi.number().greater(1).less(100), { + type: 'number', + exclusiveMinimum: 1, + exclusiveMaximum: 100 + }); + }); + + it('represents number with positive/negative constraints', () => { + + Helper.validateJsonSchema(Joi.number().positive(), { + type: 'number', + 'x-constraint': { + sign: 'positive' + } + }); + + Helper.validateJsonSchema(Joi.number().positive().only(), { + type: 'number', + 'x-constraint': { + sign: 'positive' + } + }); + + Helper.validateJsonSchema(Joi.number().negative(), { + type: 'number', + 'x-constraint': { + sign: 'negative' + } + }); + + Helper.validateJsonSchema(Joi.number().positive().multiple(3), { + type: 'number', + multipleOf: 3, + 'x-constraint': { + sign: 'positive' + } + }); + + Helper.validateJsonSchema(Joi.number().multiple(3).positive(), { + type: 'number', + multipleOf: 3, + 'x-constraint': { + sign: 'positive' + } + }); + }); + + it('represents number with valids', () => { + + Helper.validateJsonSchema(Joi.number().valid(1, 2, 3), { + type: 'number', + enum: [1, 2, 3] + }); + + Helper.validateJsonSchema(Joi.number().min(10).valid(10, 11), { + type: 'number', + enum: [10, 11], + minimum: 10 + }); + + Helper.validateJsonSchema(Joi.number().max(10).valid(9, 10), { + type: 'number', + enum: [9, 10], + maximum: 10 + }); + + Helper.validateJsonSchema(Joi.number().greater(10).valid(11, 12), { + type: 'number', + enum: [11, 12], + exclusiveMinimum: 10 + }); + + Helper.validateJsonSchema(Joi.number().less(10).valid(8, 9), { + type: 'number', + enum: [8, 9], + exclusiveMaximum: 10 + }); + + Helper.validateJsonSchema(Joi.number().multiple(5).valid(5, 10), { + type: 'number', + enum: [5, 10], + multipleOf: 5 + }); + }); + }); + + describe('object', () => { + + it('represents basic object', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string().required(), + b: Joi.number() + }).unknown(false), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { type: 'number' } + }, + required: ['a'], + additionalProperties: false + }); + }); + + it('represents object with pattern properties', () => { + + Helper.validateJsonSchema(Joi.object().pattern(/^s/, Joi.string()), { + type: 'object', + patternProperties: { + '^s': { type: 'string', minLength: 1 } + }, + additionalProperties: false + }); + + Helper.validateJsonSchema(Joi.object().pattern(Joi.any(), Joi.string()), { + type: 'object', + additionalProperties: { type: 'string', minLength: 1 } + }); + + Helper.validateJsonSchema(Joi.object().pattern(/^s/, Joi.string()).unknown(false), { + type: 'object', + patternProperties: { + '^s': { type: 'string', minLength: 1 } + }, + additionalProperties: false + }); + + Helper.validateJsonSchema(Joi.object() + .pattern(/^s/, Joi.string()) + .pattern(/^n/, Joi.number()), { + type: 'object', + patternProperties: { + '^s': { type: 'string', minLength: 1 }, + '^n': { type: 'number' } + }, + additionalProperties: false + }); + + Helper.validateJsonSchema(Joi.object().pattern(Joi.string().min(5), Joi.number()), { + type: 'object', + patternProperties: { + '.*': { type: 'number' } + }, + additionalProperties: false + }); + + const customJoi = Joi.extend({ + type: 'customAny', + base: Joi.any() + }); + + Helper.validateJsonSchema(Joi.object().pattern(customJoi.customAny(), Joi.number()), { + type: 'object', + patternProperties: { + '.*': { type: 'number' } + }, + additionalProperties: false + }); + }); + + it('represents object with property constraints', () => { + + Helper.validateJsonSchema(Joi.object().min(2), { + type: 'object', + minProperties: 2 + }); + + Helper.validateJsonSchema(Joi.object().max(5), { + type: 'object', + maxProperties: 5 + }); + + Helper.validateJsonSchema(Joi.object().length(3), { + type: 'object', + minProperties: 3, + maxProperties: 3 + }); + + Helper.validateJsonSchema(Joi.object().min(2).max(5), { + type: 'object', + minProperties: 2, + maxProperties: 5 + }); + + Helper.validateJsonSchema(Joi.object().min(0).max(0), { + type: 'object', + minProperties: 0, + maxProperties: 0 + }); + + Helper.validateJsonSchema(Joi.object().min(2).max(5).only(), { + type: 'object', + additionalProperties: false, + minProperties: 2, + maxProperties: 5 + }); + + Helper.validateJsonSchema(Joi.object().length(3).only(), { + type: 'object', + additionalProperties: false, + minProperties: 3, + maxProperties: 3 + }); + }); + + it('sorts required properties in objects', () => { + + Helper.validateJsonSchema(Joi.object({ + z: Joi.string().required(), + a: Joi.string().required() + }), { + type: 'object', + properties: { + z: { type: 'string', minLength: 1 }, + a: { type: 'string', minLength: 1 } + }, + required: ['a', 'z'], + additionalProperties: false + }); + }); + + 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', () => { + + 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', + 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(''), { + type: 'string' + }); + + 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'], + 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'], + pattern: 'abc' + }); + + Helper.validateJsonSchema(Joi.string().email().valid('a@b.com'), { + type: 'string', + enum: ['a@b.com'], + format: 'email' + }); + + Helper.validateJsonSchema(Joi.string().guid().valid('550e8400-e29b-41d4-a716-446655440000'), { + type: 'string', + enum: ['550e8400-e29b-41d4-a716-446655440000'], + format: 'uuid' + }); + + Helper.validateJsonSchema(Joi.string().ip().valid('127.0.0.1'), { + type: 'string', + format: 'ip', + enum: ['127.0.0.1'] + }); + + Helper.validateJsonSchema(Joi.string().valid('a'), { enum: ['a'], type: 'string' }); + }); + + it('represents string with formats', () => { + + Helper.validateJsonSchema(Joi.string().length(5).pattern(/foo/).email().hostname().uri().uuid().guid(), { + type: 'string', + minLength: 5, + maxLength: 5, + pattern: 'foo', + format: 'uuid' + }); + + Helper.validateJsonSchema(Joi.string().ip(), { type: 'string', minLength: 1, format: 'ip' }); + Helper.validateJsonSchema(Joi.string().ip({ version: 'ipv4' }), { type: 'string', minLength: 1, format: 'ipv4' }); + Helper.validateJsonSchema(Joi.string().ip({ version: ['ipv4', 'ipv6'] }), { type: 'string', minLength: 1, format: 'ip' }); + Helper.validateJsonSchema(Joi.string().base64(), { type: 'string', minLength: 1, format: 'base64' }); + Helper.validateJsonSchema(Joi.string().dataUri(), { type: 'string', minLength: 1, format: 'data-uri' }); + Helper.validateJsonSchema(Joi.string().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' }); + }); + + it('represents string with various options', () => { + + 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 }); + + Helper.validateJsonSchema(Joi.string().allow(1), { + anyOf: [ + { type: 'string', minLength: 1 }, + { enum: [1] } + ] + }); + + 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'); + + 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 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' } + ] + }); + }); + }); + + 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 + }); + }); + + it('represents nested array with items', () => { + + const schema = Joi.object({ + a: Joi.array().items( + Joi.object({ + b: Joi.string().required() + }) + ) + }); + + 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 complex alternatives with multiple conditions', () => { + + const schema = Joi.any().when('a', { is: 1, then: Joi.string() }).when('a', { is: 2, then: Joi.number() }); + + Helper.validateJsonSchema(schema, { + anyOf: [ + { type: 'string', minLength: 1 }, + {}, + { type: 'number' } + ] + }); + }); + + it('represents complex alternatives with multiple conditions on object', () => { + + const schema = Joi.object({ + a: Joi.any(), + b: Joi.any() + .when('a', { is: 1, then: Joi.string() }) + .when('a', { is: 2, then: Joi.number() }) + }); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + a: {}, + b: { + anyOf: [ + { type: 'string', minLength: 1 }, + {}, + { type: 'number' } + ] + } + }, + additionalProperties: false + }); + }); + }); + + describe('symbol', () => { + + it('represents empty schema for Joi.symbol()', () => { + + Helper.validateJsonSchema(Joi.symbol(), {}); + }); + + it('represents anyOf for Joi.symbol().map()', () => { + + const s1 = Symbol('1'); + const s2 = Symbol('2'); + Helper.validateJsonSchema(Joi.symbol().map({ a: s1, b: s2 }), { + anyOf: [ + { const: 'a' }, + { const: 'b' } + ] + }); + }); + + it('represents anyOf for Joi.symbol().map() with mixed keys', () => { + + const s1 = Symbol('1'); + const s2 = Symbol('2'); + Helper.validateJsonSchema(Joi.symbol().map([[1, s1], [true, s2]]), { + anyOf: [ + { const: 1 }, + { const: true } + ] + }); + }); + }); + + describe('link', () => { + + it('represents Joi.link() to a local schema', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.link('#type.a') + }).id('type'), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { $ref: '#/properties/a' } + }, + additionalProperties: false + }); + }); + + it('represents Joi.link() to a root schema', () => { + + Helper.validateJsonSchema(Joi.object({ + a: Joi.string(), + b: Joi.link('a') + }), { + type: 'object', + properties: { + a: { type: 'string', minLength: 1 }, + b: { + $ref: '#/properties/a' + } + }, + additionalProperties: false + }); + }); + + it('represents Joi.link() with shared schema', () => { + + const shared = Joi.number().id('shared'); + const schema = Joi.object({ + a: Joi.link('#shared') + }).shared(shared); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + a: { $ref: '#/$defs/shared' } + }, + additionalProperties: false, + $defs: { + shared: { type: 'number' } + } + }); + }); + + it('represents Joi.link() with nested shared schema', () => { + + const shared = Joi.number().id('shared'); + const nestedShared = Joi.boolean().id('nestedShared'); + const schema = Joi.object({ + a: Joi.link('#shared'), + b: Joi.object({ + c: Joi.link('#nestedShared') + }).shared(nestedShared) + }).shared(shared); + + Helper.validateJsonSchema(schema, { + type: 'object', + properties: { + a: { $ref: '#/$defs/shared' }, + b: { + type: 'object', + properties: { + c: { $ref: '#/$defs/nestedShared' } + }, + additionalProperties: false + } + }, + additionalProperties: false, + $defs: { + shared: { type: 'number' }, + nestedShared: { type: 'boolean' } + } + }); + }); + + it('represents Joi.link() to root anchored ref', () => { + + Helper.validateJsonSchema(Joi.object({ + a: [Joi.string(), Joi.number()], + b: { + c: Joi.link('/a') + } + }), { + type: 'object', + properties: { + a: { + anyOf: [ + { type: 'string', minLength: 1 }, + { type: 'number' } + ] + }, + b: { + type: 'object', + properties: { + c: { + $ref: '#/properties/a' + } + }, + additionalProperties: false + } + }, + additionalProperties: false + }); + }); + + it('represents Joi.link() with uninitialized schema', () => { + + Helper.validateJsonSchema(Joi.link(), {}); + }); + }); +});