Skip to content

Instantly share code, notes, and snippets.

@rcdilorenzo
Last active November 18, 2021 00:25
Show Gist options
  • Save rcdilorenzo/bc6644b1018cd04b9f05f2b5d397b03d to your computer and use it in GitHub Desktop.
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)
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();
});
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;
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();
});
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