feat: resolve local capability schema references

This commit is contained in:
lda
2026-08-11 19:02:35 +07:00 Verified
parent d31946cd26
commit a3c3b0ed03
7 changed files with 415 additions and 28 deletions
@@ -322,6 +322,7 @@ export const SchemaFieldControl = ({
);
}
// Hidden source controls intentionally project the current value as literal input.
const source: FieldSource = showSourceControl
? sources[pathKey(field)] ?? { mode: "literal", value }
: { mode: "literal", value };
@@ -40,6 +40,23 @@ describe("SchemaForm", () => {
expect(screen.getByText("Raw schema").closest("details")).not.toHaveAttribute("open");
});
it("suppresses source selectors when source controls are disabled", () => {
render(
<SchemaForm
initialSources={{ summary: { mode: "bind", sourcePath: "input.summary" } }}
initialValue={{ summary: "literal summary" }}
schema={{ type: "object", properties: { summary: { type: "string" } } }}
showSourceControls={false}
submitLabel="Call capability"
/>,
);
expect(screen.getByRole("textbox", { name: "Summary" })).toHaveValue("literal summary");
expect(screen.queryByRole("group", { name: "Value source" })).not.toBeInTheDocument();
expect(screen.queryByRole("textbox", { name: "Source path for Summary" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Call capability" })).toBeInTheDocument();
});
it("renders unsupported fields as JSON editors with their exact fallback reason", () => {
render(
<SchemaForm
@@ -22,6 +22,7 @@ export type SchemaFormProps = {
readonly renderBeforeFields?: ReactNode;
readonly submitLabel?: string;
readonly sourceSuggestions?: ReadonlyArray<string>;
readonly showSourceControls?: boolean;
};
const EMPTY_SOURCES: FieldSources = {};
@@ -103,6 +104,7 @@ export const SchemaForm = ({
renderBeforeFields,
submitLabel = "Save form",
sourceSuggestions = EMPTY_SUGGESTIONS,
showSourceControls = true,
}: SchemaFormProps) => {
const field = normalizeSchema(schema);
const [values, setValues] = useState<unknown>(() =>
@@ -179,6 +181,7 @@ export const SchemaForm = ({
onValueChange={handleValueChange}
sourceSuggestions={sourceSuggestions}
sources={sources}
showSourceControl={showSourceControls}
value={values}
/>
<button type="submit">{submitLabel}</button>
@@ -98,7 +98,78 @@ describe("normalizeSchema", () => {
});
expect(field.children[1]).toMatchObject({
kind: "json",
fallbackReason: "The schema contains an unresolved $ref, which the native form cannot represent.",
fallbackReason: "Local schema reference target was not found.",
});
});
it("projects the local ref in the wf.source.read_resource input schema", () => {
const field = normalizeSchema({
$defs: {
SourceResourceRef: {
description:
"Workflow-safe resource handle. Only explicit source-aware helper nodes dereference it.",
properties: {
kind: {
const: "source_resource_ref",
default: "source_resource_ref",
title: "Kind",
type: "string",
},
logical_source: { minLength: 1, title: "Logical Source", type: "string" },
uri: { minLength: 1, title: "Uri", type: "string" },
mime_type: {
anyOf: [{ type: "string" }, { type: "null" }],
default: null,
title: "Mime Type",
},
name: {
anyOf: [{ type: "string" }, { type: "null" }],
default: null,
title: "Name",
},
},
required: ["logical_source", "uri"],
title: "SourceResourceRef",
type: "object",
},
},
description: "Input model for wf.source.read_resource node.",
properties: {
ref: { $ref: "#/$defs/SourceResourceRef" },
max_chars: {
default: 4000,
maximum: 20000,
minimum: 1,
title: "Max Chars",
type: "integer",
},
},
required: ["ref"],
title: "ReadResourceInput",
type: "object",
});
const ref = field.children.find((child) => child.key === "ref");
expect(ref).toMatchObject({ kind: "object", required: true });
expect(ref?.children.map((child) => child.key)).toEqual([
"kind",
"logical_source",
"uri",
"mime_type",
"name",
]);
expect(ref?.children.find((child) => child.key === "logical_source")).toMatchObject({
kind: "string",
required: true,
});
expect(ref?.children.find((child) => child.key === "uri")).toMatchObject({
kind: "string",
required: true,
});
expect(ref?.children.find((child) => child.key === "kind")).toMatchObject({
kind: "string",
hasDefault: true,
defaultValue: "source_resource_ref",
});
});
@@ -1,3 +1,5 @@
import { resolveLocalSchemaNode } from "./schema-reference.js";
export type SchemaField = {
readonly path: ReadonlyArray<string | number>;
readonly key: string;
@@ -101,31 +103,40 @@ const requiredPropertyNames = (schema: SchemaRecord): ReadonlySet<string> => {
};
const normalizeField = (
rootSchema: unknown,
schema: unknown,
path: ReadonlyArray<string | number>,
key: string,
required: boolean,
defaultTitle: string,
): SchemaField => {
const title = isRecord(schema) ? stringValue(schema.title) ?? defaultTitle : defaultTitle;
if (!isRecord(schema)) {
const resolution = resolveLocalSchemaNode(rootSchema, schema);
if (!resolution.ok) {
const title = isRecord(schema) ? stringValue(schema.title) ?? defaultTitle : defaultTitle;
return fallback(schema, path, key, required, title, resolution.reason);
}
const resolvedSchema = resolution.schema;
const title = isRecord(resolvedSchema)
? stringValue(resolvedSchema.title) ?? defaultTitle
: defaultTitle;
if (!isRecord(resolvedSchema)) {
return fallback(schema, path, key, required, title, "The schema is not a JSON object; edit JSON directly.");
}
const reason = unsupportedReason(schema);
if (reason) return fallback(schema, path, key, required, title, reason);
const reason = unsupportedReason(resolvedSchema);
if (reason) return fallback(resolvedSchema, path, key, required, title, reason);
const enumValue = schema.enum;
const enumValue = resolvedSchema.enum;
if (Array.isArray(enumValue) && enumValue.every(isEnumValue)) {
return {
path,
key,
title,
description: stringValue(schema.description),
description: stringValue(resolvedSchema.description),
kind: "enum",
required,
hasDefault: hasOwn(schema, "default"),
defaultValue: schema.default,
hasDefault: hasOwn(resolvedSchema, "default"),
defaultValue: resolvedSchema.default,
enumValues: enumValue,
children: [],
item: null,
@@ -133,20 +144,21 @@ const normalizeField = (
};
}
const type = schema.type;
const type = resolvedSchema.type;
if (type === undefined) {
return fallback(schema, path, key, required, title, "The schema is unconstrained; edit JSON directly.");
return fallback(resolvedSchema, path, key, required, title, "The schema is unconstrained; edit JSON directly.");
}
if (type === "object") {
const properties = schema.properties;
const properties = resolvedSchema.properties;
if (properties !== undefined && !isRecord(properties)) {
return fallback(schema, path, key, required, title, "The schema has invalid properties; edit JSON directly.");
return fallback(resolvedSchema, path, key, required, title, "The schema has invalid properties; edit JSON directly.");
}
const requiredNames = requiredPropertyNames(schema);
const requiredNames = requiredPropertyNames(resolvedSchema);
const children = properties
? Object.entries(properties).map(([propertyKey, propertySchema]) =>
normalizeField(
rootSchema,
propertySchema,
[...path, propertyKey],
propertyKey,
@@ -159,11 +171,11 @@ const normalizeField = (
path,
key,
title,
description: stringValue(schema.description),
description: stringValue(resolvedSchema.description),
kind: "object",
required,
hasDefault: hasOwn(schema, "default"),
defaultValue: schema.default,
hasDefault: hasOwn(resolvedSchema, "default"),
defaultValue: resolvedSchema.default,
enumValues: [],
children,
item: null,
@@ -172,20 +184,20 @@ const normalizeField = (
}
if (type === "array") {
const itemSchema = schema.items;
const itemSchema = resolvedSchema.items;
if (itemSchema === undefined) {
return fallback(schema, path, key, required, title, "The array has no item schema; edit JSON directly.");
return fallback(resolvedSchema, path, key, required, title, "The array has no item schema; edit JSON directly.");
}
const item = normalizeField(itemSchema, [...path, 0], "item", true, `${title} item`);
const item = normalizeField(rootSchema, itemSchema, [...path, 0], "item", true, `${title} item`);
return {
path,
key,
title,
description: stringValue(schema.description),
description: stringValue(resolvedSchema.description),
kind: "array",
required,
hasDefault: hasOwn(schema, "default"),
defaultValue: schema.default,
hasDefault: hasOwn(resolvedSchema, "default"),
defaultValue: resolvedSchema.default,
enumValues: [],
children: [],
item,
@@ -198,11 +210,11 @@ const normalizeField = (
path,
key,
title,
description: stringValue(schema.description),
description: stringValue(resolvedSchema.description),
kind: type,
required,
hasDefault: hasOwn(schema, "default"),
defaultValue: schema.default,
hasDefault: hasOwn(resolvedSchema, "default"),
defaultValue: resolvedSchema.default,
enumValues: [],
children: [],
item: null,
@@ -210,7 +222,7 @@ const normalizeField = (
};
}
return fallback(schema, path, key, required, title, "The schema type is unsupported; edit JSON directly.");
return fallback(resolvedSchema, path, key, required, title, "The schema type is unsupported; edit JSON directly.");
};
export const normalizeSchemaField = (
@@ -218,7 +230,7 @@ export const normalizeSchemaField = (
path: ReadonlyArray<string | number> = [],
key = "root",
required = true,
): SchemaField => normalizeField(schema, path, key, required, key === "root" ? "Value" : key);
): SchemaField => normalizeField(schema, schema, path, key, required, key === "root" ? "Value" : key);
export const normalizeSchema = (schema: unknown): SchemaField =>
normalizeSchemaField(schema);
@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import { resolveLocalSchemaNode } from "./schema-reference.js";
describe("resolveLocalSchemaNode", () => {
it("resolves direct and nested local definitions", () => {
const root = {
$defs: {
Resource: {
type: "object",
properties: {
logical_source: { type: "string" },
uri: { type: "string" },
},
required: ["logical_source", "uri"],
},
Envelope: {
type: "object",
properties: { resource: { $ref: "#/$defs/Resource" } },
},
},
properties: { ref: { $ref: "#/$defs/Envelope" } },
};
expect(resolveLocalSchemaNode(root, root.properties.ref)).toEqual({
ok: true,
schema: {
type: "object",
properties: { resource: { $ref: "#/$defs/Resource" } },
},
});
expect(
resolveLocalSchemaNode(root, root.$defs.Envelope.properties.resource),
).toEqual({
ok: true,
schema: {
type: "object",
properties: {
logical_source: { type: "string" },
uri: { type: "string" },
},
required: ["logical_source", "uri"],
},
});
});
it("resolves legacy definitions and escaped JSON Pointer tokens", () => {
const root = {
definitions: {
"name/with~token": { type: "string" },
},
};
expect(
resolveLocalSchemaNode(root, { $ref: "#/definitions/name~1with~0token" }),
).toEqual({
ok: true,
schema: { type: "string" },
});
});
it("traverses only own non-negative array indices", () => {
const root = { items: [{ type: "string" }, { type: "number" }] };
expect(resolveLocalSchemaNode(root, { $ref: "#/items/1" })).toEqual({
ok: true,
schema: { type: "number" },
});
expect(resolveLocalSchemaNode(root, { $ref: "#/items/-1" })).toEqual({
ok: false,
reason: "Malformed local schema reference pointer.",
});
});
it("merges only annotation siblings over a resolved schema", () => {
const root = {
$defs: { Value: { type: "string", title: "Original", default: "old" } },
};
expect(
resolveLocalSchemaNode(root, {
$ref: "#/$defs/Value",
title: "Display value",
description: "Shown to the operator.",
default: "new",
}),
).toEqual({
ok: true,
schema: {
type: "string",
title: "Display value",
description: "Shown to the operator.",
default: "new",
},
});
});
it("returns stable failures for unsupported references and malformed pointers", () => {
const root = { $defs: {} };
expect(
resolveLocalSchemaNode(root, { $ref: "https://example.test/schema" }),
).toEqual({
ok: false,
reason: "External schema references are not supported.",
});
expect(resolveLocalSchemaNode(root, { $ref: "other.json#/schema" })).toEqual({
ok: false,
reason: "External schema references are not supported.",
});
expect(resolveLocalSchemaNode(root, { $ref: "#/missing" })).toEqual({
ok: false,
reason: "Local schema reference target was not found.",
});
expect(resolveLocalSchemaNode(root, { $ref: "#/$defs/name~2" })).toEqual({
ok: false,
reason: "Malformed local schema reference pointer.",
});
});
it("rejects cycles and structural siblings without mutating the schema", () => {
const root = {
$defs: {
First: { $ref: "#/$defs/Second" },
Second: { $ref: "#/$defs/First" },
Value: { type: "string" },
},
};
const structuralSibling = { $ref: "#/$defs/Value", properties: {} };
expect(resolveLocalSchemaNode(root, root.$defs.First)).toEqual({
ok: false,
reason: "Local schema reference cycle detected.",
});
expect(resolveLocalSchemaNode(root, structuralSibling)).toEqual({
ok: false,
reason: "Structural siblings beside $ref are not supported.",
});
expect(structuralSibling).toEqual({
$ref: "#/$defs/Value",
properties: {},
});
});
});
@@ -0,0 +1,140 @@
type SchemaRecord = Record<string, unknown>;
export type SchemaReferenceResolution =
| { readonly ok: true; readonly schema: unknown }
| { readonly ok: false; readonly reason: string };
const ANNOTATION_KEYS = ["title", "description", "default"] as const;
const isRecord = (value: unknown): value is SchemaRecord =>
typeof value === "object" && value !== null && !Array.isArray(value);
const hasOwn = (value: SchemaRecord, key: string): boolean =>
Object.prototype.hasOwnProperty.call(value, key);
const failure = (reason: string): SchemaReferenceResolution => ({
ok: false,
reason,
});
const decodePointer = (pointer: string): string[] | null => {
const tokens = pointer.slice(2).split("/");
const decodedTokens: string[] = [];
for (const token of tokens) {
let decoded = "";
for (let index = 0; index < token.length; index += 1) {
const character = token[index];
if (character !== "~") {
decoded += character;
continue;
}
const escape = token[index + 1];
if (escape !== "0" && escape !== "1") return null;
decoded += escape === "0" ? "~" : "/";
index += 1;
}
decodedTokens.push(decoded);
}
return decodedTokens;
};
type PointerLookup =
| { readonly ok: true; readonly value: unknown }
| { readonly ok: false; readonly malformed: boolean };
const pointerTarget = (rootSchema: unknown, ref: string): PointerLookup => {
const tokens = decodePointer(ref);
if (tokens === null) return { ok: false, malformed: true };
let current: unknown = rootSchema;
for (const token of tokens) {
if (Array.isArray(current)) {
if (!/^(0|[1-9]\d*)$/.test(token)) return { ok: false, malformed: true };
const index = Number(token);
if (
!Number.isSafeInteger(index) ||
!Object.prototype.hasOwnProperty.call(current, index)
) {
return { ok: false, malformed: false };
}
current = current[index];
continue;
}
if (!isRecord(current) || !hasOwn(current, token))
return { ok: false, malformed: false };
current = current[token];
}
return { ok: true, value: current };
};
const hasStructuralSiblings = (schemaNode: SchemaRecord): boolean =>
Object.keys(schemaNode).some(
(key) =>
key !== "$ref" &&
!(ANNOTATION_KEYS as ReadonlyArray<string>).includes(key),
);
const mergeAnnotations = (
schema: unknown,
referenceNode: SchemaRecord,
): unknown => {
if (!isRecord(schema)) return schema;
const annotations = Object.fromEntries(
ANNOTATION_KEYS.filter((key) => hasOwn(referenceNode, key)).map((key) => [
key,
referenceNode[key],
]),
);
return Object.keys(annotations).length === 0
? schema
: { ...schema, ...annotations };
};
const resolveNode = (
rootSchema: unknown,
schemaNode: unknown,
activeReferences: Set<string>,
): SchemaReferenceResolution => {
if (!isRecord(schemaNode) || !hasOwn(schemaNode, "$ref")) {
return { ok: true, schema: schemaNode };
}
const ref = schemaNode.$ref;
if (typeof ref !== "string")
return failure("Malformed local schema reference pointer.");
if (/^[A-Za-z][A-Za-z\d+.-]*:/.test(ref)) {
return failure("External schema references are not supported.");
}
if (!ref.startsWith("#")) {
return failure("External schema references are not supported.");
}
if (!ref.startsWith("#/")) {
return failure("Only local JSON Pointer schema references are supported.");
}
if (hasStructuralSiblings(schemaNode)) {
return failure("Structural siblings beside $ref are not supported.");
}
if (activeReferences.has(ref))
return failure("Local schema reference cycle detected.");
const target = pointerTarget(rootSchema, ref);
if (!target.ok) {
return failure(
target.malformed
? "Malformed local schema reference pointer."
: "Local schema reference target was not found.",
);
}
// Track only the current reference path so repeated sibling refs remain valid.
activeReferences.add(ref);
const resolved = resolveNode(rootSchema, target.value, activeReferences);
activeReferences.delete(ref);
if (!resolved.ok) return resolved;
return { ok: true, schema: mergeAnnotations(resolved.schema, schemaNode) };
};
export const resolveLocalSchemaNode = (
rootSchema: unknown,
schemaNode: unknown,
): SchemaReferenceResolution => resolveNode(rootSchema, schemaNode, new Set());