Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
173 changes: 172 additions & 1 deletion lib/base.js
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1116,6 +1283,10 @@ internals.Base = class {
}

return mapToStandardError(result.error);
},
jsonSchema: {
input: (options) => this.$_jsonSchema('input', options),
output: (options) => this.$_jsonSchema('output', options)
}
};
}
Expand Down
19 changes: 19 additions & 0 deletions lib/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions lib/extend.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 9 additions & 2 deletions lib/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -1003,7 +1003,14 @@ declare namespace Joi {

interface AnySchema<TSchema = any>
extends SchemaInternals,
StandardSchemaV1<TSchema> {
StandardSchemaV1<any, TSchema>,
StandardJSONSchemaV1<any, TSchema> {
/**
* The Standard properties.
*/
readonly "~standard": StandardSchemaV1.Props<any, TSchema> &
StandardJSONSchemaV1.Props<any, TSchema>;

/**
* Flags of current schema.
*/
Expand Down
4 changes: 3 additions & 1 deletion lib/schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
});


Expand Down Expand Up @@ -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(/.+/, [
Expand Down
43 changes: 43 additions & 0 deletions lib/types/alternatives.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading
Loading