Last active
August 6, 2022 15:26
-
-
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
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
/* | |
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