Last active
November 18, 2021 00:25
-
-
Save rcdilorenzo/bc6644b1018cd04b9f05f2b5d397b03d to your computer and use it in GitHub Desktop.
Helper to convert io-ts types to JSON Schema (to be extracted to open source NPM package)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as t from 'io-ts'; | |
import Ajv from 'ajv'; | |
import addFormats from 'ajv-formats'; | |
import { fromNullable } from 'io-ts-types/lib/fromNullable'; | |
import convertToJSONSchema from './convertToJSONSchema'; | |
import isoString from './isoString'; | |
const SampleObject = t.strict( | |
{ | |
aString: t.string, | |
nullableString: fromNullable(t.string, 'a default'), | |
name: t.literal('ExactMatch'), | |
flag: t.boolean, | |
optionalFlag: fromNullable(t.boolean, false), | |
flags: t.array(t.boolean), | |
nested: t.strict({ | |
key: t.string, | |
}), | |
nestedAllKeysOptional: t.strict({ | |
optionalKey: fromNullable(t.array(t.string), []), | |
}), | |
optionalNested: fromNullable( | |
t.exact( | |
t.type({ | |
aNumber: t.number, | |
}), | |
), | |
{ aNumber: 0 }, | |
), | |
undefinedValue: t.undefined, | |
fixedOptions: fromNullable(t.keyof({ one: null, two: null }), 'one'), | |
boolOrString: t.union([t.boolean, fromNullable(t.string, '')]), | |
optionalBool: t.union([t.boolean, t.undefined]), | |
undefinedOrString: t.union([ | |
fromNullable(t.undefined, undefined), | |
t.string, | |
]), | |
timestamp: isoString, | |
filters: fromNullable( | |
t.record( | |
t.string, | |
t.array( | |
t.union([t.string, t.number, t.boolean, fromNullable(t.null, null)]), | |
), | |
), | |
{}, | |
), | |
}, | |
'SampleObject', | |
); | |
test('converts basic types to json schema', () => { | |
const result = convertToJSONSchema(SampleObject); | |
// Ensure produced schema compiles with ajv | |
let ajv = new Ajv(); | |
addFormats(ajv); | |
ajv.compile(result); | |
expect(result).toEqual({ | |
$id: 'SampleObject/1', | |
type: 'object', | |
properties: { | |
aString: { type: 'string' }, | |
nullableString: { type: 'string', default: 'a default' }, | |
name: { type: 'string', const: 'ExactMatch' }, | |
flag: { type: 'boolean' }, | |
optionalFlag: { type: 'boolean', default: false }, | |
flags: { type: 'array', items: { type: 'boolean' } }, | |
nested: { | |
type: 'object', | |
properties: { | |
key: { type: 'string' }, | |
}, | |
required: ['key'], | |
}, | |
nestedAllKeysOptional: { | |
type: 'object', | |
properties: { | |
optionalKey: { | |
type: 'array', | |
items: { type: 'string' }, | |
default: [], | |
}, | |
}, | |
}, | |
optionalNested: { | |
type: 'object', | |
default: { aNumber: 0 }, | |
properties: { | |
aNumber: { type: 'number' }, | |
}, | |
required: ['aNumber'], | |
}, | |
fixedOptions: { | |
type: 'string', | |
enum: ['one', 'two'], | |
default: 'one', | |
}, | |
boolOrString: { | |
anyOf: [{ type: 'boolean' }, { type: 'string', default: '' }], | |
default: '', | |
}, | |
optionalBool: { type: 'boolean' }, | |
undefinedValue: {}, | |
undefinedOrString: { type: 'string' }, | |
timestamp: { type: 'string', format: 'date-time' }, | |
filters: { | |
type: 'object', | |
properties: {}, | |
default: {}, | |
additionalProperties: { | |
type: 'array', | |
items: { | |
anyOf: [ | |
{ type: 'string' }, | |
{ type: 'number' }, | |
{ type: 'boolean' }, | |
{ type: 'null' }, | |
], | |
}, | |
}, | |
}, | |
}, | |
required: [ | |
'aString', | |
'name', | |
'flag', | |
'flags', | |
'nested', | |
'nestedAllKeysOptional', | |
'timestamp', | |
], | |
}); | |
const sample = { | |
aString: 'stringValue', | |
name: 'ExactMatch', | |
flags: [true], | |
flag: false, | |
nested: { | |
key: 'hi', | |
}, | |
nestedAllKeysOptional: {}, | |
boolOrString: true, | |
filters: { | |
account: [null, 'testAccount'], | |
}, | |
timestamp: '2020-04-20T12:34:56.000Z', | |
}; | |
ajv = new Ajv(); | |
addFormats(ajv); | |
const valid = ajv.validate(result, sample); | |
expect(ajv.errors).toBeNull(); | |
expect(valid).toBeTruthy(); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as t from 'io-ts'; | |
import isoString from './isoString'; | |
import omit from 'lodash/omit'; | |
export type IOTSInputType = | |
| t.StringType | |
| t.BooleanType | |
| t.NumberType | |
| t.InterfaceType<any> | |
| t.ExactType<any> | |
| t.ArrayType<any> | |
| t.KeyofType<any> | |
| t.UnionType<any> | |
| t.UndefinedType | |
| t.NullType | |
| t.LiteralType<any> | |
| t.DictionaryType<any, any> | |
| typeof isoString; | |
const hasDefault = (value: IOTSInputType) => { | |
return value.decode(null)._tag === 'Right'; | |
}; | |
const withDefault = <T>(existing: T, value: IOTSInputType) => { | |
const nullableDefault = value.decode(null); | |
if (nullableDefault._tag === 'Right') { | |
return { default: nullableDefault.right, ...existing }; | |
} | |
return existing; | |
}; | |
const isUndefinedType = (value: IOTSInputType): value is t.UndefinedType => { | |
return value._tag === 'UndefinedType'; | |
}; | |
const isOptional = (value: IOTSInputType): boolean => { | |
switch (value._tag) { | |
case 'UndefinedType': | |
return true; | |
case 'UnionType': | |
return value.types.filter(isOptional).length > 0; | |
default: | |
return hasDefault(value); | |
} | |
}; | |
const recursiveConvertToJSONSchema = ( | |
value: IOTSInputType, | |
): Record<string, any> => { | |
switch (value._tag) { | |
case 'ExactType': | |
return withDefault(recursiveConvertToJSONSchema(value.type), value); | |
case 'StringType': | |
return withDefault({ type: 'string' }, value); | |
case 'BooleanType': | |
return withDefault({ type: 'boolean' }, value); | |
case 'NumberType': | |
return withDefault({ type: 'number' }, value); | |
case 'ISOStringType': | |
return withDefault({ type: 'string', format: 'date-time' }, value); | |
case 'ArrayType': | |
return withDefault( | |
{ | |
type: 'array', | |
// Array values don't make sense to have a default | |
items: omit(recursiveConvertToJSONSchema(value.type), 'default'), | |
}, | |
value, | |
); | |
case 'UndefinedType': | |
return {}; | |
case 'NullType': | |
// Null cannot have a default since JSON doesn't support undefined | |
return { type: 'null' }; | |
case 'KeyofType': | |
return withDefault( | |
{ type: 'string', enum: Object.keys(value.keys) }, | |
value, | |
); | |
case 'InterfaceType': | |
const keys = Object.keys(value.props); | |
const properties = keys.reduce((acc: Record<string, any>, key) => { | |
const valueType = value.props[key]; | |
return { | |
...acc, | |
[key]: recursiveConvertToJSONSchema(valueType), | |
}; | |
}, {}); | |
const required = keys.filter((key) => !isOptional(value.props[key])); | |
return withDefault( | |
{ | |
...(required.length > 0 ? { required } : {}), | |
type: 'object', | |
properties, | |
}, | |
value, | |
); | |
case 'UnionType': | |
const types = value.types.filter((t: any) => !isUndefinedType(t)); | |
if (types.length === 1) { | |
return withDefault(recursiveConvertToJSONSchema(types[0]), types[0]); | |
} | |
return withDefault( | |
{ | |
anyOf: types.map((valueType: any) => | |
recursiveConvertToJSONSchema(valueType), | |
), | |
}, | |
value, | |
); | |
case 'DictionaryType': | |
/* istanbul ignore if */ | |
if (value.domain._tag !== 'StringType' || hasDefault(value.domain)) { | |
throw new Error( | |
'Cannot encode dictionary with non-string keys as JSON Schema', | |
); | |
} | |
return withDefault( | |
{ | |
type: 'object', | |
properties: {}, | |
additionalProperties: recursiveConvertToJSONSchema(value.codomain), | |
}, | |
value, | |
); | |
case 'LiteralType': | |
/* istanbul ignore else */ | |
if (typeof value.value === 'string') { | |
return withDefault({ type: 'string', const: value.value }, value); | |
} | |
/* istanbul ignore next */ | |
default: | |
throw new Error(`Unknown type: ${JSON.stringify(value, null, 2)}`); | |
} | |
}; | |
const convertToJSONSchema = ( | |
value: IOTSInputType, | |
version = 1, | |
): Record<string, any> => { | |
return { | |
$id: `${value.name}/${version}`, | |
...recursiveConvertToJSONSchema(value), | |
}; | |
}; | |
export default convertToJSONSchema; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import isoString from './isoString'; | |
import { fail } from 'assert'; | |
test('converts a valid number to an ISO string', () => { | |
const result = isoString.decode(1572978600000); | |
if (result._tag === 'Left') { | |
fail(`Expected result to not fail: ${result}`); | |
} | |
expect(result.right).toEqual('2019-11-05T18:30:00.000Z'); | |
}); | |
test('encodes value', () => { | |
const value = '2019-11-05T18:30:00.000Z'; | |
const result = isoString.encode(value); | |
expect(result.toString()).toEqual(value); | |
}); | |
test('returns error with invalid number', () => { | |
const result = isoString.decode('blah'); | |
expect(result._tag).toEqual('Left'); | |
}); | |
test('determines if valid', () => { | |
expect(isoString.is('not-a-number')).toBeFalsy(); | |
expect(isoString.is(0)).toBeTruthy(); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as t from 'io-ts'; | |
const isISOString = (value: any) => { | |
return value != null && !isNaN(new Date(value).getTime()); | |
}; | |
class ISOStringType extends t.Type<string | number> { | |
_tag: 'ISOStringType'; | |
constructor() { | |
super( | |
'ISOStringType', | |
(value: any): value is string => isISOString(value), | |
(u, c) => | |
isISOString(u) | |
? t.success(new Date(u as any).toISOString()) | |
: t.failure(u, c), | |
value => new Date(value).toISOString() | |
); | |
} | |
} | |
ISOStringType.prototype._tag = 'ISOStringType'; | |
const isoString: ISOStringType = new ISOStringType(); | |
export default isoString; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment