json-2-joi 2.0.2 → 2.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/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ### 2.1.0
2
+
3
+ - Fix the issue that `enum` is only applied to `string` type or when missing type info.
4
+ - Add `strictEnum` and `enableEnum` option.
5
+ - Allow override options when calling `convert` api.
6
+
1
7
  ### 2.0.2
2
8
 
3
9
  - Remove `min` setting from JOI schema if `minLength` is not defined in JSON schema.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Converts a JSON schema to a Joi schema for object validation.
6
6
 
7
7
  ## Change based on the forked version (enjoi):
8
8
 
9
- - Provided `refineDescription(schema)`, `allowNull` options
9
+ - Provided `refineDescription(schema)`, `allowNull`, `strictEnum`, `enableEnum` options
10
10
  - Change cycle reference resovling without self-generating id and rely on $id if defined in JSON schema
11
11
  - Support `const` keyword in JSON schema
12
12
  - Support `example` setting for Joi based on JSON schema's `examples`, `default` or `enum`
@@ -53,7 +53,9 @@ Please file issues for other unsupported features.
53
53
  - `refineType(type, format)` - an (optional) function to call to apply to type based on the type and format of the JSON schema.
54
54
  - `refineSchema(joiSchema, jsonSchema)` - an (optional) function to call to apply to adjust Joi schema base on the original JSON schema. Primary use case is handling `nullable` flag in OpenAPI 3.0
