Skip to content

Instantly share code, notes, and snippets.

@tg44
Last active February 23, 2023 15:07
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tg44/7602a9812678f779fd5e124ff8fa42ed to your computer and use it in GitHub Desktop.
Save tg44/7602a9812678f779fd5e124ff8fa42ed to your computer and use it in GitHub Desktop.
JsonSchema generation from {{ mustache }} templates
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));
});
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