Last active
February 23, 2023 15:07
-
-
Save tg44/7602a9812678f779fd5e124ff8fa42ed to your computer and use it in GitHub Desktop.
JsonSchema generation from {{ mustache }} templates
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 Ajv from "ajv"; | |
import { describe, expect, test } from "@jest/globals"; | |
import { schemaGenFromMustacheTemplate } from "./mustacheSchema"; | |
interface TestGen { | |
name: string; | |
template: string; | |
schema: Record<string, any>; | |
expectedError: string | null; | |
} | |
const testDescriptionsBasic: TestGen[] = [ | |
{ | |
name: "no param trutly", | |
template: "hello", | |
schema: {}, | |
expectedError: null, | |
}, | |
{ | |
name: "no param allow more", | |
template: "hello", | |
schema: { test: "asd" }, | |
expectedError: null, | |
}, | |
{ | |
name: "single param trutly", | |
template: "hello {{name}}", | |
schema: { name: "you" }, | |
expectedError: null, | |
}, | |
{ | |
name: "single param multi use trutly", | |
template: "hello {{name}} {{name}}", | |
schema: { name: "you" }, | |
expectedError: null, | |
}, | |
{ | |
name: "single param trutly on numbers too", | |
template: "hello {{name}}", | |
schema: { name: 12345.54 }, | |
expectedError: null, | |
}, | |
{ | |
name: "single param missing", | |
template: "hello {{name}}", | |
schema: {}, | |
expectedError: "must have required property 'name'", | |
}, | |
{ | |
name: "single param allow more", | |
template: "hello {{name}}", | |
schema: { name: "you", test: "asd" }, | |
expectedError: null, | |
}, | |
{ | |
name: "nested param trutly", | |
template: "hello {{name.first}}", | |
schema: { name: { first: "test" } }, | |
expectedError: null, | |
}, | |
{ | |
name: "nested param multiuse trutly", | |
template: "hello {{name.first}} {{name.first}}", | |
schema: { name: { first: "test" } }, | |
expectedError: null, | |
}, | |
{ | |
name: "nested param missing", | |
template: "hello {{name.first}}", | |
schema: { name: {} }, | |
expectedError: "must have required property 'first'", | |
}, | |
{ | |
name: "nested param multiple trutly", | |
template: "hello {{name.first}} {{name.second}}", | |
schema: { name: { first: "test", second: "test" } }, | |
expectedError: null, | |
}, | |
{ | |
name: "deeply nested params multiple trutly", | |
template: "hello {{name.first.a}} {{name.second}} {{name.first.b}}", | |
schema: { name: { first: { a: "test", b: "test" }, second: "test" } }, | |
expectedError: null, | |
}, | |
{ | |
name: "deeply nested params multiple missing", | |
template: "hello {{name.first.a}} {{name.second}} {{name.first.b}}", | |
schema: { name: { first: { b: "test" }, second: "test" } }, | |
expectedError: "must have required property 'a'", | |
}, | |
{ | |
name: "single unparsed param trutly", | |
template: "hello {{{name}}}", | |
schema: { name: "you" }, | |
expectedError: null, | |
}, | |
{ | |
name: "# as if string trutly", | |
template: "hello {{#name}} named {{/name}}", | |
schema: { name: "you" }, | |
expectedError: null, | |
}, | |
{ | |
name: "# as if boolean trutly", | |
template: "hello {{#name}} named {{/name}}", | |
schema: { name: false }, | |
expectedError: null, | |
}, | |
{ | |
name: "# as if null trutly", | |
template: "hello {{#name}} named {{/name}}", | |
schema: { name: null }, | |
expectedError: null, | |
}, | |
{ | |
name: "# as if missing trutly", | |
template: "hello {{#name}} named {{/name}}", | |
schema: {}, | |
expectedError: null, | |
}, | |
{ | |
name: "# as if array trutly", | |
template: "hello {{#name}} named {{/name}}", | |
schema: { name: ["a", "b"] }, | |
expectedError: null, | |
}, | |
{ | |
name: "# as array trutly", | |
template: "hello {{#name}} {{.}} {{/name}}", | |
schema: { name: ["a", "b"] }, | |
expectedError: null, | |
}, | |
{ | |
name: "# as array empty falsy", | |
template: "hello {{#name}} {{.}} {{/name}}", | |
schema: { name: [] }, | |
expectedError: "must NOT have fewer than 1 items", | |
}, | |
{ | |
name: "# as array objects trutly", | |
template: "hello {{#name}} {{a}} {{/name}}", | |
schema: { name: [{ a: "x" }] }, | |
expectedError: null, | |
}, | |
{ | |
name: "# as array objects nested trutly", | |
template: "hello {{#name}} {{a}} {{b}} {{/name}}", | |
schema: { name: [{ a: "x", b: "y" }] }, | |
expectedError: null, | |
}, | |
{ | |
name: "^ as if string trutly", | |
template: "hello {{#name}} named {{/name}}", | |
schema: { name: "you" }, | |
expectedError: null, | |
}, | |
{ | |
name: "^ as if boolean trutly", | |
template: "hello {{#name}} named {{/name}}", | |
schema: { name: false }, | |
expectedError: null, | |
}, | |
{ | |
name: "^ as if null trutly", | |
template: "hello {{#name}} named {{/name}}", | |
schema: { name: null }, | |
expectedError: null, | |
}, | |
{ | |
name: "^ as if missing trutly", | |
template: "hello {{#name}} named {{/name}}", | |
schema: {}, | |
expectedError: null, | |
}, | |
{ | |
name: "^ as if array trutly", | |
template: "hello {{#name}} named {{/name}}", | |
schema: { name: ["a", "b"] }, | |
expectedError: null, | |
}, | |
]; | |
const testDescriptionsComposit: TestGen[] = [ | |
{ | |
name: "^ and # as if bool trutly", | |
template: | |
"hello {{#name}} has name {{/name}}{{^name}} has no name {{/name}}", | |
schema: { name: true }, | |
expectedError: null, | |
}, | |
{ | |
name: "^ and # as if bool reversed trutly", | |
template: | |
"hello {{^name}} has no name {{/name}}{{#name}} has name {{/name}}", | |
schema: { name: true }, | |
expectedError: null, | |
}, | |
{ | |
name: "^ and # as if array trutly", | |
template: | |
"hello {{#name}} has {{.}} {{/name}}{{^name}} has no name {{/name}}", | |
schema: { name: ["name"] }, | |
expectedError: null, | |
}, | |
{ | |
name: "^ and # as if array falsly", | |
template: | |
"hello {{#name}} has {{.}} {{/name}}{{^name}} has no name {{/name}}", | |
schema: { name: null }, | |
expectedError: "must be array", | |
}, | |
]; | |
const runTest = (testGen: TestGen) => { | |
test(testGen.name, () => { | |
const schema = schemaGenFromMustacheTemplate(testGen.template); | |
// console.log(JSON.stringify(schema)); | |
const ajv = new Ajv({ allowUnionTypes: true, verbose: true, strict: true }); | |
const validate = ajv.compile(schema); | |
const res = validate(testGen.schema); | |
if (testGen.expectedError === null) { | |
if (!res) console.log(validate.errors); | |
expect(res).toBeTruthy(); | |
expect(validate.errors).toBeNull(); | |
} else { | |
// console.log(validate.errors); | |
expect(res).toBeFalsy(); | |
expect(validate.errors?.[0].message).toEqual(testGen.expectedError); | |
} | |
}); | |
}; | |
describe("mustache", () => { | |
testDescriptionsBasic.forEach((t) => runTest(t)); | |
testDescriptionsComposit.forEach((t) => runTest(t)); | |
}); |
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 Mustache, { TemplateSpans } from "mustache"; | |
const variableSpans = ["name", "&", "#", "^"]; | |
export type AllowedSchemas = | |
| typeof SingleField | |
| ArraySchema | |
| ObjectSchema | |
| typeof AnyField; | |
export type AllowedUISchemas = | |
| typeof SingleFieldUISchema | |
| ArrayUISchema | |
| ObjectUISchema | |
| typeof AnyFieldUISchema; | |
const SingleField = { type: ["string", "number"], nullable: false }; | |
const SingleFieldUISchema: { nullable: boolean; type: "string" } = { | |
type: "string", | |
nullable: false, | |
}; | |
const AnyField = { | |
type: ["string", "number", "object", "array", "boolean", "null"], | |
}; | |
const AnyFieldUISchema: { type: "boolean" } = { type: "boolean" }; | |
type ArraySchema = { | |
type: "array"; | |
items: AllowedSchemas; | |
nullable: false; | |
minItems: 0 | 1; | |
}; | |
type ArrayUISchema = { | |
type: "array"; | |
items: AllowedUISchemas; | |
nullable: false; | |
minItems: 0 | 1; | |
}; | |
type ObjectSchema = { | |
type: "object"; | |
properties: Record<string, AllowedSchemas>; | |
required: string[]; | |
nullable: false; | |
additionalProperties: true; | |
}; | |
type ObjectUISchema = { | |
type: "object"; | |
properties: Record<string, AllowedUISchemas>; | |
required: string[]; | |
nullable: false; | |
additionalProperties: true; | |
}; | |
export const convertToUISchema = (schema: AllowedSchemas): AllowedUISchemas => { | |
return JSON.parse( | |
JSON.stringify(schema) | |
.replaceAll( | |
JSON.stringify(SingleField), | |
JSON.stringify(SingleFieldUISchema) | |
) | |
.replaceAll(JSON.stringify(AnyField), JSON.stringify(AnyFieldUISchema)) | |
); | |
}; | |
function nonEmptyObject(obj: object) { | |
return Object.keys(obj).length > 0; | |
} | |
const schemaOfParam = ( | |
param: TemplateSpans[0] | |
): Record<string, AllowedSchemas> => { | |
switch (param[0]) { | |
case "name": | |
return { [param[1]]: SingleField }; | |
case "&": | |
return { [param[1]]: SingleField }; | |
case "#": | |
if (Array.isArray(param[4])) { | |
const spans: TemplateSpans = param[4]; | |
const schemaList = spans | |
.flatMap((p) => [schemaOfParam(p)]) | |
.filter(nonEmptyObject); | |
const itemDef = mergeSchema(schemaList); | |
if (Object.keys(itemDef).length > 0) { | |
return { | |
[param[1]]: { | |
type: "array", | |
items: { | |
type: "object", | |
properties: itemDef, | |
required: Object.keys(itemDef), | |
nullable: false, | |
additionalProperties: true, | |
}, | |
nullable: false, | |
minItems: 1, | |
}, | |
}; | |
} | |
if (schemaList.length > 0) { | |
return { | |
[param[1]]: { | |
type: "array", | |
items: SingleField, | |
nullable: false, | |
minItems: 1, | |
}, | |
}; | |
} | |
} | |
return { | |
[param[1]]: AnyField, | |
}; | |
case "^": | |
return { | |
[param[1]]: AnyField, | |
}; | |
default: | |
return {}; | |
} | |
}; | |
const mergeSchema = (schemas: Record<string, AllowedSchemas>[]) => { | |
const newSchema: Record<string, AllowedSchemas> = {}; | |
schemas.forEach((schema) => { | |
Object.keys(schema).forEach((k) => { | |
if (k === ".") { | |
return; | |
} | |
const split = k.split("."); | |
if (split.length === 1) { | |
const currentNewSchema = newSchema[k]; | |
const iteratedSchema = schema[k]; | |
if (currentNewSchema === undefined) { | |
newSchema[k] = schema[k]; | |
} else if ( | |
currentNewSchema.type === "array" && | |
Array.isArray(schema[k].type) | |
) { | |
currentNewSchema.nullable = false; | |
currentNewSchema.minItems = 0; | |
} else if ( | |
Array.isArray(currentNewSchema.type) && | |
iteratedSchema.type === "array" | |
) { | |
iteratedSchema.nullable = false; | |
iteratedSchema.minItems = 0; | |
newSchema[k] = iteratedSchema; | |
} else if ( | |
Array.isArray(currentNewSchema.type) && | |
Array.isArray(schema[k].type) | |
) { | |
// we are good | |
} else { | |
console.log(currentNewSchema); | |
console.log(iteratedSchema); | |
throw Error("Unsupported schema merge"); | |
} | |
} else { | |
let current = newSchema; | |
let required: string[] = []; | |
for (let i = 0; i < split.length - 1; i++) { | |
if ( | |
current[split[i]] === undefined || | |
current[split[i]].type !== "object" | |
) { | |
current[split[i]] = { | |
type: "object", | |
properties: {}, | |
required: [], | |
nullable: false, | |
additionalProperties: true, | |
}; | |
} | |
if (required.indexOf(split[i]) === -1) { | |
required.push(split[i]); | |
} | |
required = (current[split[i]] as ObjectSchema).required; | |
current = (current[split[i]] as ObjectSchema).properties; | |
} | |
const actualSchema = schema[k]; | |
if ( | |
required.indexOf(split[split.length - 1]) === -1 && | |
!("nullable" in actualSchema && actualSchema.nullable) | |
) { | |
required.push(split[split.length - 1]); | |
} | |
current[split[split.length - 1]] = actualSchema; | |
} | |
}); | |
}); | |
return newSchema; | |
}; | |
export const schemaGenFromMustacheTemplate = ( | |
template: string | |
): AllowedSchemas => { | |
const parsedTemplate = Mustache.parse(template); | |
const params = parsedTemplate.flatMap((e) => { | |
if (variableSpans.indexOf(e[0]) > -1) { | |
return [schemaOfParam(e)]; | |
} | |
return []; | |
}); | |
const innerSchema = mergeSchema(params); | |
return { | |
properties: innerSchema, | |
type: "object", | |
nullable: false, | |
required: Object.entries(innerSchema) | |
.filter(([, v]) => { | |
return !( | |
("nullable" in v && v.nullable) || | |
(Array.isArray(v.type) && v.type.indexOf("null") !== -1) | |
); | |
}) | |
.map(([k]) => k), | |
additionalProperties: true, | |
}; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment