joi-to-json 4.3.2 → 5.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 CHANGED
@@ -10,31 +10,16 @@ That is why I build [joi-route-to-swagger](https://github.com/kenspirit/joi-rout
10
10
 
11
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
- 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
-
15
- The implementation of this JOI to JSON conversion tool is simply a pipeline of two components:
16
-
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)
20
-
21
- 2. Parsers
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, the baseline format.
22
14
  - Each supported output JSON format (e.g. JSON Draft 07, OpenAPI) has one parser implementation.
23
15
  - All parsers converts the baseline format into its own format
24
16
 
25
17
 
26
18
  ## Joi Version Support
27
19
 
28
- * @commercial/joi
29
- * v12.1.0
30
20
  * joi
31
- * 13.7.0
32
- * 14.3.1
33
- * @hapi/joi
34
- * 15.1.1
35
- * 16.1.8
36
- * joi
37
- * 17.9.2
21
+ * 17.13.3
22
+ * 18.0.0
38
23
 
39
24
  Although the versions chosen are the latest one for each major version, It should support other minor versions as well.
40
25
 
@@ -300,16 +285,13 @@ parse(joi.string(), 'open-api', {}, { logicalOpParser }); // Partially override
300
285
 
301
286
  * JOI Standard Representation Conversion
302
287
 