55
55
  - `extensions` - an array of extensions to pass [joi.extend](https://github.com/hapijs/joi/blob/master/API.md#extendextension).
56
- - `allowNull` - Allows null value when the field is NOT required with a default value of `false`.
56
+ - `allowNull` - Default as `false`. When `true` and the field has no `enum` setting, `null` value will be allowed.
57
+ - `strictEnum` - Default as `true`. When `false`, and `allowNull` is `true`, `null` (and empty string additionally for string type) will be added to `enum` list.
58
+ - `enableEnum` - Default as `true`. When `false`, `enum` restriction will not be added to Joi Schema.
57
59
 
58
60
  Example:
59
61
 
@@ -204,6 +206,64 @@ const lengthSchema = json2Joi.convert({
204
206
  });
205
207
  ```
206
208
 
209
+ #### Override options during conversion
210
+
211
+ When reusing the Resolver and calling `convert` API, it's possible that different schema might have different requirement.
212
+
213
+ It's possible to override these options for each conversion:
214
+
215
+ * refineType
216
+ * refineSchema
217
+ * refineDescription
218
+ * allowNull
219
+ * strictEnum
220
+ * enableEnum
221
+
222
+ However, the overriden options do not have impact on the `subSchemas` as they are preprocessed during Resolver construction.
223
+
224
+ ```javascript
225
+ const subSchemas = {
226
+ measurement: {
227
+ type: 'object',
228
+ properties: {
229
+ quantity: {
230
+ type: 'number',
231
+ enum: [0, 1]
232
+ }
233
+ }
234
+ }
235
+ };
236
+ const enjoi = Enjoi.resolver({
237
+ subSchemas,
238
+ enableEnum: false
239
+ });
240
+
241
+ const jsonSchema = {
242
+ type: 'object',
243
+ properties: {
244
+ weight: {
245
+ type: 'object',
246
+ properties: {
247
+ quantity: {
248
+ type: 'number',
249
+ enum: [0, 1]
250
+ }
251
+ }
252
+ },
253
+ length: {
254
+ $ref: 'measurement'
255
+ }
256
+ }
257
+ };
258
+
259
+ const schema1 = enjoi.convert(jsonSchema, { enableEnum: true });
260
+ const schema2 = enjoi.convert(jsonSchema);
261
+
262
+ t.ok(schema1.validate({ weight: { quantity: 2 } }).error, 'follow overridden options');
263
+ t.ok(!schema1.validate({ length: { quantity: 2 } }).error, 'no impact on subschemas');
264
+ t.ok(!schema2.validate({ weight: { quantity: 2 } }).error, 'still use original options');
265
+ ```
266
+
207
267
  ### Custom Types
208
268
 
209
269
  Custom types can be provided through the `extensions` option.
package/lib/resolver.js CHANGED
@@ -20,9 +20,33 @@ const optionsSchema = Joi.object({
20
20
  refineSchema: Joi.func().allow(null),
21
21
  refineDescription: Joi.func().allow(null),
22
22
  allowNull: Joi.boolean().default(false),
23
+ strictEnum: Joi.boolean().default(true),
24
+ enableEnum: Joi.boolean().default(true),
23
25
  joiOptions: Joi.object().unknown(true).default({})
24
26
  });
25
27
 
28
+ const overridableOptions = {
29
+ refineType: Joi.func().allow(null),
30
+ refineSchema: Joi.func().allow(null),
31
+ refineDescription: Joi.func().allow(null),
32
+ allowNull: Joi.boolean(),
33
+ strictEnum: Joi.boolean(),
34
+ enableEnum: Joi.boolean()
35
+ };
36
+ const overridableOptionKeys = Object.keys(overridableOptions);
37
+ const overrideOptionsSchema = Joi.object(overridableOptions);
38
+
39
+ function mergeOverridableOptions(target, ...sources) {
40
+ sources.forEach((src) => {
41
+ overridableOptionKeys.forEach((key) => {
42
+ if (typeof src[key] !== 'undefined') {
43
+ target[key] = src[key];
44
+ }
45
+ })
46
+ });
47
+ return target;
48
+ }
49
+
26
50
  function isNumber(value) {
27
51
  return typeof value === 'number';
28
52
  }
@@ -37,11 +61,31 @@ function isNotEmptyObject(value) {
37
61
 
38
62
  function allowNullIfNeeded(schema, allowNull = false) {
39
63
  if (allowNull) {
64
+ const definition = schema.describe();
65
+ if (definition.flags && definition.flags.only && definition.allow) {
66
+ // Already has enum restriction
67
+ return schema;
68
+ }
40
69
  return schema.allow(null);
41
70
  }
42
71
  return schema;
43
72
  }
44
73
 
74
+ function addEnumRestriction(joiSchema, enumList, { allowNull = false, strictEnum = true, enableEnum = true }) {
75
+ if (!enableEnum || !Array.isArray(enumList) || enumList.length === 0) {
76
+ return joiSchema;
77
+ }
78
+
79
+ const valids = [].concat(enumList);
80
+ if (allowNull && !strictEnum) {
81
+ valids.push(null);
82
+ if (joiSchema.type === 'string') {
83
+ valids.push('');
84
+ }
85
+ }
86
+ return joiSchema.valid(...valids);
87
+ }
88
+
45
89
  function normalizedId(id) {
46
90
  return id.replace(/[^a-zA-Z0-9]/g, '_');
47
91
  }
@@ -104,13 +148,10 @@ class SchemaResolver {
104
148
 
105
149
  Hoek.assert(!validatedResult.error, validatedResult.error);
106
150
 
107
- const { subSchemas = {}, refineType, refineSchema, extensions = [], allowNull = false, refineDescription, joiOptions } = validatedResult.value;
151
+ const { subSchemas = {}, extensions = [], joiOptions } = validatedResult.value;
108
152
 
153
+ this.resolveOptions = mergeOverridableOptions({}, validatedResult.value);
109
154
  this.subSchemas = subSchemas;
110
- this.refineType = refineType;
111
- this.refineDescription = refineDescription;
112
- this.refineSchema = refineSchema;
113
- this.allowNull = allowNull;
114
155
  this.joiOptions = joiOptions;
115
156
  this.joiSharedSchemas = new Map();
116
157
 
@@ -122,7 +163,7 @@ class SchemaResolver {
122
163
  // shorthand schema type does not need to set $id
123
164
  sharedSchema.$id = sharedSchemaId;
124
165
  }
125
- this.joiSharedSchemas.set(sharedSchemaId, this.resolve(sharedSchema));
166
+ this.joiSharedSchemas.set(sharedSchemaId, this.resolve(sharedSchema, this.resolveOptions));
126
167
  }
127
168
 
128
169
  const itr = this.joiSharedSchemas.keys();
@@ -132,26 +173,29 @@ class SchemaResolver {
132
173
  }
133
174
  }
134
175
 
135
- resolveShareSchemas(schema, baseUri = '', sharedProperties = '$defs') {
176
+ resolveShareSchemas(schema, resolveOptions, sharedProperties = '$defs', baseUri = '') {
136
177
  const localShared = schema[sharedProperties]
137
178
  if (localShared) {
138
179
  for (let sharedSchemaId in localShared) {
139
180
  this.joiSharedSchemas.set(
140
181
  `${baseUri}#/${sharedProperties}/${sharedSchemaId}`,
141
- this.resolve(localShared[sharedSchemaId], baseUri)
182
+ this.resolve(localShared[sharedSchemaId], resolveOptions, baseUri)
142
183
  );
