joi-to-json 3.1.2 → 4.0.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/README.md +76 -35
- package/lib/convertors/v12.js +119 -47
- package/lib/convertors/v14.js +3 -0
- package/lib/convertors/v16.js +2 -0
- package/lib/parsers/json-draft-04.js +27 -0
- package/lib/parsers/json-draft-2019-09.js +1 -1
- package/lib/parsers/json.js +191 -41
- package/lib/parsers/open-api.js +40 -3
- package/package.json +5 -4
- package/CHANGELOG.md +0 -11
package/README.md
CHANGED
|
@@ -8,27 +8,19 @@ It's **The most powerful schema description language and data validator for Java
|
|
|
8
8
|
Many times, we need to utilize this schema description to produce other output, such as Swagger OpenAPI doc.
|
|
9
9
|
That is why I build [joi-route-to-swagger](https://github.com/kenspirit/joi-route-to-swagger) in the first place.
|
|
10
10
|
|
|
11
|
-
At the beginning, `joi-route-to-swagger` relies on [joi-to-json-schema](https://github.com/lightsofapollo/joi-to-json-schema/) which utilizes many joi internal api or properties.
|
|
11
|
+
At the beginning, `joi-route-to-swagger` relies on [joi-to-json-schema](https://github.com/lightsofapollo/joi-to-json-schema/) which utilizes many joi internal api or properties. Maybe joi did not provide the `describe` api way before, but I always feel uncomfortable of relying on internal api.
|
|
12
12
|
|
|
13
13
|
The intention of `joi-to-json` is to support converting different version's joi schema to [JSON Schema](https://json-schema.org) using `describe` api.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
The implementation of this JOI to JSON conversion tool is simply a pipeline of two components:
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
1. Convertors
|
|
18
|
+
- Each JOI version has one convertor implementation.
|
|
19
|
+
- It converts the `joi.describe()` output to the baseline format (currently the v16 and v17 one)
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
- In v1.0.0, it directly converts `joi.describe()` to JSON schema using different parser implementations.
|
|
23
|
-
- In v2.0.0, `joi.describe()` of different versions are first converted to one base format, the latest version of `joi.describe()` output. Then different parsers (JSON, OpenAPI) all refer to this base format.
|
|
24
|
-
|
|
25
|
-
* The benefits of the change are:
|
|
26
|
-
- Easier to retire old version of joi.
|
|
27
|
-
- Easier to support more output formats.
|
|
28
|
-
|
|
29
|
-
## Installation
|
|
30
|
-
|
|
31
|
-
>npm install joi-to-json
|
|
21
|
+
2. Parsers
|
|
22
|
+
- Each supported output JSON format (e.g. JSON Draft 07, OpenAPI) has one parser implementation.
|
|
23
|
+
- All parsers converts the baseline format into its own format
|
|
32
24
|
|
|
33
25
|
|
|
34
26
|
## Joi Version Support
|
|
@@ -42,11 +34,14 @@ It's a breaking change.
|
|
|
42
34
|
* 15.1.1
|
|
43
35
|
* 16.1.8
|
|
44
36
|
* joi
|
|
45
|
-
* 17.
|
|
37
|
+
* 17.9.2
|
|
38
|
+
|
|
39
|
+
Although the versions chosen are the latest one for each major version, It should support other minor versions as well.
|
|
46
40
|
|
|
47
|
-
For all above versions, I have tested one complex joi object [fixtures](./fixtures) which covers most of the JSON schema attributes that can be described in joi schema.
|
|
48
41
|
|
|
49
|
-
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
>npm install joi-to-json
|
|
50
45
|
|
|
51
46
|
|
|
52
47
|
## Usage
|
|
@@ -55,11 +50,11 @@ Only one API `parse` is available. It's signature is `parse(joiObj, type = 'jso
|
|
|
55
50
|
|
|
56
51
|
Currently supported output types:
|
|
57
52
|
* `json` - Default. Stands for JSON Schema Draft 07
|
|
58
|
-
* `open-api` - Stands for OpenAPI Schema
|
|
53
|
+
* `open-api` - Stands for OpenAPI 3.0 Schema - an extended subset of JSON Schema Specification Wright Draft 00 (aka Draft 5)
|
|
59
54
|
* `json-draft-04` - Stands for JSON Schema Draft 04
|
|
60
55
|
* `json-draft-2019-09` - Stands for JSON Schema Draft 2019-09
|
|
61
56
|
|
|
62
|
-
The output schema format are in [outputs](./outputs) under specific folders for different types.
|
|
57
|
+
The output schema format are in [outputs](./outputs-parsers) under specific folders for different types.
|
|
63
58
|
|
|
64
59
|
Sample code is as below:
|
|
65
60
|
|
|
@@ -95,7 +90,26 @@ const jsonSchema = parse(joiSchema)
|
|
|
95
90
|
// const openApiSchema = parse(joiSchema, 'open-api')
|
|
96
91
|
```
|
|
97
92
|
|
|
98
|
-
|
|
93
|
+
## Features
|
|
94
|
+
|
|
95
|
+
### Special Joi Operator Support
|
|
96
|
+
|
|
97
|
+
* [Logical Relation Operator](./docs/logical_rel_support.md)
|
|
98
|
+
|
|
99
|
+
### Named Link
|
|
100
|
+
|
|
101
|
+
Supports named link for schema reuse, such as `.link('#person')`. **For `open-api` conversion**, as the shared schemas are located in `#/components/schemas` which is not self-contained, the conversion result contains an **extra `schemas`** field so that you can extract it when required.
|
|
102
|
+
|
|
103
|
+
### Conditional Expression
|
|
104
|
+
|
|
105
|
+
Starting from Draft 7, JSON Specification supports `If-Then-Else` style expression. Before that, we can also use something called [Implication](http://json-schema.org/understanding-json-schema/reference/conditionals.html#implication) using Schema Composition Approach to simulate that.
|
|
106
|
+
|
|
107
|
+
By default, the `If-Then-Else` approach is used if the output spec supports it. However, if the joi conditional expression (`alternatives` or `when`) is annotated using Meta `.meta({ 'if-style': true })`, the JSON schema conversion will use the Composition approach using `allOf` and/or `anyOf` instead.
|
|
108
|
+
|
|
109
|
+
**Limitation**: Currently, if the joi condition definition is referring to another field, the `If-Then-Else` style output is not supported. Instead, it simply uses the `anyOf` composing the `then` and `otherwise` on the defined field.
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
## YAML File Generation
|
|
99
113
|
|
|
100
114
|
Most Joi specifications result in the expected OpenAPI schema.
|
|
101
115
|
|
|
@@ -196,7 +210,7 @@ properties:
|
|
|
196
210
|
properties:
|
|
197
211
|
key:
|
|
198
212
|
type: string
|
|
199
|
-
additionalProperties: true
|
|
213
|
+
additionalProperties: true
|
|
200
214
|
additionalProperties: false
|
|
201
215
|
```
|
|
202
216
|
|
|
@@ -242,10 +256,6 @@ properties:
|
|
|
242
256
|
additionalProperties: true
|
|
243
257
|
```
|
|
244
258
|
|
|
245
|
-
## Special Joi Operator Support
|
|
246
|
-
|
|
247
|
-
* [Logical Relation Operator](./docs/logical_rel_support.md)
|
|
248
|
-
|
|
249
259
|
## Browser support
|
|
250
260
|
For generating JSON Schema in a browser you should use below import syntax for `joi` library in order to work because the `joi` browser minimized build does not have `describe` api which the `joi-to-json` relies on.
|
|
251
261
|
|
|
@@ -265,7 +275,7 @@ const logicalOpParser: Joi2Json.LogicalOpParserOpts = {
|
|
|
265
275
|
};
|
|
266
276
|
|
|
267
277
|
parse(joi.string()); // Default call
|
|
268
|
-
parse(joi.string(), 'json', {}, false); // Completely disable Logical Relation Operator
|
|
278
|
+
parse(joi.string(), 'json', {}, { logicalOpParser: false }); // Completely disable Logical Relation Operator
|
|
269
279
|
parse(joi.string(), 'open-api', {}, { logicalOpParser }); // Partially override Logical Relation Operator
|
|
270
280
|
```
|
|
271
281
|
|
|
@@ -273,19 +283,50 @@ parse(joi.string(), 'open-api', {}, { logicalOpParser }); // Partially override
|
|
|
273
283
|
|
|
274
284
|
>npm run test
|
|
275
285
|
|
|
276
|
-
|
|
286
|
+
### Categories of Test Cases
|
|
277
287
|
|
|
278
|
-
*
|
|
288
|
+
* JOI Standard Representation Conversion
|
|
279
289
|
|
|
280
|
-
|
|
290
|
+
`fixtures-conversion` folder stores each JOI version's supported keyword for different data types.
|
|
291
|
+
In case any data type or keyword is not supported in historical JOI version, we can just create customized file to override the `base` version, such as `v15/link.js`.
|
|
281
292
|
|
|
282
|
-
|
|
293
|
+
Standard converted results are stored in `outputs-conversion` folder.
|
|
294
|
+
|
|
295
|
+
`test/conversion.spec.js` Test Spec handles all supported JOI versions' conversion verificaiton.
|
|
296
|
+
|
|
297
|
+
* JSON output format Conversion
|
|
298
|
+
|
|
299
|
+
`outputs-parsers` folder stores different output formats base on the JOI Standard Representation in `outputs-conversion` folder.
|
|
300
|
+
The Test Spec under `test/parser/` are responsible for these area.
|
|
301
|
+
|
|
302
|
+
* JSON schema (Draft 07) Validity Unit Test
|
|
283
303
|
|
|
284
|
-
|
|
304
|
+
For special **Logical Relation Operator** and **Conditional Expression**, some Unit Tests are created to verify the JOI Spec and corresponding JSON Spec are valid of the same verification intention.
|
|
285
305
|
|
|
286
|
-
**Version 2.3.0**
|
|
287
306
|
|
|
288
|
-
|
|
307
|
+
### Test Debug Approach
|
|
308
|
+
|
|
309
|
+
When running `conversion.spec.js`, below environment variables can be set:
|
|
310
|
+
|
|
311
|
+
* `TEST_CONVERTOR`: control which version of joi to test.
|
|
312
|
+
Example: `TEST_CONVERTOR=v17`
|
|
313
|
+
* `TEST_CASE`: control which test cases to verify. Name of the test cases is the key of the return object in `fixtures-conversion`.
|
|
314
|
+
Example: `TEST_CASE=conditional,match_all` verifies the case in `alternatives.js`
|
|
315
|
+
* `TEST_UPDATE_CONVERSION_BASELINE`: control whether writes the baseline file generated from the latest-version convertor (Currently `v17`).
|
|
316
|
+
It is activated when setting to `true`.
|
|
317
|
+
|
|
318
|
+
When runninng Test Spec under `test/parser`, below environment variables can be set:
|
|
319
|
+
|
|
320
|
+
* `TEST_CASE`: control which test cases to verify.
|
|
321
|
+
For example, when running `json.spec.js`, and set `TEST_CASE=conditional,match_all`, it verifies the corresponding JSON files in `outputs-parsers/json/alternatives`.
|
|
322
|
+
* `TEST_UPDATE_PARSER_BASELINE`: control whether writes the baseline file for the corresponding parser.
|
|
323
|
+
It is activated when setting to `true`. For example, when running `json.spec.js`, it writes the baseline files under `outputs-parsers/json`.
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
## Known Limitation
|
|
327
|
+
|
|
328
|
+
* For `object.pattern` usage in Joi, `pattern` parameter can only be a regular expression now as I cannot convert Joi object to regex yet.
|
|
329
|
+
* `If-Then-Else` style output is not applicable for the condition referring to the other field.
|
|
289
330
|
|
|
290
331
|
## License
|
|
291
332
|
|
package/lib/convertors/v12.js
CHANGED
|
@@ -12,6 +12,16 @@ class JoiSpecConvertor {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
_convertObject(joiObj) {
|
|
15
|
+
_.each(joiObj.rules, (rule, _idx) => {
|
|
16
|
+
const name = rule.name
|
|
17
|
+
if (['min', 'max', 'length'].includes(name)) {
|
|
18
|
+
rule.args = {
|
|
19
|
+
limit: rule.arg
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
delete rule.arg
|
|
23
|
+
})
|
|
24
|
+
|
|
15
25
|
if (joiObj.children) {
|
|
16
26
|
joiObj.keys = joiObj.children
|
|
17
27
|
delete joiObj.children
|
|
@@ -30,6 +40,9 @@ class JoiSpecConvertor {
|
|
|
30
40
|
|
|
31
41
|
_convertDependencies(joiObj) {
|
|
32
42
|
_.each(joiObj.dependencies, (dependency) => {
|
|
43
|
+
if (dependency.key === null) {
|
|
44
|
+
delete dependency.key
|
|
45
|
+
}
|
|
33
46
|
if (dependency.type) {
|
|
34
47
|
dependency.rel = dependency.type
|
|
35
48
|
delete dependency.type
|
|
@@ -48,61 +61,92 @@ class JoiSpecConvertor {
|
|
|
48
61
|
}
|
|
49
62
|
|
|
50
63
|
_convertAlternatives(joiObj) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
isWhenCase = !!condition.then || !!condition.otherwise
|
|
55
|
-
}
|
|
64
|
+
// backup alternatives setting
|
|
65
|
+
const alternatives = joiObj.alternatives
|
|
66
|
+
let fieldName = 'matches'
|
|
56
67
|
|
|
57
|
-
if (
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
joiObj.whens = joiObj.alternatives
|
|
68
|
+
if (joiObj.base) {
|
|
69
|
+
// When case is based on one schema type
|
|
70
|
+
fieldName = 'whens'
|
|
61
71
|
|
|
62
|
-
|
|
63
|
-
|
|
72
|
+
const baseType = joiObj.base
|
|
73
|
+
delete joiObj.base
|
|
74
|
+
delete joiObj.alternatives
|
|
75
|
+
if (joiObj.flags && joiObj.flags.presence) {
|
|
64
76
|
delete joiObj.flags.presence
|
|
65
77
|
}
|
|
66
|
-
if (joiObj.whens.peek) {
|
|
67
|
-
joiObj.whens.is = joiObj.whens.peek
|
|
68
|
-
delete joiObj.whens.peek
|
|
69
|
-
}
|
|
70
78
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
case 'then':
|
|
83
|
-
this.toBaseSpec(value)
|
|
84
|
-
break
|
|
85
|
-
case 'ref':
|
|
86
|
-
if (value.indexOf('ref:') === 0) {
|
|
87
|
-
value = {
|
|
88
|
-
path: value.replace('ref:', '').split('.')
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
break
|
|
79
|
+
this.toBaseSpec(baseType)
|
|
80
|
+
|
|
81
|
+
_.merge(joiObj, baseType)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Convert alternatives
|
|
85
|
+
joiObj[fieldName] = _.map(alternatives, (alternative) => {
|
|
86
|
+
if (alternative.peek || alternative.ref) {
|
|
87
|
+
if (alternative.peek) {
|
|
88
|
+
alternative.is = alternative.peek
|
|
89
|
+
delete alternative.peek
|
|
92
90
|
}
|
|
93
|
-
return value
|
|
94
|
-
})
|
|
95
91
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
92
|
+
return _.mapValues(alternative, (value, condition) => {
|
|
93
|
+
switch (condition) {
|
|
94
|
+
case 'is':
|
|
95
|
+
this.toBaseSpec(value)
|
|
96
|
+
if (alternative.ref) {
|
|
97
|
+
// alternative field reference case
|
|
98
|
+
value.type = 'any'
|
|
99
|
+
value.allow.splice(0, 0, { 'override': true })
|
|
100
|
+
}
|
|
101
|
+
break
|
|
102
|
+
case 'otherwise':
|
|
103
|
+
this.toBaseSpec(value)
|
|
104
|
+
break
|
|
105
|
+
case 'then':
|
|
106
|
+
this.toBaseSpec(value)
|
|
107
|
+
break
|
|
108
|
+
case 'ref':
|
|
109
|
+
if (value.indexOf('ref:') === 0) {
|
|
110
|
+
value = {
|
|
111
|
+
path: value.replace('ref:', '').split('.')
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
return value
|
|
117
|
+
})
|
|
118
|
+
} else {
|
|
100
119
|
return {
|
|
101
120
|
schema: this.toBaseSpec(alternative)
|
|
102
121
|
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// FIXME: Joi seems merging the `is` to `then` schema
|
|
126
|
+
_.forEach(joiObj[fieldName], (alternative) => {
|
|
127
|
+
if (alternative.is && alternative.then) {
|
|
128
|
+
if (alternative.is.type === 'object') {
|
|
129
|
+
const isKeys = Object.keys(alternative.is.keys)
|
|
130
|
+
_.forEach(isKeys, (key) => {
|
|
131
|
+
delete alternative.then.keys[key]
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
delete joiObj.alternatives
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
_convertBinary(joiObj) {
|
|
141
|
+
_.each(joiObj.rules, (rule, _idx) => {
|
|
142
|
+
const name = rule.name
|
|
143
|
+
if (['min', 'max', 'length'].includes(name)) {
|
|
144
|
+
rule.args = {
|
|
145
|
+
limit: rule.arg
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
delete rule.arg
|
|
149
|
+
})
|
|
106
150
|
}
|
|
107
151
|
|
|
108
152
|
_convertString(joiObj) {
|
|
@@ -162,6 +206,18 @@ class JoiSpecConvertor {
|
|
|
162
206
|
}
|
|
163
207
|
|
|
164
208
|
_convertBoolean(joiObj) {
|
|
209
|
+
if (_.isArray(joiObj.truthy)) {
|
|
210
|
+
_.remove(joiObj.truthy, (i) => i === true)
|
|
211
|
+
if (joiObj.truthy.length === 0) {
|
|
212
|
+
delete joiObj.truthy
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (_.isArray(joiObj.falsy)) {
|
|
216
|
+
_.remove(joiObj.falsy, (i) => i === false)
|
|
217
|
+
if (joiObj.falsy.length === 0) {
|
|
218
|
+
delete joiObj.falsy
|
|
219
|
+
}
|
|
220
|
+
}
|
|
165
221
|
if (joiObj.flags.insensitive === false) {
|
|
166
222
|
joiObj.flags.sensitive = true
|
|
167
223
|
}
|
|
@@ -180,6 +236,12 @@ class JoiSpecConvertor {
|
|
|
180
236
|
limit: rule.arg
|
|
181
237
|
}
|
|
182
238
|
}
|
|
239
|
+
if (name === 'has') {
|
|
240
|
+
this.toBaseSpec(rule.arg)
|
|
241
|
+
rule.args = {
|
|
242
|
+
schema: rule.arg
|
|
243
|
+
}
|
|
244
|
+
}
|
|
183
245
|
delete rule.arg
|
|
184
246
|
})
|
|
185
247
|
|
|
@@ -196,12 +258,12 @@ class JoiSpecConvertor {
|
|
|
196
258
|
|
|
197
259
|
if (joiObj.flags.allowOnly) {
|
|
198
260
|
joiObj.flags.only = joiObj.flags.allowOnly
|
|
199
|
-
delete joiObj.flags.allowOnly
|
|
200
261
|
}
|
|
262
|
+
delete joiObj.flags.allowOnly
|
|
201
263
|
if (joiObj.flags.allowUnknown) {
|
|
202
264
|
joiObj.flags.unknown = joiObj.flags.allowUnknown
|
|
203
|
-
delete joiObj.flags.allowUnknown
|
|
204
265
|
}
|
|
266
|
+
delete joiObj.flags.allowUnknown
|
|
205
267
|
if (joiObj.description) {
|
|
206
268
|
joiObj.flags.description = joiObj.description
|
|
207
269
|
delete joiObj.description
|
|
@@ -218,6 +280,13 @@ class JoiSpecConvertor {
|
|
|
218
280
|
joiObj.metas = joiObj.meta
|
|
219
281
|
delete joiObj.meta
|
|
220
282
|
}
|
|
283
|
+
if (joiObj.invalids) {
|
|
284
|
+
// FIXME Looks like before v16, joi spec default add [''] for string and [Infinity,-Infinity] for number
|
|
285
|
+
if (!(joiObj.invalids.length === 1 && joiObj.invalids[0] === '') &&
|
|
286
|
+
!(joiObj.invalids.length === 2 && joiObj.invalids[0] === Infinity && joiObj.invalids[1] === -Infinity)) {
|
|
287
|
+
joiObj.invalid = joiObj.invalids
|
|
288
|
+
}
|
|
289
|
+
}
|
|
221
290
|
delete joiObj.invalids
|
|
222
291
|
|
|
223
292
|
this._convertExamples(joiObj)
|
|
@@ -232,6 +301,9 @@ class JoiSpecConvertor {
|
|
|
232
301
|
case 'alternatives':
|
|
233
302
|
this._convertAlternatives(joiObj)
|
|
234
303
|
break
|
|
304
|
+
case 'binary':
|
|
305
|
+
this._convertBinary(joiObj)
|
|
306
|
+
break
|
|
235
307
|
case 'string':
|
|
236
308
|
this._convertString(joiObj)
|
|
237
309
|
break
|
package/lib/convertors/v14.js
CHANGED
|
@@ -10,6 +10,9 @@ class JoiSpecConvertor extends BaseConverter {
|
|
|
10
10
|
const example = joiObj.examples[0]
|
|
11
11
|
joiObj.examples = [example.value]
|
|
12
12
|
}
|
|
13
|
+
if (joiObj.examples && joiObj.allow && joiObj.flags && joiObj.flags.only) {
|
|
14
|
+
joiObj.examples = joiObj.allow
|
|
15
|
+
}
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
_convertNumber(joiObj) {
|
package/lib/convertors/v16.js
CHANGED
|
@@ -15,6 +15,10 @@ class JoiJsonDraftSchemaParser extends JoiJsonSchemaParser {
|
|
|
15
15
|
}, opts))
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
_isIfThenElseSupported() {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
|
|
18
22
|
_setNumberFieldProperties(fieldSchema, fieldDefn) {
|
|
19
23
|
super._setNumberFieldProperties(fieldSchema, fieldDefn)
|
|
20
24
|
|
|
@@ -29,6 +33,29 @@ class JoiJsonDraftSchemaParser extends JoiJsonSchemaParser {
|
|
|
29
33
|
_getLocalSchemaBasePath() {
|
|
30
34
|
return '#/definitions'
|
|
31
35
|
}
|
|
36
|
+
|
|
37
|
+
_setArrayFieldProperties(fieldSchema, fieldDefn, definitions, level) {
|
|
38
|
+
super._setArrayFieldProperties(fieldSchema, fieldDefn, definitions, level)
|
|
39
|
+
|
|
40
|
+
delete fieldSchema.contains
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_setConst(fieldSchema, fieldDefn) {
|
|
44
|
+
super._setConst(fieldSchema, fieldDefn)
|
|
45
|
+
|
|
46
|
+
if (typeof fieldSchema.const !== 'undefined') {
|
|
47
|
+
if (fieldSchema.const === null) {
|
|
48
|
+
fieldSchema.type = 'null'
|
|
49
|
+
} else if (_.isArray(fieldSchema.const)) {
|
|
50
|
+
fieldSchema.type = 'array'
|
|
51
|
+
} else {
|
|
52
|
+
// boolean / number / string / object
|
|
53
|
+
fieldSchema.type = typeof fieldSchema.const
|
|
54
|
+
}
|
|
55
|
+
fieldSchema.enum = [fieldSchema.const]
|
|
56
|
+
delete fieldSchema.const
|
|
57
|
+
}
|
|
58
|
+
}
|
|
32
59
|
}
|
|
33
60
|
|
|
34
61
|
module.exports = JoiJsonDraftSchemaParser
|
|
@@ -8,7 +8,7 @@ class JoiJsonDraftSchemaParser extends JoiJsonSchemaParser {
|
|
|
8
8
|
if (!_.isEmpty(joiSpec.metas)) {
|
|
9
9
|
_.each(joiSpec.metas, meta => {
|
|
10
10
|
_.each(meta, (value, key) => {
|
|
11
|
-
if (key === 'deprecated') {
|
|
11
|
+
if (key === 'deprecated' || key === 'readOnly' || key === 'writeOnly') {
|
|
12
12
|
schema[key] = value
|
|
13
13
|
}
|
|
14
14
|
})
|
package/lib/parsers/json.js
CHANGED
|
@@ -111,10 +111,12 @@ class JoiJsonSchemaParser {
|
|
|
111
111
|
this._setArrayFieldProperties(schema, joiSpec, definitions, level)
|
|
112
112
|
this._setObjectProperties(schema, joiSpec, definitions, level)
|
|
113
113
|
this._setAlternativesProperties(schema, joiSpec, definitions, level)
|
|
114
|
-
this.
|
|
115
|
-
this._addNullTypeIfNullable(schema, joiSpec)
|
|
114
|
+
this._setConditionProperties(schema, joiSpec, definitions, level, 'whens')
|
|
116
115
|
this._setMetaProperties(schema, joiSpec)
|
|
117
116
|
this._setLinkFieldProperties(schema, joiSpec)
|
|
117
|
+
this._setConst(schema, joiSpec)
|
|
118
|
+
this._setAnyProperties(schema, joiSpec, definitions, level)
|
|
119
|
+
this._addNullTypeIfNullable(schema, joiSpec)
|
|
118
120
|
|
|
119
121
|
if (!_.isEmpty(joiSpec.shared)) {
|
|
120
122
|
this.parse(joiSpec.shared[0], definitions, level)
|
|
@@ -134,6 +136,10 @@ class JoiJsonSchemaParser {
|
|
|
134
136
|
return schema
|
|
135
137
|
}
|
|
136
138
|
|
|
139
|
+
_isIfThenElseSupported() {
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
|
|
137
143
|
_getChildrenFieldName() {
|
|
138
144
|
return 'keys'
|
|
139
145
|
}
|
|
@@ -173,9 +179,21 @@ class JoiJsonSchemaParser {
|
|
|
173
179
|
|
|
174
180
|
_addNullTypeIfNullable(fieldSchema, fieldDefn) {
|
|
175
181
|
// This should always be the last call in parse
|
|
182
|
+
if (fieldSchema.const === null) {
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
176
186
|
const enums = _.get(fieldDefn, this.enumFieldName)
|
|
177
187
|
if (Array.isArray(enums) && enums.includes(null)) {
|
|
178
|
-
|
|
188
|
+
if (Array.isArray(fieldSchema.type)) {
|
|
189
|
+
if (!fieldSchema.type.includes('null')) {
|
|
190
|
+
fieldSchema.type.push('null')
|
|
191
|
+
}
|
|
192
|
+
} else if (fieldSchema.type) {
|
|
193
|
+
fieldSchema.type = [fieldSchema.type, 'null']
|
|
194
|
+
} else {
|
|
195
|
+
fieldSchema.type = 'null'
|
|
196
|
+
}
|
|
179
197
|
}
|
|
180
198
|
}
|
|
181
199
|
|
|
@@ -200,13 +218,6 @@ class JoiJsonSchemaParser {
|
|
|
200
218
|
return _.get(fieldDefn, 'flags.default')
|
|
201
219
|
}
|
|
202
220
|
|
|
203
|
-
_getConst(fieldDefn) {
|
|
204
|
-
const enumList = fieldDefn[this.enumFieldName]
|
|
205
|
-
if (fieldDefn.flags && fieldDefn.flags.only && _.size(enumList) === 1) {
|
|
206
|
-
return enumList[0]
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
221
|
_getEnum(fieldDefn) {
|
|
211
222
|
const enumList = fieldDefn[this.enumFieldName]
|
|
212
223
|
const filteredEnumList = enumList ? _.filter(enumList, e => !isJoiOverride(e)) : enumList
|
|
@@ -234,8 +245,13 @@ class JoiJsonSchemaParser {
|
|
|
234
245
|
this._setIfNotEmpty(fieldSchema, 'examples', this._getFieldExample(fieldDefn))
|
|
235
246
|
this._setIfNotEmpty(fieldSchema, 'description', this._getFieldDescription(fieldDefn))
|
|
236
247
|
this._setIfNotEmpty(fieldSchema, 'default', this._getDefaultValue(fieldDefn))
|
|
237
|
-
this._setIfNotEmpty(fieldSchema, 'const', this._getConst(fieldDefn))
|
|
238
248
|
this._setIfNotEmpty(fieldSchema, 'enum', this._getEnum(fieldDefn))
|
|
249
|
+
if (fieldDefn.invalid && fieldDefn.invalid.length > 0) {
|
|
250
|
+
fieldSchema.not = {
|
|
251
|
+
type: fieldSchema.type,
|
|
252
|
+
enum: fieldDefn.invalid
|
|
253
|
+
}
|
|
254
|
+
}
|
|
239
255
|
}
|
|
240
256
|
|
|
241
257
|
_setBinaryFieldProperties(fieldSchema, fieldDefn) {
|
|
@@ -480,6 +496,9 @@ class JoiJsonSchemaParser {
|
|
|
480
496
|
case 'unique':
|
|
481
497
|
fieldSchema.uniqueItems = true
|
|
482
498
|
break
|
|
499
|
+
case 'has':
|
|
500
|
+
fieldSchema.contains = this.parse(value.schema, definitions, level + 1)
|
|
501
|
+
break
|
|
483
502
|
default:
|
|
484
503
|
break
|
|
485
504
|
}
|
|
@@ -518,61 +537,192 @@ class JoiJsonSchemaParser {
|
|
|
518
537
|
}
|
|
519
538
|
|
|
520
539
|
_setAlternativesProperties(schema, joiSpec, definitions, level) {
|
|
521
|
-
if (schema.type !== 'alternatives') {
|
|
540
|
+
if (schema.type !== 'alternatives' || joiSpec.matches.length === 0) {
|
|
522
541
|
return
|
|
523
542
|
}
|
|
524
543
|
|
|
525
|
-
if (joiSpec.matches.
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
schema.oneOf = []
|
|
533
|
-
if (match.then) schema.oneOf.push(this.parse(match.then, definitions, level + 1))
|
|
534
|
-
if (match.otherwise) schema.oneOf.push(this.parse(match.otherwise, definitions, level + 1))
|
|
544
|
+
if (joiSpec.matches[0].schema) {
|
|
545
|
+
// try style
|
|
546
|
+
let mode = 'anyOf'
|
|
547
|
+
if (joiSpec.flags && joiSpec.flags.match === 'one') {
|
|
548
|
+
mode = 'oneOf'
|
|
549
|
+
} else if (joiSpec.flags && joiSpec.flags.match === 'all') {
|
|
550
|
+
mode = 'allOf'
|
|
535
551
|
}
|
|
536
|
-
|
|
537
|
-
schema
|
|
552
|
+
|
|
553
|
+
schema[mode] = _.map(joiSpec.matches, (match) => {
|
|
538
554
|
return this.parse(match.schema, definitions, level + 1)
|
|
539
555
|
})
|
|
556
|
+
|
|
557
|
+
if (schema[mode].length === 1) {
|
|
558
|
+
_.merge(schema, schema[mode][0])
|
|
559
|
+
delete schema[mode]
|
|
560
|
+
}
|
|
561
|
+
} else {
|
|
562
|
+
this._setConditionProperties(schema, joiSpec, definitions, level, 'matches', 'anyOf')
|
|
540
563
|
}
|
|
541
564
|
|
|
542
|
-
|
|
565
|
+
if (schema.type === 'alternatives') {
|
|
566
|
+
delete schema.type
|
|
567
|
+
}
|
|
543
568
|
}
|
|
544
569
|
|
|
545
|
-
|
|
546
|
-
if (
|
|
570
|
+
_setConditionProperties(schema, joiSpec, definitions, level, conditionFieldName, logicKeyword = 'allOf') {
|
|
571
|
+
if (!joiSpec[conditionFieldName] || joiSpec[conditionFieldName].length === 0) {
|
|
547
572
|
return
|
|
548
573
|
}
|
|
549
574
|
|
|
550
|
-
|
|
551
|
-
|
|
575
|
+
let ifThenStyle = this._isIfThenElseSupported()
|
|
576
|
+
const styleSetting = _.remove(joiSpec.metas, (meta) => {
|
|
577
|
+
return typeof meta['if-style'] !== undefined
|
|
578
|
+
})
|
|
552
579
|
|
|
553
|
-
|
|
580
|
+
if (ifThenStyle && styleSetting.length > 0 && styleSetting[0]['if-style'] === false) {
|
|
581
|
+
ifThenStyle = false
|
|
582
|
+
}
|
|
554
583
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
584
|
+
if (joiSpec[conditionFieldName].length > 1) {
|
|
585
|
+
// Multiple case
|
|
586
|
+
schema[logicKeyword] = _.map(joiSpec[conditionFieldName], (condition) => {
|
|
587
|
+
return this._setConditionSchema(ifThenStyle, {}, condition, definitions, level + 1)
|
|
588
|
+
})
|
|
589
|
+
} else {
|
|
590
|
+
this._setConditionSchema(ifThenStyle, schema, joiSpec[conditionFieldName][0], definitions, level + 1)
|
|
591
|
+
}
|
|
592
|
+
}
|
|
560
593
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
594
|
+
_setConditionSchema(ifThenStyle, schema, conditionJoiSpec, definitions, level) {
|
|
595
|
+
// When "if" is not present, both "then" and "else" MUST be entirely ignored.
|
|
596
|
+
// There must be either "is" or "switch"
|
|
597
|
+
if (!conditionJoiSpec.is && !conditionJoiSpec.switch) {
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (conditionJoiSpec.switch) {
|
|
602
|
+
this._parseSwitchCondition(ifThenStyle, schema, conditionJoiSpec, definitions, level)
|
|
603
|
+
} else {
|
|
604
|
+
if (ifThenStyle) {
|
|
605
|
+
this._parseIfThenElseCondition(schema, conditionJoiSpec, definitions, level)
|
|
606
|
+
} else {
|
|
607
|
+
this._setConditionCompositionStyle(schema, conditionJoiSpec, definitions, level)
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return schema
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
_parseIfThenElseCondition(schema, conditionSpec, definitions, level) {
|
|
615
|
+
if (conditionSpec.ref) {
|
|
616
|
+
// Currently, if there is reference, if-then-else style is not supported
|
|
617
|
+
// To use it, the condition must be defined in parent level using schema-style is
|
|
618
|
+
return this._setConditionCompositionStyle(schema, conditionSpec, definitions, level)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
schema.if = this.parse(conditionSpec.is, definitions, level)
|
|
622
|
+
|
|
623
|
+
if (conditionSpec.then) {
|
|
624
|
+
schema.then = this.parse(conditionSpec.then, definitions, level)
|
|
625
|
+
}
|
|
626
|
+
if (conditionSpec.otherwise) {
|
|
627
|
+
schema.else = this.parse(conditionSpec.otherwise, definitions, level)
|
|
628
|
+
}
|
|
629
|
+
return schema
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
_parseSwitchCondition(ifThenStyle, schema, condition, definitions, level) {
|
|
633
|
+
// Switch cannot be used if the joi condition is a schema
|
|
634
|
+
// Hence, condition.ref should always exists
|
|
635
|
+
const anyOf = []
|
|
636
|
+
for (const switchCondition of condition.switch) {
|
|
637
|
+
if (ifThenStyle && !condition.ref) {
|
|
638
|
+
const innerSchema = this._parseIfThenElseCondition(
|
|
639
|
+
{}, switchCondition, definitions, level + 1)
|
|
640
|
+
|
|
641
|
+
anyOf.push(innerSchema)
|
|
642
|
+
} else {
|
|
643
|
+
if (switchCondition.then) {
|
|
644
|
+
anyOf.push(this.parse(switchCondition.then, definitions, level + 1))
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (switchCondition.otherwise) {
|
|
648
|
+
anyOf.push(this.parse(switchCondition.otherwise, definitions, level + 1))
|
|
564
649
|
}
|
|
565
650
|
}
|
|
651
|
+
}
|
|
566
652
|
|
|
653
|
+
if (anyOf.length > 1) {
|
|
654
|
+
schema.anyOf = anyOf
|
|
655
|
+
} else {
|
|
656
|
+
_.merge(schema, oneOf[0])
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
_setConditionCompositionStyle(schema, condition, definitions, level) {
|
|
661
|
+
if (condition.ref) {
|
|
662
|
+
// If the condition is refering to other field, cannot use the `is` condition for composition
|
|
663
|
+
// Simple choise between then / otherwise
|
|
664
|
+
const oneOf = schema.oneOf || []
|
|
567
665
|
if (condition.then) {
|
|
568
|
-
|
|
666
|
+
oneOf.push(this.parse(condition.then, definitions, level + 1))
|
|
569
667
|
}
|
|
570
|
-
|
|
571
668
|
if (condition.otherwise) {
|
|
572
|
-
|
|
669
|
+
oneOf.push(this.parse(condition.otherwise, definitions, level + 1))
|
|
573
670
|
}
|
|
671
|
+
if (oneOf.length > 1) {
|
|
672
|
+
schema.oneOf = oneOf
|
|
673
|
+
} else {
|
|
674
|
+
_.merge(schema, oneOf[0])
|
|
675
|
+
}
|
|
676
|
+
} else {
|
|
677
|
+
// Before Draft 7, you can express an “if-then” conditional using the Schema Composition keywords
|
|
678
|
+
// and a boolean algebra concept called “implication”.
|
|
679
|
+
// A -> B(pronounced, A implies B) means that if A is true, then B must also be true.
|
|
680
|
+
// It can be expressed as !A || B
|
|
681
|
+
|
|
682
|
+
// Variations of implication can express the same things you can express with the if/then/else keywords.
|
|
683
|
+
// if/then can be expressed as A -> B, if/else can be expressed as !A -> B,
|
|
684
|
+
// and if/then/else can be expressed as A -> B AND !A -> C
|
|
685
|
+
if (condition.is && condition.then && condition.otherwise) {
|
|
686
|
+
schema.allOf = [
|
|
687
|
+
{
|
|
688
|
+
anyOf: [
|
|
689
|
+
{
|
|
690
|
+
not: this.parse(condition.is, definitions, level + 1)
|
|
691
|
+
},
|
|
692
|
+
this.parse(condition.then, definitions, level + 1)
|
|
693
|
+
]
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
anyOf: [
|
|
697
|
+
this.parse(condition.is, definitions, level + 1),
|
|
698
|
+
this.parse(condition.otherwise, definitions, level + 1)
|
|
699
|
+
]
|
|
700
|
+
}
|
|
701
|
+
]
|
|
702
|
+
} else if (condition.is && condition.then) {
|
|
703
|
+
schema.anyOf = [
|
|
704
|
+
{
|
|
705
|
+
not: this.parse(condition.is, definitions, level + 1)
|
|
706
|
+
},
|
|
707
|
+
this.parse(condition.then, definitions, level + 1)
|
|
708
|
+
]
|
|
709
|
+
}
|
|
710
|
+
}
|
|
574
711
|
|
|
712
|
+
return schema
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
_setConst(schema, fieldDefn) {
|
|
716
|
+
const enumList = fieldDefn[this.enumFieldName]
|
|
717
|
+
const filteredEnumList = enumList ? _.filter(enumList, e => !isJoiOverride(e)) : enumList
|
|
718
|
+
if (fieldDefn.flags && fieldDefn.flags.only && _.size(filteredEnumList) === 1) {
|
|
719
|
+
schema.const = filteredEnumList[0]
|
|
575
720
|
delete schema.type
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
_setAnyProperties(schema) {
|
|
725
|
+
if (schema.type !== 'any') {
|
|
576
726
|
return
|
|
577
727
|
}
|
|
578
728
|
|
package/lib/parsers/open-api.js
CHANGED
|
@@ -6,7 +6,8 @@ class JoiOpenApiSchemaParser extends JoiJsonSchemaParser {
|
|
|
6
6
|
super(_.merge({
|
|
7
7
|
logicalOpParser: {
|
|
8
8
|
xor: null,
|
|
9
|
-
with: null
|
|
9
|
+
with: null,
|
|
10
|
+
without: null
|
|
10
11
|
}
|
|
11
12
|
}, opts))
|
|
12
13
|
}
|
|
@@ -69,6 +70,10 @@ class JoiOpenApiSchemaParser extends JoiJsonSchemaParser {
|
|
|
69
70
|
return schema
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
_isIfThenElseSupported() {
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
|
|
72
77
|
_getLocalSchemaBasePath() {
|
|
73
78
|
return '#/components/schemas'
|
|
74
79
|
}
|
|
@@ -82,9 +87,41 @@ class JoiOpenApiSchemaParser extends JoiJsonSchemaParser {
|
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
_addNullTypeIfNullable(fieldSchema, fieldDefn) {
|
|
85
|
-
|
|
86
|
-
|
|
90
|
+
super._addNullTypeIfNullable(fieldSchema, fieldDefn)
|
|
91
|
+
|
|
92
|
+
if (fieldSchema.type === 'null') {
|
|
93
|
+
delete fieldSchema.type
|
|
94
|
+
fieldSchema.nullable = true
|
|
95
|
+
} else if (_.isArray(fieldSchema.type) && fieldSchema.type.includes('null')) {
|
|
96
|
+
_.remove(fieldSchema.type, (i) => {
|
|
97
|
+
return i === 'null'
|
|
98
|
+
})
|
|
87
99
|
fieldSchema.nullable = true
|
|
100
|
+
|
|
101
|
+
if (fieldSchema.type.length === 1) {
|
|
102
|
+
fieldSchema.type = fieldSchema.type[0]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (_.isArray(fieldSchema.type)) {
|
|
107
|
+
// anyOf might exist for When / Alternative case
|
|
108
|
+
fieldSchema.anyOf = fieldSchema.anyOf || []
|
|
109
|
+
|
|
110
|
+
_.forEach(fieldSchema.type, (t) => {
|
|
111
|
+
const typeExisted = _.some(fieldSchema.anyOf, (condition) => {
|
|
112
|
+
return condition.type === t
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
if (!typeExisted) {
|
|
116
|
+
const def = { type: t }
|
|
117
|
+
if (t === 'array') {
|
|
118
|
+
def.items = {}
|
|
119
|
+
}
|
|
120
|
+
fieldSchema.anyOf.push(def)
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
delete fieldSchema.type
|
|
88
125
|
}
|
|
89
126
|
}
|
|
90
127
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "joi-to-json",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "joi to JSON / OpenAPI Schema Converter",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"repository": {
|
|
@@ -28,16 +28,17 @@
|
|
|
28
28
|
"semver-compare": "^1.0.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
+
"@jest/transform": "^29.6.2",
|
|
31
32
|
"ajv": "^8.12.0",
|
|
32
33
|
"ajv-draft-04": "^1.0.0",
|
|
33
|
-
"eslint": "^8.
|
|
34
|
-
"jest": "^29.
|
|
34
|
+
"eslint": "^8.47.0",
|
|
35
|
+
"jest": "^29.6.2",
|
|
35
36
|
"joi-12": "npm:@commercial/joi@^12.1.0",
|
|
36
37
|
"joi-13": "npm:joi@^13.7.0",
|
|
37
38
|
"joi-14": "npm:joi@^14.3.1",
|
|
38
39
|
"joi-15": "npm:@hapi/joi@^15.1.1",
|
|
39
40
|
"joi-16": "npm:@hapi/joi@^16.1.8",
|
|
40
|
-
"joi-17": "npm:joi@^17.
|
|
41
|
+
"joi-17": "npm:joi@^17.9.2"
|
|
41
42
|
},
|
|
42
43
|
"types": "index.d.ts",
|
|
43
44
|
"files": [
|