Skip to content

Instantly share code, notes, and snippets.

@nktknshn
Last active August 6, 2022 15:26
Show Gist options
  • Save nktknshn/b8e8c845cfcd028313793f6783ac7027 to your computer and use it in GitHub Desktop.
Save nktknshn/b8e8c845cfcd028313793f6783ac7027 to your computer and use it in GitHub Desktop.
Generate io-ts codecs and types for JSON data from a set of samples using jq, quicktype and io-ts-codegen
/*
Generate io-ts codecs and types for JSON data from a set of samples using jq, quicktype and io-ts-codegen
Usage:
# collect samples
for i in $(seq 1 10); do
wget -P samples/ https://api.github.com/events && sleep 2
done
# generate types and codecs
cat samples/* | jq -s |
./node_modules/.bin/quicktype --lang schema --no-enums |
./node_modules/.bin/ts-node schema_to_types.ts > generated.ts
# test it
curl https://api.github.com/events | ./node_modules/.bin/ts-node generated.ts
Arguments:
'--use-option' use Option for nullable and optional properties
'--null-to-any' map 'null' properties to 'any' or 'Option<any>'
*/
import { lookup } from 'fp-ts/lib/Array';
import { chain, fold, fromNullable } from 'fp-ts/lib/Option';
import { pipe } from 'fp-ts/lib/pipeable';
import * as t from 'io-ts-codegen';
import { JSONSchema4 as JSONSchema } from "json-schema";
import * as _ from 'lodash';
import * as readline from 'readline';
const config = {
nullToAny: process.argv.indexOf('--null-to-any') > -1,
useOption: process.argv.indexOf('--use-option') > -1,
}
export function readStdin() {
return new Promise<string[]>((resolve, reject) => {
let rl = readline.createInterface({ input: process.stdin, });
var lines: string[] = [];
rl.on('line', (line) => { lines.push(line) })
rl.on('close', () => { resolve(lines) })
})
}
function refUriToName(uri: string) {
const re = /#\/definitions\/([a-zA-Z0-9_]+)/
return pipe(
fromNullable(uri.match(re)),
chain(matches => lookup(1, matches)),
)
}
function isValidName(name: string) {
return /^[a-zA-Z_]/.test(name)
}
function sanitizeName(name: string) {
if (!isValidName(name))
return 'T_' + name
return name
}
function lower(entityName: string) {
return entityName[0].toLocaleLowerCase() + entityName.substr(1)
}
function getRequiredProperties(schema: JSONSchema): { [key: string]: true } {
const required: { [key: string]: true } = {}
if (schema.required) {
schema.required.forEach(function (k) {
required[k] = true
})
}
return required
}
function optionCombinator(schema: JSONSchema) {
const decl = toType(schema)
const deps = t.getNodeDependencies(decl)
return t.customCombinator(
`Option<${t.printStatic(decl)}>`,
`optionFromNullable(${t.printRuntime(decl)})`,
deps
)
}
function toInterfaceCombinator(schema: JSONSchema) {
// handle { [key: string]: Type } type
if (_.isObject(schema.additionalProperties) && '$ref' in schema.additionalProperties)
return t.recordCombinator(t.stringType, toType(schema.additionalProperties))
// handle object type
else {
const required = getRequiredProperties(schema)
const keys = Object.keys(schema.properties || {})
keys.forEach(key => {
if (!isValidName(key))
console.error(`Filtering out '${key}' which is not a valid property name`)
})
return t.interfaceCombinator(
keys
.filter(isValidName)
.map(key =>
required.hasOwnProperty(key)
? t.property(key, toType(schema.properties![key])) // required property
: config.useOption
? t.property(key, optionCombinator(schema.properties![key])) // Option<Type>
: t.property(key, toType(schema.properties![key]), true) // optional property
)
)
}
}
function toUnionCombinator(types: JSONSchema[]): t.TypeReference {
const nonNullTypes = types.filter(_ => _.type !== 'null')
// if the value isn't nullable make a union
if (nonNullTypes.length == types.length)
return t.unionCombinator(types.map(toType))
// if the value can be null
else
// produce Option
if (config.useOption)
return optionCombinator(
nonNullTypes.length > 1
? { 'anyOf': nonNullTypes } // (Type1 | Type2 | ... | null) => Option<Type1 | Type2 | ...>
: nonNullTypes[0] // (Type1 | null) => Option<Type1>
)
else
// or nullable type
return t.unionCombinator([...nonNullTypes.map(toType), t.nullType])
}
export function toType(schema: JSONSchema): t.TypeReference {
switch (schema.type) {
case 'string':
if (schema.enum)
// enum maps to a union of literals
if (schema.enum.length > 1)
return t.unionCombinator(
schema.enum.map(s => t.literalCombinator(String(s)))
)
// single value enum maps to a literal
else
return t.literalCombinator(String(schema.enum[0]))
else
return t.stringType
case 'integer':
return t.numberType
case 'number':
return t.numberType
case 'boolean':
return t.booleanType
case 'object':
return toInterfaceCombinator(schema)
case 'array':
if (schema.items)
return t.arrayCombinator(toType(schema.items))
else
return t.unknownArrayType
case 'any':
return t.customCombinator('any', 't.any')
case 'null':
if (config.nullToAny)
if (config.useOption)
return optionCombinator({ type: 'any' })
else
return toType({ type: 'any' })
else
return t.nullType
case undefined:
// if the schema represents a union type
if (_.isArray(schema.anyOf))
return toUnionCombinator(schema.anyOf)
// if schema references a subtype
else if (schema['$ref']) {
const name = pipe(
refUriToName(schema['$ref']),
fold(
() => { throw new Error(`Unprocessable $ref ${schema['$ref']}`) },
name => sanitizeName(name)
)
)
return t.customCombinator(`${name}`, lower(name), [name, lower(name)])
}
// if type of the object wasn't inferred
else if (Object.keys(schema).length == 0)
return t.unknownType
else
throw new Error("Unprocessable schema " + JSON.stringify(schema))
default:
throw new Error("Unprocessable schema " + JSON.stringify(schema))
}
}
type DeclarationsMap = { [name: string]: t.TypeDeclaration }
function definitionsToDeclarations(definitions: JSONSchema, lowerNames = false): DeclarationsMap {
var map: DeclarationsMap = {}
Object.keys(definitions).map(name => {
const def = definitions[name]
let entityName = sanitizeName(name)
if (lowerNames)
entityName = lower(entityName)
map[name] = t.typeDeclaration(entityName, toType(def), true)
})
return map
}
async function main() {
const input = (await readStdin()).join("")
const schema: JSONSchema = JSON.parse(input);
const definitions = {
'TopLevel': schema.items,
...schema.definitions
}
console.log(`import * as t from 'io-ts'
import { optionFromNullable } from 'io-ts-types/lib/optionFromNullable'
import { Option } from 'fp-ts/lib/Option'
import { isRight, fold, left } from 'fp-ts/lib/Either'
import { pipe } from 'fp-ts/lib/pipeable'
import { PathReporter } from 'io-ts/lib/PathReporter'
import * as readline from 'readline';
export function readStdin() {
return new Promise<string[]>((resolve, reject) => {
let rl = readline.createInterface({ input: process.stdin, });
var lines: string[] = [];
rl.on('line', (line) => { lines.push(line) })
rl.on('close', () => { resolve(lines) })
})
}
`)
let map = definitionsToDeclarations(definitions)
let sorted = t.sort(_.values(map))
sorted.forEach(d => {
console.log(t.printStatic(d))
console.log()
})
// generate lowercase names for decoders
map = definitionsToDeclarations(definitions, true)
sorted = t.sort(_.values(map))
sorted.forEach(d => {
console.log(t.printRuntime(d))
console.log()
})
console.log(`
async function main() {
const input = (await readStdin()).join("")
pipe(
topLevel.decode(JSON.parse(input)),
fold(
(err) => {
console.error(PathReporter.report(left(err)))
},
(result) => console.log(result)
)
)
}
main()
`)
}
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment