joi 18.0.2 → 18.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/base.js CHANGED
@@ -16,7 +16,12 @@ const Validator = require('./validator');
16
16
  const Values = require('./values');
17
17
 
18
18
 
19
- const internals = {};
19
+ const internals = {
20
+ standardTypes: new Set(['string', 'number', 'integer', 'boolean', 'object', 'array', 'null']),
21
+ jsonSchemaTarget: 'draft-2020-12',
22
+ primitiveTypes: new Set(['string', 'number', 'boolean']),
23
+ nullSchema: () => ({ type: 'null' })
24
+ };
20
25
 
21
26
 
22
27
  internals.Base = class {
@@ -62,6 +67,168 @@ internals.Base = class {
62
67
  return Manifest.describe(this);
63
68
  }
64
69
 
70
+ $_jsonSchema(mode, options = {}) {
71
+
72
+ if (options.target !== undefined &&
73
+ options.target !== internals.jsonSchemaTarget) {
74
+
75
+ throw new Error(`Unsupported JSON Schema target: ${options.target}`);
76
+ }
77
+
78
+ const rootCall = !options.$defs;
79
+ const defs = options.$defs ?? {};
80
+
81
+ let schema = {};
82
+
83
+ const isTypeAny = this.type === 'any';
84
+ const isOnly = this._flags.only;
85
+
86
+ const valids = this._valids && Array.from(this._valids._values).filter((v) => v !== null);
87
+ let typesOverlap = true;
88
+
89
+ // If 'only' is set, check if the allowed values' types overlap with the schema type
90
+
91
+ if (valids && valids.length && isOnly && !isTypeAny) {
92
+ const types = new Set(valids.map((v) => typeof v));
93
+ typesOverlap = types.has(this.type) || (this.type === 'date' && types.has('object'));
94
+ }
95
+
96
+ // Set the JSON Schema 'type' if it's a standard type and there's an overlap
97
+
98
+ if (!isTypeAny && typesOverlap && internals.standardTypes.has(this.type)) {
99
+ schema.type = this.type;
100
+ }
101
+
102
+ if (this._flags.description) {
103
+ schema.description = this._flags.description;
104
+ }
105
+
106
+ if (this._flags.default !== undefined && typeof this._flags.default !== 'function') {
107
+ schema.default = this._flags.default;
108
+ }
109
+
110
+ // Apply type-specific JSON Schema conversion
111
+
112
+ const subOptions = { ...options, $defs: defs };
113
+ if (this._definition.jsonSchema && typesOverlap) {
114
+ schema = this._definition.jsonSchema(this, schema, mode, subOptions);
115
+ }
116
+
117
+ // Apply rule-specific JSON Schema conversions
118
+
119
+ for (const rule of this._rules) {
120
+ const definition = this._definition.rules[rule.name];
121
+ if (definition.jsonSchema && typesOverlap) {
122
+ schema = definition.jsonSchema(rule, schema, isOnly, mode, subOptions);
123
+ }
124
+ }
125
+
126
+ // Handle shared schemas
127
+
128
+ if (this.$_terms.shared) {
129
+ for (const shared of this.$_terms.shared) {
130
+ defs[shared._flags.id] = shared.$_jsonSchema(mode, subOptions);
131
+ }
132
+ }
133
+
134
+ if (rootCall && Object.keys(defs).length) {
135
+ schema.$defs = defs;
136
+ }
137
+
138
+ // Handle allowed values (valids)
139
+
140
+ if (this._valids) {
141
+
142
+ const values = valids.filter((v) => typeof v !== 'symbol');
143
+ if (values.length) {
144
+ if (this._flags.only) {
145
+ schema.enum = values;
146
+
147
+ const list = Common.intersect(new Set(values.map((v) => typeof v)), internals.primitiveTypes);
148
+
149
+ if (list.size) {
150
+ const types = [...list];
151
+ schema.type = types.length === 1 ? types[0] : types;
152
+ }
153
+ }
154
+ else {
155
+ // If values are allowed but not exclusive, add them via 'anyOf' if they differ from the main type
156
+
157
+ const otherTypes = values.filter((v) => typeof v !== this.type || isTypeAny);
158
+ if (otherTypes.length && !(isTypeAny && !isOnly)) {
159
+ if (!schema.anyOf) {
160
+ schema = {
161
+ anyOf: [schema]
162
+ };
163
+ }
164
+
165
+ schema.anyOf.push({ enum: otherTypes });
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ // Handle 'null' if it's an allowed value
172
+
173
+ if (this._valids && this._valids.has(null) && !(isTypeAny && !isOnly)) {
174
+ if (this._valids.length === 1 && (isTypeAny || isOnly)) {
175
+ schema.type = 'null';
176
+ }
177
+ else if (schema.type) {
178
+ schema.type = [schema.type, 'null'];
179
+ }
180
+ else if (schema.anyOf) {
181
+ schema.anyOf.unshift(internals.nullSchema());
182
+ }
183
+ else {
184
+ schema = {
185
+ anyOf: [
186
+ internals.nullSchema(),
187
+ schema
188
+ ]
189
+ };
190
+ }
191
+ }
192
+
193
+ // Handle conditionals (whens) by generating multiple possible schemas combined with 'anyOf'
194
+
195
+ if (this.$_terms.whens) {
196
+
197
+ const base = this.clone();
198
+ base.$_terms.whens = null;
199
+
200
+ const matches = [];
201
+ for (const when of this.$_terms.whens) {
202
+ const tests = when.is ? [when] : when.switch;
203
+ for (let i = 0; i < tests.length; ++i) {
204
+ const test = tests[i];
205
+ if (test.then) {
206
+ matches.push(base.concat(test.then).$_jsonSchema(mode, subOptions));
207
+ }
208
+
209
+ if (test.otherwise) {
210
+ matches.push(base.concat(test.otherwise).$_jsonSchema(mode, subOptions));
211
+ }
212
+
213
+ if (!test.then || (i === tests.length - 1 && !test.otherwise)) {
214
+ matches.push(base.$_jsonSchema(mode, subOptions));
215
+ }
216
+ }
217
+ }
218
+
219
+ const results = [];
220
+ for (const match of matches) {
221
+ if (!results.some((r) => deepEqual(r, match))) {
222
+ results.push(match);
223
+ }
224
+ }
225
+
226
+ return { anyOf: results };
227
+ }
228
+
229
+ return schema;
230
+ }
231
+
65
232
  // Rules
66
233
 
67
234
  allow(...values) {
@@ -1116,6 +1283,10 @@ internals.Base = class {
1116
1283
  }
1117
1284
 
1118
1285
  return mapToStandardError(result.error);
1286
+ },
1287
+ jsonSchema: {
1288
+ input: (options) => this.$_jsonSchema('input', options),
1289
+ output: (options) => this.$_jsonSchema('output', options)
1119
1290
  }
1120
1291
  };
1121
1292
  }
package/lib/common.js CHANGED
@@ -99,6 +99,25 @@ exports.default = function (value, defaultValue) {
99
99
  };
100
100
 
101
101
 
102
+ exports.intersect = function (set, other) {
103
+
104
+ /* $lab:coverage:off$ */
105
+ if (typeof set.intersection === 'function') {
106
+ return set.intersection(other);
107
+ }
108
+
109
+ const result = new Set();
110
+ for (const item of set) {
111
+ if (other.has(item)) {
112
+ result.add(item);
113
+ }
114
+ }
115
+
116
+ return result;
117
+ /* $lab:coverage:on$ */
118
+ };
119
+
120
+
102
121
  exports.isIsoDate = function (date) {
103
122
 
104
123
  return internals.isoDate.test(date);
package/lib/extend.js CHANGED
@@ -129,6 +129,12 @@ exports.type = function (from, options) {
129
129
 
130
130
  def.rules = rules;
131
131
 
132
+ // JSON Schema
133
+
134
+ if (!def.jsonSchema) {
135
+ def.jsonSchema = parent.jsonSchema;
136
+ }
137
+
132
138
  // Modifiers
133
139
 
134
140
  const modifiers = Object.assign({}, parent.modifiers);
package/lib/index.d.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  // TypeScript Version: 2.8
9
9
 
10
10
  // TODO express type of Schema in a type-parameter (.default, .valid, .example etc)
11
- import type { StandardSchemaV1 } from "@standard-schema/spec";
11
+ import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec";
12
12
 
13
13
  declare namespace Joi {
14
14
  type Types =
@@ -1003,7 +1003,14 @@ declare namespace Joi {
1003
1003
 
1004
1004
  interface AnySchema<TSchema = any>
1005
1005
  extends SchemaInternals,
1006
- StandardSchemaV1<TSchema> {
1006
+ StandardSchemaV1<any, TSchema>,
1007
+ StandardJSONSchemaV1<any, TSchema> {
1008
+ /**
1009
+ * The Standard properties.
1010
+ */
1011
+ readonly "~standard": StandardSchemaV1.Props<any, TSchema> &
1012
+ StandardJSONSchemaV1.Props<any, TSchema>;
1013
+
1007
1014
  /**
1008
1015
  * Flags of current schema.
1009
1016
  */
package/lib/schemas.js CHANGED
@@ -80,7 +80,8 @@ internals.rule = Joi.object({
80
80
  manifest: Joi.boolean(),
81
81
  method: Joi.function().allow(false),
82
82
  multi: Joi.boolean(),
83
- validate: Joi.function()
83
+ validate: Joi.function(),
84
+ jsonSchema: Joi.function()
84
85
  });
85
86
 
86
87
 
@@ -114,6 +115,7 @@ exports.extension = Joi.object({
114
115
  prepare: Joi.function().maxArity(3),
115
116
  rebuild: Joi.function().arity(1),
116
117
  rules: Joi.object().pattern(internals.nameRx, internals.rule),
118
+ jsonSchema: Joi.function(),
117
119
  terms: Joi.object().pattern(internals.nameRx, Joi.object({
118
120
  init: Joi.array().allow(null).required(),
119
121
  manifest: Joi.object().pattern(/.+/, [
@@ -146,6 +146,49 @@ module.exports = Any.extend({
146
146
  return internals.errors(errors, helpers);
147
147
  },
148
148
 
149
+ jsonSchema(schema, res, mode, options) {
150
+
151
+ const matches = [];
152
+
153
+ // Collect all alternative schemas from 'matches' term
154
+
155
+ for (const match of schema.$_terms.matches) {
156
+ if (match.schema) {
157
+ matches.push(match.schema.$_jsonSchema(mode, options));
158
+ }
159
+ else {
160
+ // Handle conditional matches (when/switch)
161
+
162
+ const tests = match.is ? [match] : match.switch;
163
+ for (const test of tests) {
164
+ if (test.then) {
165
+ matches.push(test.then.$_jsonSchema(mode, options));
166
+ }
167
+
168
+ if (test.otherwise) {
169
+ matches.push(test.otherwise.$_jsonSchema(mode, options));
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ if (matches.length) {
176
+ delete res.type;
177
+
178
+ // Map alternatives to 'anyOf' or 'oneOf' based on the match flag
179
+
180
+ const matchMode = schema._flags.match ?? 'any';
181
+ if (matchMode === 'one') {
182
+ res.oneOf = matches;
183
+ }
184
+ else {
185
+ res.anyOf = matches;
186
+ }
187
+ }
188
+
189
+ return res;
190
+ },
191
+
149
192
  rules: {
150
193
 
151
194
  conditional: {
@@ -68,6 +68,87 @@ module.exports = Any.extend({
68
68
  return { value: value.slice() }; // Clone the array so that we don't modify the original
69
69
  },
70
70
 
71
+ jsonSchema(schema, res, mode, options) {
72
+
73
+ const ordered = schema.$_terms.ordered;
74
+
75
+ // Handle ordered items (tuple-like) using 'prefixItems'
76
+
77
+ if (ordered.length) {
78
+ res.prefixItems = ordered.map((item) => item.$_jsonSchema(mode, options));
79
+ }
80
+
81
+ if (schema.$_terms.items.length) {
82
+ let items;
83
+ if (schema.$_terms.items.length === 1) {
84
+ items = schema.$_terms.items[0].$_jsonSchema(mode, options);
85
+ }
86
+ else {
87
+ items = {
88
+ anyOf: schema.$_terms.items.map((item) => item.$_jsonSchema(mode, options))
89
+ };
90
+ }
91
+
92
+ // If there are ordered items, remaining items are 'unevaluatedItems'
93
+
94
+ if (ordered.length) {
95
+ res.unevaluatedItems = items;
96
+ res.minItems = ordered.length;
97
+ }
98
+ else {
99
+ res.items = items;
100
+ }
101
+ }
102
+ else if (ordered.length) {
103
+ // No additional items allowed beyond the ordered ones
104
+
105
+ res.unevaluatedItems = false;
106
+ res.minItems = ordered.length;
107
+ res.maxItems = ordered.length;
108
+ }
109
+
110
+ // Map 'has' rules to 'contains' in JSON Schema
111
+
112
+ const contains = [];
113
+ for (const rule of schema._rules) {
114
+ if (rule.name === 'has') {
115
+ contains.push(rule.args.schema.$_jsonSchema(mode, options));
116
+ }
117
+ }
118
+
119
+ if (contains.length) {
120
+ if (contains.length === 1) {
121
+ res.contains = contains[0];
122
+ }
123
+ else {
124
+ res.allOf = contains.map((item) => ({ contains: item }));
125
+ }
126
+ }
127
+
128
+ if (schema._flags.single &&
129
+ schema.$_terms.items.length) {
130
+
131
+ let items;
132
+ if (schema.$_terms.items.length === 1) {
133
+ items = schema.$_terms.items[0].$_jsonSchema(mode, options);
134
+ }
135
+ else {
136
+ items = {
137
+ anyOf: schema.$_terms.items.map((item) => item.$_jsonSchema(mode, options))
138
+ };
139
+ }
140
+
141
+ res = {
142
+ anyOf: [
143
+ res,
144
+ items
145
+ ]
146
+ };
147
+ }
148
+
149
+ return res;
150
+ },
151
+
71
152
  rules: {
72
153
 
73
154
  has: {
@@ -368,6 +449,13 @@ module.exports = Any.extend({
368
449
 
369
450
  return helpers.error('array.' + name, { limit: args.limit, value });
370
451
  },
452
+ jsonSchema(rule, res) {
453
+
454
+ res.minItems = rule.args.limit;
455
+ res.maxItems = rule.args.limit;
456
+
457
+ return res;
458
+ },
371
459
  args: [
372
460
  {
373
461
  name: 'limit',
@@ -382,6 +470,12 @@ module.exports = Any.extend({
382
470
  method(limit) {
383
471
 
384
472
  return this.$_addRule({ name: 'max', method: 'length', args: { limit }, operator: '<=' });
473
+ },
474
+ jsonSchema(rule, res) {
475
+
476
+ res.maxItems = rule.args.limit;
477
+
478
+ return res;
385
479
  }
386
480
  },
387
481
 
@@ -389,6 +483,12 @@ module.exports = Any.extend({
389
483
  method(limit) {
390
484
 
391
485
  return this.$_addRule({ name: 'min', method: 'length', args: { limit }, operator: '>=' });
486
+ },
487
+ jsonSchema(rule, res) {
488
+
489
+ res.minItems = rule.args.limit;
490
+
491
+ return res;
392
492
  }
393
493
  },
394
494
 
@@ -558,6 +658,12 @@ module.exports = Any.extend({
558
658
 
559
659
  return value;
560
660
  },
661
+ jsonSchema(rule, res) {
662
+
663
+ res.uniqueItems = true;
664
+
665
+ return res;
666
+ },
561
667
  args: ['comparator', 'options'],
562
668
  multi: true
563
669
  }
@@ -33,6 +33,14 @@ module.exports = Any.extend({
33
33
  }
34
34
  },
35
35
 
36
+ jsonSchema(schema, res, mode, options) {
37
+
38
+ res.type = 'string';
39
+ res.format = 'binary';
40
+
41
+ return res;
42
+ },
43
+
36
44
  rules: {
37
45
  encoding: {
38
46
  method(encoding) {
@@ -56,6 +64,12 @@ module.exports = Any.extend({
56
64
 
57
65
  return helpers.error('binary.' + name, { limit: args.limit, value });
58
66
  },
67
+ jsonSchema(rule, res) {
68
+
69
+ res.minLength = rule.args.limit;
70
+ res.maxLength = rule.args.limit;
71
+ return res;
72
+ },
59
73
  args: [
60
74
  {
61
75
  name: 'limit',
@@ -70,6 +84,11 @@ module.exports = Any.extend({
70
84
  method(limit) {
71
85
 
72
86
  return this.$_addRule({ name: 'max', method: 'length', args: { limit }, operator: '<=' });
87
+ },
88
+ jsonSchema(rule, res) {
89
+
90
+ res.maxLength = rule.args.limit;
91
+ return res;
73
92
  }
74
93
  },
75
94
 
@@ -77,6 +96,11 @@ module.exports = Any.extend({
77
96
  method(limit) {
78
97
 
79
98
  return this.$_addRule({ name: 'min', method: 'length', args: { limit }, operator: '>=' });
99
+ },
100
+ jsonSchema(rule, res) {
101
+
102
+ res.minLength = rule.args.limit;
103
+ return res;
80
104
  }
81
105
  }
82
106
  },
package/lib/types/date.js CHANGED
@@ -7,7 +7,9 @@ const Common = require('../common');
7
7
  const Template = require('../template');
8
8
 
9
9
 
10
- const internals = {};
10
+ const internals = {
11
+ formats: ['iso', 'javascript', 'unix']
12
+ };
11
13
 
12
14
 
13
15
  internals.isDate = function (value) {
@@ -48,6 +50,14 @@ module.exports = Any.extend({
48
50
  return { value, errors: error('date.format', { format }) };
49
51
  },
50
52
 
53
+ jsonSchema(schema, res, mode, options) {
54
+
55
+ res.type = 'string';
56
+ res.format = 'date-time';
57
+
58
+ return res;
59
+ },
60
+
51
61
  rules: {
52
62
 
53
63
  compare: {
@@ -78,7 +88,7 @@ module.exports = Any.extend({
78
88
  format: {
79
89
  method(format) {
80
90
 
81
- assert(['iso', 'javascript', 'unix'].includes(format), 'Unknown date format', format);
91
+ assert(internals.formats.includes(format), 'Unknown date format', format);
82
92
 
83
93
  return this.$_setFlag('format', format);
84
94
  }
@@ -88,6 +98,15 @@ module.exports = Any.extend({
88
98
  method(date) {
89
99
 
90
100
  return this.$_addRule({ name: 'greater', method: 'compare', args: { date }, operator: '>' });
101
+ },
102
+ jsonSchema(rule, res) {
103
+
104
+ const date = rule.args.date;
105
+ if (date instanceof Date) {
106
+ res['x-constraint'] = { ...res['x-constraint'], greater: date.toISOString() };
107
+ }
108
+
109
+ return res;
91
110
  }
92
111
  },
93
112
 
@@ -102,6 +121,15 @@ module.exports = Any.extend({
102
121
  method(date) {
103
122
 
104
123
  return this.$_addRule({ name: 'less', method: 'compare', args: { date }, operator: '<' });
124
+ },
125
+ jsonSchema(rule, res) {
126
+
127
+ const date = rule.args.date;
128
+ if (date instanceof Date) {
129
+ res['x-constraint'] = { ...res['x-constraint'], less: date.toISOString() };
130
+ }
131
+
132
+ return res;
105
133
  }
106
134
  },
107
135
 
@@ -109,6 +137,15 @@ module.exports = Any.extend({
109
137
  method(date) {
110
138
 
111
139
  return this.$_addRule({ name: 'max', method: 'compare', args: { date }, operator: '<=' });
140
+ },
141
+ jsonSchema(rule, res) {
142
+
143
+ const date = rule.args.date;
144
+ if (date instanceof Date) {
145
+ res['x-constraint'] = { ...res['x-constraint'], max: date.toISOString() };
146
+ }
147
+
148
+ return res;
112
149
  }
113
150
  },
114
151
 
@@ -116,6 +153,15 @@ module.exports = Any.extend({
116
153
  method(date) {
117
154
 
118
155
  return this.$_addRule({ name: 'min', method: 'compare', args: { date }, operator: '>=' });
156
+ },
157
+ jsonSchema(rule, res) {
158
+
159
+ const date = rule.args.date;
160
+ if (date instanceof Date) {
161
+ res['x-constraint'] = { ...res['x-constraint'], min: date.toISOString() };
162
+ }
163
+
164
+ return res;
119
165
  }
120
166
  },
121
167