Skip to content

Commit b366678

Browse files
authored
Merge pull request #3102 from hapijs/feat/standard-json-schema
feat: add Standard JSON Schema
2 parents 481e270 + f9f9c32 commit b366678

20 files changed

Lines changed: 2764 additions & 9 deletions

API.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,6 +1235,22 @@ try {
12351235
catch (err) { }
12361236
```
12371237

1238+
#### `any['~standard']`
1239+
1240+
Provides compatibility with the [Standard Schema](https://github.com/standard-schema/spec) specification. It contains a `jsonSchema` object with the following methods:
1241+
1242+
- `input([options])` - returns the JSON Schema for the input value (before conversion).
1243+
- `output([options])` - returns the JSON Schema for the output value (after conversion).
1244+
1245+
Where `options` is an optional object with:
1246+
- `target` - the JSON Schema target version. Currently only supports `'draft-2020-12'`. Defaults to `'draft-2020-12'`.
1247+
1248+
```js
1249+
const schema = Joi.string().min(5);
1250+
const jsonSchema = schema['~standard'].jsonSchema.input();
1251+
// { type: 'string', minLength: 5 }
1252+
```
1253+
12381254
#### `any.when([condition], options)`
12391255

12401256
Adds conditions that are evaluated during validation and modify the schema before it is applied to the value, where:
@@ -3205,6 +3221,13 @@ Where:
32053221
- `errors`: Validation error(s) generated by `$_createError()` or `helpers.error()`.
32063222

32073223
If `errors` is defined, validation will abort regardless of `abortEarly`. Refer to the validation process above for further information.
3224+
- `jsonSchema`: A function with signature `function (schema, res, mode, options) {}` that returns the JSON Schema for the type where:
3225+
- `schema`: The current schema instance.
3226+
- `res`: The current JSON Schema object.
3227+
- `mode`: Either `'input'` or `'output'`.
3228+
- `options`: The options passed to `jsonSchema.input()` or `jsonSchema.output()`.
3229+
3230+
Must return the modified JSON Schema object.
32083231
- `rules`: A hash of validation rule names and their implementation where:
32093232
- `alias`: Aliases of the rule. Can be a string or an array of strings.
32103233
- `args`: An array of argument names or an object that define the parameters the rule will accept where:
@@ -3222,6 +3245,14 @@ Where:
32223245
- `helpers`: [Validation helpers](#validation-helpers)
32233246
- `args`: Resolved and validated arguments mapped by their names.
32243247
- `rule`: The rule definitions passed to `$_addRule` left untouched. Useful if you need access to the raw arguments before validation.
3248+
- `jsonSchema`: A function with signature `function (rule, res, isOnly, mode, options) {}` that returns the JSON Schema for the rule where:
3249+
- `rule`: The rule object.
3250+
- `res`: The current JSON Schema object.
3251+
- `isOnly`: A boolean indicating if the schema has the `only` flag set.
3252+
- `mode`: Either `'input'` or `'output'`.
3253+
- `options`: The options passed to `jsonSchema.input()` or `jsonSchema.output()`.
3254+
3255+
Must return the modified JSON Schema object.
32253256
- `overrides`: A hash of method names and their overridden implementation. To refer to the parent method, use [`$_parent()`](#_parentmethod-args)
32263257

32273258
```js

lib/base.js

100755100644
Lines changed: 172 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@ const Validator = require('./validator');
1616
const Values = require('./values');
1717

1818

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+
};
2025

2126

2227
internals.Base = class {
@@ -62,6 +67,168 @@ internals.Base = class {
6267
return Manifest.describe(this);
6368
}
6469

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+
65232
// Rules
66233

67234
allow(...values) {
@@ -1116,6 +1283,10 @@ internals.Base = class {
11161283
}
11171284

11181285
return mapToStandardError(result.error);
1286+
},
1287+
jsonSchema: {
1288+
input: (options) => this.$_jsonSchema('input', options),
1289+
output: (options) => this.$_jsonSchema('output', options)
11191290
}
11201291
};
11211292
}

lib/common.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,25 @@ exports.default = function (value, defaultValue) {
9999
};
100100

101101

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+
102121
exports.isIsoDate = function (date) {
103122

104123
return internals.isoDate.test(date);

lib/extend.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,12 @@ exports.type = function (from, options) {
129129

130130
def.rules = rules;
131131

132+
// JSON Schema
133+
134+
if (!def.jsonSchema) {
135+
def.jsonSchema = parent.jsonSchema;
136+
}
137+
132138
// Modifiers
133139

134140
const modifiers = Object.assign({}, parent.modifiers);

lib/index.d.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
// TypeScript Version: 2.8
99

1010
// 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";
1212

1313
declare namespace Joi {
1414
type Types =
@@ -1003,7 +1003,14 @@ declare namespace Joi {
10031003

10041004
interface AnySchema<TSchema = any>
10051005
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+
10071014
/**
10081015
* Flags of current schema.
10091016
*/

lib/schemas.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ internals.rule = Joi.object({
8080
manifest: Joi.boolean(),
8181
method: Joi.function().allow(false),
8282
multi: Joi.boolean(),
83-
validate: Joi.function()
83+
validate: Joi.function(),
84+
jsonSchema: Joi.function()
8485
});
8586

8687

@@ -114,6 +115,7 @@ exports.extension = Joi.object({
114115
prepare: Joi.function().maxArity(3),
115116
rebuild: Joi.function().arity(1),
116117
rules: Joi.object().pattern(internals.nameRx, internals.rule),
118+
jsonSchema: Joi.function(),
117119
terms: Joi.object().pattern(internals.nameRx, Joi.object({
118120
init: Joi.array().allow(null).required(),
119121
manifest: Joi.object().pattern(/.+/, [

lib/types/alternatives.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,49 @@ module.exports = Any.extend({
146146
return internals.errors(errors, helpers);
147147
},
148148

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+
149192
rules: {
150193

151194
conditional: {

0 commit comments

Comments
 (0)