diff --git a/client/plugos/syscalls/jsonschema.test.ts b/client/plugos/syscalls/jsonschema.test.ts new file mode 100644 index 00000000..8d8eb561 --- /dev/null +++ b/client/plugos/syscalls/jsonschema.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from "vitest"; +import { inferFromObject } from "./jsonschema.ts"; + +test("inferFromObject maps primitive field types", () => { + const schema = inferFromObject({ + name: "Foo", + count: 3, + ratio: 1.5, + done: false, + missing: null, + }); + expect(schema.type).toBe("object"); + expect(schema["x-inferred"]).toBe(true); + expect(schema.properties.name).toEqual({ type: "string" }); + expect(schema.properties.count).toEqual({ type: "integer" }); + expect(schema.properties.ratio).toEqual({ type: "number" }); + expect(schema.properties.done).toEqual({ type: "boolean" }); + expect(schema.properties.missing).toEqual({ type: "null" }); +}); + +test("inferFromObject recurses into arrays and nested objects", () => { + const schema = inferFromObject({ + tags: ["a", "b"], + meta: { owner: "z", weight: 2 }, + empty: [], + }); + expect(schema.properties.tags).toEqual({ + type: "array", + items: { type: "string" }, + }); + expect(schema.properties.meta).toEqual({ + type: "object", + properties: { owner: { type: "string" }, weight: { type: "integer" } }, + }); + // An empty array has no element to learn `items` from. + expect(schema.properties.empty).toEqual({ type: "array" }); +}); + +test("inferFromObject handles a non-object top-level value", () => { + const schema = inferFromObject("hello"); + expect(schema.type).toBe("string"); + expect(schema["x-inferred"]).toBe(true); +}); diff --git a/client/plugos/syscalls/jsonschema.ts b/client/plugos/syscalls/jsonschema.ts index 4bd7766b..a15660f7 100644 --- a/client/plugos/syscalls/jsonschema.ts +++ b/client/plugos/syscalls/jsonschema.ts @@ -75,6 +75,53 @@ export function validateSchema(schema: any): undefined | string { return; } +/** + * Best-effort: infer a JSON Schema (draft 2020-12) from the *shape* of a single + * sample value. Types are guessed from one example, so the result is a hint, + * not a contract — the top-level schema is marked `"x-inferred": true`. + * + * Useful when a tag/object type has no declared schema but an example object + * exists and you want a plausible schema for it. + */ +export function inferFromObject(value: any): any { + const schema = inferSchemaNode(value); + schema["$schema"] = "https://json-schema.org/draft/2020-12/schema"; + schema["x-inferred"] = true; + return schema; +} + +/** Infer a bare JSON Schema node for a value, recursing into arrays/objects. */ +function inferSchemaNode(value: any): any { + if (value === null || value === undefined) { + return { type: "null" }; + } + if (Array.isArray(value)) { + const node: any = { type: "array" }; + if (value.length > 0) { + node.items = inferSchemaNode(value[0]); + } + return node; + } + switch (typeof value) { + case "boolean": + return { type: "boolean" }; + case "number": + return { type: Number.isInteger(value) ? "integer" : "number" }; + case "string": + return { type: "string" }; + case "object": { + const properties: Record = {}; + for (const [k, v] of Object.entries(value)) { + properties[k] = inferSchemaNode(v); + } + return { type: "object", properties }; + } + default: + // functions, symbols, bigint, … — not representable in JSON Schema. + return {}; + } +} + export function jsonschemaSyscalls(): SysCallMapping { return { "jsonschema.validateObject": ( @@ -87,5 +134,8 @@ export function jsonschemaSyscalls(): SysCallMapping { "jsonschema.validateSchema": (_ctx, schema: any): undefined | string => { return validateSchema(schema); }, + "jsonschema.inferFromObject": (_ctx, object: any): any => { + return inferFromObject(object); + }, }; } diff --git a/docs/API/jsonschema.md b/docs/API/jsonschema.md index 7dc7bea2..af4ea432 100644 --- a/docs/API/jsonschema.md +++ b/docs/API/jsonschema.md @@ -32,6 +32,18 @@ else end ``` +### jsonschema.inferFromObject(object) +Infers a best-effort JSON schema from the *shape* of a single sample value. Types are guessed from one example, so the result is a hint rather than a contract — the returned schema is marked with `"x-inferred": true`. Useful when an object type has no declared schema but you have an example to learn from. + +Example: +```lua +local sample = { name = "Widget", count = 3, tags = { "a", "b" } } +local schema = jsonschema.inferFromObject(sample) +-- schema.properties.name.type == "string" +-- schema.properties.count.type == "integer" +-- schema.properties.tags.type == "array" (items.type == "string") +``` + ### jsonschema.validateSchema(schema) Validates a JSON schema itself to ensure it's well-formed. diff --git a/plug-api/syscalls/jsonschema.ts b/plug-api/syscalls/jsonschema.ts index 72aed754..4d4d39f7 100644 --- a/plug-api/syscalls/jsonschema.ts +++ b/plug-api/syscalls/jsonschema.ts @@ -21,3 +21,12 @@ export function validateObject( export function validateSchema(schema: any): Promise { return syscall("jsonschema.validateSchema", schema); } + +/** + * Infers a best-effort JSON schema from a sample value's shape. + * @param object the sample value to infer a schema from + * @returns an inferred JSON schema (draft 2020-12), marked `x-inferred` + */ +export function inferFromObject(object: any): Promise { + return syscall("jsonschema.inferFromObject", object); +}