303
- `fixtures-conversion` folder stores each JOI version's supported keyword for different data types.
304
- 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`.
305
-
306
- Standard converted results are stored in `outputs-conversion` folder.
307
-
308
- `test/conversion.spec.js` Test Spec handles all supported JOI versions' conversion verificaiton.
288
+ `fixtures-baseline` folder stores JOI schema definition for different data types.
289
+ `outputs-baseline` folder stores expected output for `describe()` api for schemas in `fixtures-baseline` folder.
290
+ `test/conversion.spec.js` Test Spec verifies expected output for `describe()` api for schemas in `fixtures-baseline` folder.
309
291
 
310
292
  * JSON output format Conversion
311
293
 
312
- `outputs-parsers` folder stores different output formats base on the JOI Standard Representation in `outputs-conversion` folder.
294
+ `outputs-parsers` folder stores different output formats base on the JOI Standard Representation in `outputs-baseline` folder.
313
295
  The Test Spec under `test/parser/` are responsible for these area.
314
296
 
315
297
  * JSON schema (Draft 07) Validity Unit Test
@@ -321,12 +303,8 @@ For special **Logical Relation Operator** and **Conditional Expression**, some U
321
303
 
322
304
  When running `conversion.spec.js`, below environment variables can be set:
323
305
 
324
- * `TEST_CONVERTOR`: control which version of joi to test.
325
- Example: `TEST_CONVERTOR=v17`
326
- * `TEST_CASE`: control which test cases to verify. Name of the test cases is the key of the return object in `fixtures-conversion`.
306
+ * `TEST_CASE`: control which test cases to verify. Name of the test cases is the key of the return object in `fixtures-baseline`.
327
307
  Example: `TEST_CASE=conditional,match_all` verifies the case in `alternatives.js`
328
- * `TEST_UPDATE_CONVERSION_BASELINE`: control whether writes the baseline file generated from the latest-version convertor (Currently `v17`).
329
- It is activated when setting to `true`.
330
308
 
331
309
  When runninng Test Spec under `test/parser`, below environment variables can be set:
332
310
 
package/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import Joi from 'joi-17';
1
+ import Joi from 'joi';
2
2
 
3
3
  /**
4
4
  * @type {string}
@@ -20,7 +20,7 @@ export interface LogicalOpParserOpts {
20
20
  oxor?: LogicalOpParserFn,
21
21
  with?: LogicalOpParserFn,
22
22
  without?: LogicalOpParserFn
23
- };
23
+ }
24
24
 
25
25
  export type ParserOptions = false | { logicalOpParser?: LogicalOpParserOpts };
26
26
 
package/index.js CHANGED
@@ -1,21 +1,9 @@
1
- const cmp = require('semver-compare')
2
-
3
- const c17 = require('./lib/convertors/v17')
4
- const c16 = require('./lib/convertors/v16')
5
- const c15 = require('./lib/convertors/v15')
6
- const c14 = require('./lib/convertors/v14')
7
- const c13 = require('./lib/convertors/v13')
8
- const c12 = require('./lib/convertors/v12')
9
-
10
1
  const JoiJsonSchemaParser = require('./lib/parsers/json')
11
2
  const JoiOpenApiSchemaParser = require('./lib/parsers/open-api')
12
3
  const JoiOpenApiThreePointOneSchemaParser = require('./lib/parsers/open-api-3.1')
13
4
  const JoiJsonDraftSchemaParser19 = require('./lib/parsers/json-draft-2019-09')
14
5
  const JoiJsonDraftSchemaParser = require('./lib/parsers/json-draft-04')
15
6
 
16
- const convertors = [
17
- c17, c16, c15, c14, c13, c12
18
- ]
19
7
  const parsers = {
20
8
  'json-draft-2019-09': JoiJsonDraftSchemaParser19,
21
9
  'json-draft-04': JoiJsonDraftSchemaParser,
@@ -29,33 +17,7 @@ function parse(joiObj, type = 'json', definitions = {}, parserOptions = {}) {
29
17
  throw new Error('Not an joi object.')
30
18
  }
31
19
 
32
- let convertor
33
-
34
- for (let i = 0; i < convertors.length; i++) {
35
- const tmpConvertor = convertors[i]
36
- try {
37
- let version = tmpConvertor.getVersion(joiObj)
38
- let result = cmp(tmpConvertor.getSupportVersion(), version)
39
- if (result <= 0) {
40
- // The first parser has smaller or equal version
41
- convertor = tmpConvertor
42
- break
43
- }
44
- } catch (e) {
45
- // Format does not match this parser version.
46
- // Skip to check the next one
47
- continue
48
- }
49
- }
50
-
51
- if (!convertor) {
52
- console.warn('No matched joi version convertor found, using the latest version')
53
- convertor = convertors[0]
54
- }
55
-
56
- // fs.writeFileSync('./joi_spec.json', JSON.stringify(joiObj.describe(), null, 2))
57
- const joiBaseSpec = new convertor().toBaseSpec(joiObj.describe())
58
- // fs.writeFileSync(`./internal_${convertor.getSupportVersion()}_${type}.json`, JSON.stringify(joiBaseSpec, null, 2))
20
+ const joiBaseSpec = joiObj.describe()
59
21
  const parser = parsers[type]
60
22
  if (!parser) {
61
23
  throw new Error(`No parser is registered for ${type}`)
@@ -89,6 +89,7 @@ class JoiJsonSchemaParser {
89
89
  this.ruleArgFieldName = this._getRuleArgFieldName()
90
90
  this.enumFieldName = this._getEnumFieldName()
91
91
  this.allowUnknownFlagName = this._getAllowUnknownFlagName()
92
+ this.inheritedPreferences = []
92
93
 
93
94
  if (opts.logicalOpParser === false) {
94
95
  this.logicalOpParser = {}
@@ -100,7 +101,12 @@ class JoiJsonSchemaParser {
100
101
  parse(joiSpec, definitions = {}, level = 0) {
101
102
  let schema = {}
102
103
 
104
+ const hasPreferences = this._pushInheritiedPreferences(joiSpec)
105
+
103
106
  if (this._getPresence(joiSpec) === 'forbidden') {
107
+ if (hasPreferences) {
108
+ this._popInheritiedPreferences()
109
+ }
104
110
  schema.not = {}
105
111
  return schema
106
112
  }
@@ -143,9 +149,28 @@ class JoiJsonSchemaParser {
143
149
  schema.$schema = this.$schema
144
150
  }
145
151
 
152
+ if (hasPreferences) {
153
+ this._popInheritiedPreferences()
154
+ }
146
155
  return schema
147
156
  }
148
157
 
158
+ _pushInheritiedPreferences(joiSpec) {
159
+ if (joiSpec.preferences) {
160
+ this.inheritedPreferences.push(_.merge({}, this._getInheritedPreferences(), joiSpec.preferences))
161
+ return true
162
+ }
163
+ return false
164
+ }
165
+
166
+ _popInheritiedPreferences() {
167
+ this.inheritedPreferences.pop()
168
+ }
169
+
170
+ _getInheritedPreferences() {
171
+ return this.inheritedPreferences[this.inheritedPreferences.length - 1] || {}
172
+ }
173
+
149
174
  _isIfThenElseSupported() {
150
175
  return true
151
176
  }
@@ -216,7 +241,8 @@ class JoiJsonSchemaParser {
216
241
  if (presence !== undefined) {
217
242
  return presence
218
243
  }
219
- return _.get(fieldDefn, `${this.optionsFieldName}.presence`)
244
+ const preferences = fieldDefn[this.optionsFieldName] || this._getInheritedPreferences()
245
+ return _.get(preferences, 'presence')
220
246
  }
221
247
 
222
248
  _isRequired(fieldDefn) {
@@ -237,11 +263,12 @@ class JoiJsonSchemaParser {
237
263
  }
238
264
 
239
265
  _getUnknown(joiSpec) {
240
- let allowUnknown = _.get(joiSpec, `${this.optionsFieldName}.allowUnknown`, false)
241
- if (joiSpec.flags && typeof joiSpec.flags[this.allowUnknownFlagName] !== 'undefined') {
242
- allowUnknown = joiSpec.flags[this.allowUnknownFlagName]
266
+ const allowUnknown = _.get(joiSpec, `flags.${this.allowUnknownFlagName}`)
267
+ if (allowUnknown !== undefined) {
268
+ return allowUnknown
243
269
  }
244
- return allowUnknown
270
+ const preferences = joiSpec[this.optionsFieldName] || this._getInheritedPreferences()
271
+ return _.get(preferences, 'allowUnknown') || false
245
272
  }
246
273
 
247
274
  _setIfNotEmpty(schema, field, value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "joi-to-json",
3
- "version": "4.3.2",
3
+ "version": "5.0.0",
4
4
  "description": "joi to JSON / OpenAPI Schema Converter",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -24,21 +24,16 @@
24
24
  ],
25
25
  "dependencies": {
26
26
  "combinations": "^1.0.0",
27
- "lodash": "^4.17.21",
28
- "semver-compare": "^1.0.0"
27
+ "lodash": "^4.17.21"
29
28
  },
30
29
  "devDependencies": {
31
- "@jest/transform": "^29.7.0",
32
- "ajv": "^8.16.0",
30
+ "@jest/transform": "^30.0.5",
31
+ "ajv": "^8.17.1",
33
32
  "ajv-draft-04": "^1.0.0",
34
- "eslint": "^8.57.0",
35
- "jest": "^29.7.0",
36
- "joi-12": "npm:@commercial/joi@^12.1.0",
37
- "joi-13": "npm:joi@^13.7.0",
38
- "joi-14": "npm:joi@^14.3.1",
39
- "joi-15": "npm:@hapi/joi@^15.1.1",
40
- "joi-16": "npm:@hapi/joi@^16.1.8",
41
- "joi-17": "npm:joi@^17.9.2"
33
+ "eslint": "^9.33.0",
34
+ "jest": "^30.0.5",
35
+ "joi": "18.0.0",
36
+ "typescript": "^5.9.2"
42
37
  },
43
38
  "types": "index.d.ts",
44
39
  "files": [
@@ -1,330 +0,0 @@
1
- const _ = require('lodash')
2
-
3
- class JoiSpecConvertor {
4
- constructor() {}
5
-
6
- static getVersion(joiObj) {
7
- return joiObj._currentJoi.version
8
- }
9
-
10
- static getSupportVersion() {
11
- return '12'
12
- }
13
-
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
-
25
- if (joiObj.children) {
26
- joiObj.keys = joiObj.children
27
- delete joiObj.children
28
- }
29
-
30
- _.each(joiObj.keys, (value, _key) => {
31
- this.toBaseSpec(value)
32
- })
33
-
34
- _.each(joiObj.patterns, (pattern, _idx) => {
35
- this.toBaseSpec(pattern.rule)
36
- })
37
-
38
- this._convertDependencies(joiObj)
39
- }
40
-
41
- _convertDependencies(joiObj) {
42
- _.each(joiObj.dependencies, (dependency) => {
43
- if (dependency.key === null) {
44
- delete dependency.key
45
- }
46
- if (dependency.type) {
47
- dependency.rel = dependency.type
48
- delete dependency.type
49
- }
50
- })
51
- }
52
-
53
- _convertDate(joiObj) {
54
- if (joiObj.flags.timestamp) {
55
- joiObj.flags.format = joiObj.flags.timestamp
56
- delete joiObj.flags.timestamp
57
- delete joiObj.flags.multiplier
58
- } else if (joiObj.flags.format = {}) {
59
- joiObj.flags.format = 'iso'
60
- }
61
- }
62
-
63
- _convertAlternatives(joiObj) {
64
- // backup alternatives setting
65
- const alternatives = joiObj.alternatives
66
- let fieldName = 'matches'
67
-
68
- if (joiObj.base) {
69
- // When case is based on one schema type
70
- fieldName = 'whens'
71
-
72
- const baseType = joiObj.base
73
- delete joiObj.base
74
- delete joiObj.alternatives
75
- if (joiObj.flags && joiObj.flags.presence) {
76
- delete joiObj.flags.presence
77
- }
78
-
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
90
- }
91
-
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 {
119
- return {
120
- schema: this.toBaseSpec(alternative)
121
- }
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
- })
150
- }
151
-
152
- _convertString(joiObj) {
153
- delete joiObj.options
154
-
155
- _.each(joiObj.rules, (rule, _idx) => {
156
- const name = rule.name
157
- if (['min', 'max'].includes(name)) {
158
- rule.args = {
159
- limit: rule.arg
160
- }
161
- } else if (['lowercase', 'uppercase'].includes(name)) {
162
- rule.name = 'case'
163
- rule.args = {
164
- direction: name.replace('case', '')
165
- }
166
- delete joiObj.flags.case
167
- } else if (rule.arg) {
168
- rule.args = {
169
- options: rule.arg
170
- }
171
- }
172
- if (name === 'regex') {
173
- rule.name = 'pattern'
174
- rule.args.regex = rule.arg.pattern.toString()
175
- delete rule.args.options.pattern
176
- }
177
- delete rule.arg
178
- })
179
- }
180
-
181
- _convertNumber(joiObj) {
182
- _.each(joiObj.rules, (rule, _idx) => {
183
- const name = rule.name
184
- if (['positive', 'negative'].includes(name)) {
185
- rule.args = {
186
- sign: name
187
- }
188
- rule.name = 'sign'
189
- }
190
- if (['greater', 'less', 'min', 'max', 'precision'].includes(name)) {
191
- rule.args = {
192
- limit: rule.arg
193
- }
194
- }
195
- if (name === 'multiple') {
196
- rule.args = {
197
- base: rule.arg
198
- }
199
- }
200
- delete rule.arg
201
- })
202
-
203
- if (joiObj.flags.precision) {
204
- delete joiObj.flags.precision
205
- }
206
- }
207
-
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
- }
221
- if (joiObj.flags.insensitive === false) {
222
- joiObj.flags.sensitive = true
223
- }
224
- delete joiObj.flags.insensitive
225
- }
226
-
227
- _convertArray(joiObj) {
228
- if (joiObj.flags.sparse === false) {
229
- delete joiObj.flags.sparse
230
- }
231
-
232
- _.each(joiObj.rules, (rule, _idx) => {
233
- const name = rule.name
234
- if (['min', 'max', 'length'].includes(name)) {
235
- rule.args = {
236
- limit: rule.arg
237
- }
238
- }
239
- if (name === 'has') {
240
- this.toBaseSpec(rule.arg)
241
- rule.args = {
242
- schema: rule.arg
243
- }
244
- }
245
- delete rule.arg
246
- })
247
-
248
- _.each(joiObj.items, (item, _idx) => {
249
- this.toBaseSpec(item)
250
- })
251
- }
252
-
253
- _convertExamples(_joiObj) {
254
- }
255
-
256
- toBaseSpec(joiObj) {
257
- joiObj.flags = joiObj.flags || {}
258
-
259
- if (joiObj.flags.allowOnly) {
260
- joiObj.flags.only = joiObj.flags.allowOnly
261
- }
262
- delete joiObj.flags.allowOnly
263
- if (joiObj.flags.allowUnknown) {
264
- joiObj.flags.unknown = joiObj.flags.allowUnknown
265
- }
266
- delete joiObj.flags.allowUnknown
267
- if (joiObj.description) {
268
- joiObj.flags.description = joiObj.description
269
- delete joiObj.description
270
- }
271
- if (joiObj.options) {
272
- joiObj.preferences = joiObj.options
273
- delete joiObj.options
274
- }
275
- if (joiObj.valids) {
276
- joiObj.allow = joiObj.valids
277
- delete joiObj.valids
278
- }
279
- if (joiObj.meta) {
280
- joiObj.metas = joiObj.meta
281
- delete joiObj.meta
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
- }
290
- delete joiObj.invalids
291
-
292
- this._convertExamples(joiObj)
293
-
294
- switch (joiObj.type) {
295
- case 'object':
296
- this._convertObject(joiObj)
297
- break
298
- case 'date':
299
- this._convertDate(joiObj)
300
- break
301
- case 'alternatives':
302
- this._convertAlternatives(joiObj)
303
- break
304
- case 'binary':
305
- this._convertBinary(joiObj)
306
- break
307
- case 'string':
308
- this._convertString(joiObj)
309
- break
310
- case 'number':
311
- this._convertNumber(joiObj)
312
- break
313
- case 'boolean':
314
- this._convertBoolean(joiObj)
315
- break
316
- case 'array':
317
- this._convertArray(joiObj)
318
- break
319
- default:
320
- break
321
- }
322
- if (_.isEmpty(joiObj.flags)) {
323
- delete joiObj.flags
324
- }
325
-
326
- return joiObj
327
- }
328
- }
329
-
330
- module.exports = JoiSpecConvertor
@@ -1,9 +0,0 @@
1
- const BaseConverter = require('./v12')
2
-
3
- class JoiSpecConvertor extends BaseConverter {
4
- static getSupportVersion() {
5
- return '13'
6
- }
7
- }
8
-
9
- module.exports = JoiSpecConvertor
@@ -1,27 +0,0 @@
1
- const BaseConverter = require('./v13')
2
-
3
- class JoiSpecConvertor extends BaseConverter {
4
- static getSupportVersion() {
5
- return '14'
6
- }
7
-
8
- _convertExamples(joiObj) {
9
- if (joiObj.examples && joiObj.examples.length === 1) {
10
- const example = joiObj.examples[0]
11
- joiObj.examples = [example.value]
12
- }
13
- if (joiObj.examples && joiObj.allow && joiObj.flags && joiObj.flags.only) {
14
- joiObj.examples = joiObj.allow
15
- }
16
- }
17
-
18
- _convertNumber(joiObj) {
19
- super._convertNumber(joiObj)
20
-
21
- if (joiObj.flags) {
22
- delete joiObj.flags.unsafe
23
- }
24
- }
25
- }
26
-
27
- module.exports = JoiSpecConvertor
@@ -1,9 +0,0 @@
1
- const BaseConverter = require('./v14')
2
-
3
- class JoiSpecConvertor extends BaseConverter {
4
- static getSupportVersion() {
5
- return '15'
6
- }
7
- }
8
-
9
- module.exports = JoiSpecConvertor
@@ -1,32 +0,0 @@
1
- const BaseConverter = require('./v15')
2
-
3
- class JoiSpecConvertor extends BaseConverter {
4
- static getVersion(joiObj) {
5
- return joiObj.$_root.version
6
- }
7
-
8
- static getSupportVersion() {
9
- return '16'
10
- }
11
-
12
- // All methods are overridden because the Joi v16.1.8 spec is closed to the latest version
13
- _convertExamples(_joiObj) { }
14
-
15
- _convertObject(_joiObj) { }
16
-
17
- _convertDate(_joiObj) { }
18
-
19
- _convertAlternatives(_joiObj) { }
20
-
21
- _convertBinary(_joiObj) { }
22
-
23
- _convertString(_joiObj) { }
24
-
25
- _convertNumber(_joiObj) { }
26
-
27
- _convertBoolean(_joiObj) { }
28
-
29
- _convertArray(_joiObj) {}
30
- }
31
-
32
- module.exports = JoiSpecConvertor
@@ -1,9 +0,0 @@
1
- const BaseConverter = require('./v16')
2
-
3
- class JoiSpecConvertor extends BaseConverter {
4
- static getSupportVersion() {
5
- return '17'
6
- }
7
- }
8
-
9
- module.exports = JoiSpecConvertor