143
184
  }
144
185
  }
145
186
  }
146
187
 
147
- convert(schema) {
188
+ convert(schema, options = {}) {
148
189
  const validateSchema = schemaSchema.validate(schema);
190
+ const { value: overrideOptions, error: overrideOptionsError } = overrideOptionsSchema.validate(options);
149
191
 
150
192
  Hoek.assert(!validateSchema.error, validateSchema.error);
193
+ Hoek.assert(!overrideOptionsError, overrideOptionsError);
151
194
 
152
195
  const schemaId = schema.$id || '';
153
196
  this.subSchemas[schemaId] = schema;
154
- const joiSchema = this.resolve(schema);
197
+
198
+ const joiSchema = this.resolve(schema, mergeOverridableOptions({}, this.resolveOptions, overrideOptions));
155
199
 
156
200
  // Cleanup all shared schema registered other than the initial shared ones
157
201
  delete this.subSchemas[schemaId];
@@ -165,7 +209,7 @@ class SchemaResolver {
165
209
  return joiSchema;
166
210
  }
167
211
 
168
- resolve(schema, baseUri = (schema.$id || '')) {
212
+ resolve(schema, resolveOptions = {}, baseUri = (schema.$id || '')) {
169
213
  if (this.joi.isSchema(schema)) {
170
214
  return schema;
171
215
  }
@@ -180,46 +224,48 @@ class SchemaResolver {
180
224
  }
181
225
  }
182
226
 
183
- this.resolveShareSchemas(schema, baseUri, '$defs');
184
- this.resolveShareSchemas(schema, baseUri, 'definitions');
227
+ this.resolveShareSchemas(schema, resolveOptions, '$defs', baseUri);
228
+ this.resolveShareSchemas(schema, resolveOptions, 'definitions', baseUri);
185
229
 
186
230
  if (typeof schema === 'string') {
187
231
  // If schema is itself a string, interpret it as a type
188
- resolvedSchema = this.resolveType({ type: schema });
232
+ resolvedSchema = this.resolveType({ type: schema }, resolveOptions, baseUri);
189
233
  } else if (schema.$ref) {
190
234
  if (schema.$ref.trim() === '#') {
191
235
  return baseUri ? this.joi.link(`#${normalizedId(baseUri)}`) : this.joi.link('/');
192
236
  }
193
237
 
194
- resolvedSchema = this.resolve(this.resolveReference(schema.$ref, baseUri), baseUri);
238
+ resolvedSchema = this.resolve(this.resolveReference(schema.$ref, baseUri), resolveOptions, baseUri);
195
239
  } else {
196
240
  const partialSchemas = [];
197
241
  if (schema.type) {
198
- partialSchemas.push(this.resolveType(schema, baseUri));
242
+ partialSchemas.push(this.resolveType(schema, resolveOptions, baseUri));
199
243
  } else if (schema.properties) {
200
244
  // if no type is specified, just properties
201
- partialSchemas.push(this.object(schema, baseUri))
245
+ partialSchemas.push(this.object(schema, resolveOptions, baseUri))
202
246
  } else if (schema.format) {
203
247
  // if no type is specified, just format
204
248
  partialSchemas.push(this.string(schema))
205
249
  } else if (schema.enum) {
206
250
  // If no type is specified, just enum
207
- partialSchemas.push(this.joi.any().valid(...schema.enum).example(schema.enum[0]));
251
+ partialSchemas.push(
252
+ addEnumRestriction(this.joi.any().example(schema.enum[0]), schema.enum, resolveOptions)
253
+ );
208
254
  } else if (schema.const) {
209
255
  // Constant value
210
256
  partialSchemas.push(this.getValueJoiType(schema.const).valid(schema.const));
211
257
  }
212
258
  if (schema.anyOf) {
213
- partialSchemas.push(this.resolveAnyOf(schema, baseUri));
259
+ partialSchemas.push(this.resolveAnyOf(schema, resolveOptions, baseUri));
214
260
  }
215
261
  if (schema.allOf) {
216
- partialSchemas.push(this.resolveAllOf(schema, baseUri));
262
+ partialSchemas.push(this.resolveAllOf(schema, resolveOptions, baseUri));
217
263
  }
218
264
  if (schema.oneOf) {
219
- partialSchemas.push(this.resolveOneOf(schema, baseUri));
265
+ partialSchemas.push(this.resolveOneOf(schema, resolveOptions, baseUri));
220
266
  }
221
267
  if (schema.not) {
222
- partialSchemas.push(this.resolveNot(schema, baseUri));
268
+ partialSchemas.push(this.resolveNot(schema, resolveOptions, baseUri));
223
269
  }
224
270
  if (partialSchemas.length === 0) {
225
271
  //Fall through to whatever.
@@ -231,8 +277,8 @@ class SchemaResolver {
231
277
  resolvedSchema = partialSchemas.length === 1 ? partialSchemas[0] : this.joi.alternatives(partialSchemas).match('all');
232
278
  }
233
279
 
234
- if (this.refineSchema) {
235
- resolvedSchema = this.refineSchema(resolvedSchema, schema);
280
+ if (resolveOptions.refineSchema) {
281
+ resolvedSchema = resolveOptions.refineSchema(resolvedSchema, schema);
236
282
  }
237
283
 
238
284
  if (schema.default !== undefined) {
@@ -299,7 +345,7 @@ class SchemaResolver {
299
345
  return refSchema;
300
346
  }
301
347
 
302
- resolveType(schema, baseUri) {
348
+ resolveType(schema, resolveOptions, baseUri) {
303
349
  let joischema;
304
350
 
305
351
  const typeDefinitionMap = {
@@ -307,16 +353,16 @@ class SchemaResolver {
307
353
  default: 'default'
308
354
  };
309
355
 
310
- const joitype = (type, format) => {
356
+ const joitype = (type, options, format) => {
311
357
  let joischema;
312
358
 
313
- if (this.refineType) {
314
- type = this.refineType(type, format);
359
+ if (options.refineType) {
360
+ type = options.refineType(type, format);
315
361
  }
316
362
 
317
363
  switch (type) {
318
364
  case 'array':
319
- joischema = this.array(schema, baseUri);
365
+ joischema = this.array(schema, options, baseUri);
320
366
  break;
321
367
  case 'boolean':
322
368
  joischema = this.joi.boolean();
@@ -326,7 +372,7 @@ class SchemaResolver {
326
372
  joischema = this.number(schema);
327
373
  break;
328
374
  case 'object':
329
- joischema = this.object(schema, baseUri);
375
+ joischema = this.object(schema, options, baseUri);
330
376
  break;
331
377
  case 'string':
332
378
  joischema = this.string(schema);
@@ -347,12 +393,12 @@ class SchemaResolver {
347
393
  const schemas = [];
348
394
 
349
395
  for (let i = 0; i < schema.type.length; i++) {
350
- schemas.push(joitype(schema.type[i], schema.format));
396
+ schemas.push(joitype(schema.type[i], resolveOptions, schema.format));
351
397
  }
352
398
 
353
399
  joischema = this.joi.alternatives(schemas);
354
400
  } else {
355
- joischema = joitype(schema.type, schema.format);
401
+ joischema = joitype(schema.type, resolveOptions, schema.format);
356
402
  }
357
403
 
358
404
  Object.keys(typeDefinitionMap).forEach(function (key) {
@@ -362,8 +408,8 @@ class SchemaResolver {
362
408
  });
363
409
 
364
410
  let desc = schema.description;
365
- if (this.refineDescription) {
366
- desc = this.refineDescription(schema);
411
+ if (resolveOptions.refineDescription) {
412
+ desc = resolveOptions.refineDescription(schema);
367
413
  }
368
414
  if (desc) {
369
415
  joischema = joischema.description(desc);
@@ -383,70 +429,69 @@ class SchemaResolver {
383
429
  joischema = joischema.type === 'array' && !Array.isArray(example) ? joischema.example([example]) : joischema.example(example);
384
430
  }
385
431
 
386
- return joischema;
432
+ return addEnumRestriction(joischema, schema.enum, resolveOptions);
387
433
  }
388
434
 
389
- resolveOneOf(schema, baseUri) {
435
+ resolveOneOf(schema, resolveOptions, baseUri) {
390
436
  Hoek.assert(Array.isArray(schema.oneOf), 'Expected oneOf to be an array.');
391
437
 
392
- return this.joi.alternatives(schema.oneOf.map(schema => this.resolve(schema, baseUri))).match('one');
438
+ return this.joi.alternatives(schema.oneOf.map(schema => this.resolve(schema, resolveOptions, baseUri))).match('one');
393
439
  }
394
440
 
395
- resolveAnyOf(schema, baseUri) {
441
+ resolveAnyOf(schema, resolveOptions, baseUri) {
396
442
  Hoek.assert(Array.isArray(schema.anyOf), 'Expected anyOf to be an array.');
397
443
 
398
- return this.joi.alternatives(schema.anyOf.map(schema => this.resolve(schema, baseUri))).match('any');
444
+ return this.joi.alternatives(schema.anyOf.map(schema => this.resolve(schema, resolveOptions, baseUri))).match('any');
399
445
  }
400
446
 
401
- resolveAllOf(schema, baseUri) {
447
+ resolveAllOf(schema, resolveOptions, baseUri) {
402
448
  Hoek.assert(Array.isArray(schema.allOf), 'Expected allOf to be an array.');
403
449
 
404
- return this.joi.alternatives(schema.allOf.map(schema => this.resolve(schema, baseUri))).match('all');
450
+ return this.joi.alternatives(schema.allOf.map(schema => this.resolve(schema, resolveOptions, baseUri))).match('all');
405
451
  }
406
452
 
407
- resolveNot(schema, baseUri) {
453
+ resolveNot(schema, resolveOptions, baseUri) {
408
454
  Hoek.assert(isObject(schema.not), 'Expected Not to be an object.');
409
455
 
410
456
  return this.joi.alternatives().conditional(
411
457
  '.',
412
458
  {
413
- not: this.resolve(schema.not, baseUri),
459
+ not: this.resolve(schema.not, resolveOptions, baseUri),
414
460
  then: this.joi.any(),
415
461
  otherwise: this.joi.any().forbidden()
416
462
  }
417
463
  );
418
464
  }
419
465
 
420
- object(schema, baseUri) {
466
+ resolveProperties(jsonSchema, resolveOptions, baseUri) {
467
+ const schemas = {};
421
468
 
422
- const resolveproperties = () => {
423
- const schemas = {};
424
-
425
- if (!isObject(schema.properties)) {
426
- return;
427
- }
469
+ if (!isObject(jsonSchema.properties)) {
470
+ return;
471
+ }
428
472
 
429
- Object.keys(schema.properties).forEach((key) => {
430
- const property = schema.properties[key];
473
+ Object.keys(jsonSchema.properties).forEach((key) => {
474
+ const property = jsonSchema.properties[key];
431
475
 
432
- let joischema = this.resolve(property, baseUri);
476
+ let joischema = this.resolve(property, resolveOptions, baseUri);
433
477
 
434
- if (Array.isArray(schema.required) && !!~schema.required.indexOf(key)) {
435
- joischema = joischema.required();
436
- } else {
437
- joischema = allowNullIfNeeded(joischema, this.allowNull);
438
- }
478
+ if (Array.isArray(jsonSchema.required) && !!~jsonSchema.required.indexOf(key)) {
479
+ joischema = joischema.required();
480
+ } else {
481
+ joischema = allowNullIfNeeded(joischema, resolveOptions.allowNull);
482
+ }
439
483
 
440
- schemas[key] = joischema;
441
- });
484
+ schemas[key] = joischema;
485
+ });
442
486
 
443
- return schemas;
444
- }
487
+ return schemas;
488
+ }
445
489
 
446
- let joischema = this.joi.object(resolveproperties(schema));
490
+ object(schema, resolveOptions, baseUri) {
491
+ let joischema = this.joi.object(this.resolveProperties(schema, resolveOptions, baseUri));
447
492
 
448
493
  if (isObject(schema.additionalProperties)) {
449
- joischema = joischema.pattern(/^/, this.resolve(schema.additionalProperties, baseUri));
494
+ joischema = joischema.pattern(/^/, this.resolve(schema.additionalProperties, resolveOptions, baseUri));
450
495
  } else if (typeof this.joiOptions.allowUnknown === 'undefined') {
451
496
  joischema = joischema.unknown(schema.additionalProperties !== false);
452
497
  }
@@ -457,25 +502,25 @@ class SchemaResolver {
457
502
  return joischema;
458
503
  }
459
504
 
460
- resolveAsArray(value, baseUri) {
505
+ resolveAsArray(value, resolveOptions, baseUri) {
461
506
  if (Array.isArray(value)) {
462
507
  // found an array, thus its _per type_
463
- return value.map((v) => allowNullIfNeeded(this.resolve(v, baseUri), this.allowNull));
508
+ return value.map((v) => allowNullIfNeeded(this.resolve(v, resolveOptions, baseUri), resolveOptions.allowNull));
464
509
  }
465
510
  // it's a single entity, so just resolve it normally
466
- return [allowNullIfNeeded(this.resolve(value, baseUri), this.allowNull)];
511
+ return [allowNullIfNeeded(this.resolve(value, resolveOptions, baseUri), resolveOptions.allowNull)];
467
512
  }
468
513
 
469
- array(schema, baseUri) {
514
+ array(schema, resolveOptions, baseUri) {
470
515
  let joischema = this.joi.array();
471
516
  let items;
472
517
 
473
518
  if (schema.items) {
474
- items = this.resolveAsArray(schema.items, baseUri);
519
+ items = this.resolveAsArray(schema.items, resolveOptions, baseUri);
475
520
 
476
521
  joischema = joischema.items(...items);
477
522
  } else if (schema.ordered) {
478
- items = this.resolveAsArray(schema.ordered, baseUri);
523
+ items = this.resolveAsArray(schema.ordered, resolveOptions, baseUri);
479
524
  joischema = joischema.ordered(...items);
480
525
  }
481
526
 
@@ -490,7 +535,7 @@ class SchemaResolver {
490
535
  joischema = joischema.unique();
491
536
  }
492
537
  if (schema.contains) {
493
- joischema = joischema.has(this.resolve(schema.contains, baseUri));
538
+ joischema = joischema.has(this.resolve(schema.contains, resolveOptions, baseUri));
494
539
  }
495
540
 
496
541
  return joischema;
@@ -519,10 +564,6 @@ class SchemaResolver {
519
564
  const timeRegex = '([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]|60)(.[0-9]+)?(Z|(\\+|-)([01][0-9]|2[0-3]):([0-5][0-9]))';
520
565
  const dateTimeRegex = dateRegex + 'T' + timeRegex;
521
566
 
522
- if (schema.enum) {
523
- return this.joi.string().valid(...schema.enum);
524
- }
525
-
526
567
  switch (schema.format) {
527
568
  case 'date':
528
569
  return joischema.regex(new RegExp('^' + dateRegex + '$', 'i'), 'JsonSchema date format');
@@ -561,7 +602,7 @@ class SchemaResolver {
561
602
  regularString(schema, joischema) {
562
603
  schema.pattern && (joischema = joischema.regex(new RegExp(schema.pattern)));
563
604
 
564
- if ((typeof schema.minLength === 'undefined' || schema.minLength === 0) && !schema.pattern && !schema.format) {
605
+ if ((typeof schema.minLength === 'undefined' || schema.minLength === 0) && !schema.pattern && !schema.format && !schema.enum) {
565
606
  joischema = joischema.allow('');
566
607
  }
567
608
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "json-2-joi",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
4
4
  "license": "Apache 2.0",
5
5
  "description": "Converts json-schema to Joi schema. (Enhanced enjoi)",
6
6
  "main": "./index.js",