feat: resolve local capability schema references
This commit is contained in:
@@ -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());
|
||||
Reference in New Issue
Block a user