- rootUnion: a name both the root and a branch declare is now MERGED rather than replaced, so a root `minimum` survives a branch `maximum`; a branch that contradicts the root on a property's `type` describes an unsatisfiable value and is declined outright. Branch offerability also requires that `properties` really is an object (a malformed `properties: null` threw) and that the member's own `type` admits objects. - rootUnion: `ownFields` becomes `declaredFields` — a branch commonly specializes a root property, and consumers must render the specialization. - TUI: the generated select and branch prefixes step aside from any name the schema itself declares (JSON has no reserved namespace), decode filters them by exact name, a branch's specialization renders in its own section, and a `const` is rendered as a one-option select whose typed value is restored from the schema on submit. - convertToolParameters: identify the branch from the discriminator constants the call supplies; when none identifies one, coerce only names every declaring branch types the same way rather than picking an arbitrary branch. Signed-off-by: cliffhall <cliff@futurescale.com>
339 lines
13 KiB
TypeScript
339 lines
13 KiB
TypeScript
/**
|
|
* Flattening of the JSON Schema **composition** keywords that a tool's
|
|
* `inputSchema` may carry at its **root** — `allOf`, and a top-level `anyOf` /
|
|
* `oneOf` union — into the plain object schema a form renderer can build fields
|
|
* from.
|
|
*
|
|
* Both form builders — the web client's `SchemaForm` and the TUI's
|
|
* `schemaToForm` — enumerate the root schema's `properties` and nothing else.
|
|
* A schema that keeps its fields on composition branches therefore rendered
|
|
* **no controls at all**: not a branch picker, not even the raw-JSON fallback a
|
|
* union-typed *property* already gets, so the tool could only ever be called
|
|
* with empty arguments (#2123). The 2026-07-28 revision makes such a schema
|
|
* explicitly legal — `type: "object"` is required at the root, and "any JSON
|
|
* Schema 2020-12 keyword may appear alongside `type`, including composition
|
|
* keywords".
|
|
*
|
|
* Shared for the same reason {@link ./nullableUnion.ts} is: the two clients
|
|
* would otherwise grow separate answers to "which schemas can I render", and
|
|
* the CLI's argument coercion (`convertToolParameters`) needs the same view of
|
|
* where a property's schema lives.
|
|
*
|
|
* Deliberately **not** a JSON Schema evaluator. It rewrites nothing it cannot
|
|
* do faithfully: `not`, and branches that are not object schemas, are left
|
|
* alone rather than guessed at (see {@link resolveRootUnion}).
|
|
*/
|
|
|
|
/**
|
|
* The keywords this module reads. Both clients' own schema types are
|
|
* structurally assignable to this — the shape is the minimum needed to
|
|
* *recognize* root composition, not a full JSON Schema model.
|
|
*/
|
|
export interface RootUnionSchema {
|
|
type?: string | string[];
|
|
title?: string;
|
|
properties?: Record<string, unknown>;
|
|
required?: string[];
|
|
allOf?: readonly unknown[];
|
|
anyOf?: readonly unknown[];
|
|
oneOf?: readonly unknown[];
|
|
/**
|
|
* OpenAPI's discriminator, which real servers emit beside `oneOf` (the issue's
|
|
* own repro does). Read only to *label* a branch — never to validate.
|
|
*/
|
|
discriminator?: { propertyName?: string };
|
|
}
|
|
|
|
/**
|
|
* What a resolved schema is: the caller's own type, intersected with the
|
|
* keywords this module may have *added* to it.
|
|
*
|
|
* The intersection is load-bearing rather than decorative. A schema that keeps
|
|
* every field on a branch declares no `properties` of its own, so returning the
|
|
* caller's `T` unchanged would hand back a type on which `properties` does not
|
|
* exist — precisely the property the flattening was performed to produce.
|
|
*/
|
|
export type ResolvedSchema<T extends RootUnionSchema> = T & RootUnionSchema;
|
|
|
|
/** One selectable alternative of a root union. */
|
|
export interface RootUnionBranch<T extends RootUnionSchema> {
|
|
/**
|
|
* The base schema merged with this branch: the object schema a renderer
|
|
* builds fields from while this alternative is selected.
|
|
*/
|
|
schema: ResolvedSchema<T>;
|
|
/** Human-readable name for the picker — see {@link branchLabel}. */
|
|
label: string;
|
|
/**
|
|
* The names **this branch itself declares** — including one the base also
|
|
* declares, since a branch commonly *specializes* a root property (root
|
|
* `count: {}`, branch `count: { type: "number" }`) and a renderer that showed
|
|
* only the base's version would render the untyped one.
|
|
*/
|
|
declaredFields: string[];
|
|
}
|
|
|
|
/** What {@link resolveRootUnion} decomposes a root schema into. */
|
|
export interface ResolvedRootUnion<T extends RootUnionSchema> {
|
|
/**
|
|
* The schema without its composition keywords: the root's own `properties` /
|
|
* `required` with every `allOf` branch merged in. Renderable on its own, and
|
|
* what a renderer uses when there is no union.
|
|
*/
|
|
base: ResolvedSchema<T>;
|
|
/**
|
|
* One entry per union alternative, or **empty** when the root carries no
|
|
* usable union — which is the overwhelmingly common case, so callers can
|
|
* treat a non-empty array as "this schema needs a branch picker".
|
|
*/
|
|
branches: RootUnionBranch<T>[];
|
|
}
|
|
|
|
/** Narrow a composition member to a readable object, or `null` if it isn't one. */
|
|
function toBranch(value: unknown): RootUnionSchema | null {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
return null;
|
|
}
|
|
return value as RootUnionSchema;
|
|
}
|
|
|
|
/** Whether a schema's `type` permits an object instance. */
|
|
function admitsObject(schema: RootUnionSchema): boolean {
|
|
const { type } = schema;
|
|
if (type === undefined) return true;
|
|
return Array.isArray(type) ? type.includes("object") : type === "object";
|
|
}
|
|
|
|
/** A readable `properties` map, or `null` when the value is not one. */
|
|
function propertiesOf(schema: RootUnionSchema): Record<string, unknown> | null {
|
|
const { properties } = schema;
|
|
if (
|
|
typeof properties !== "object" ||
|
|
properties === null ||
|
|
Array.isArray(properties)
|
|
) {
|
|
return null;
|
|
}
|
|
return properties;
|
|
}
|
|
|
|
/**
|
|
* Whether a branch is one a form can offer as an alternative.
|
|
*
|
|
* Three ways it is not, all of which would put an option in the picker that
|
|
* cannot be filled in:
|
|
*
|
|
* - **It carries no fields.** A `{ type: "null" }` member — the nullable
|
|
* encoding {@link ./nullableUnion.ts} owns — and a `$ref`-only or empty
|
|
* branch render nothing.
|
|
* - **`properties` is not an object.** Members arrive as `unknown`, so a
|
|
* malformed `properties: null` is reachable and would throw in `Object.keys`
|
|
* rather than being declined.
|
|
* - **Its `type` rules objects out.** Tool arguments are a JSON object, so a
|
|
* `{ type: "string", properties: {…} }` member can never match — rendering it
|
|
* as a fillable form would offer a call that cannot be valid.
|
|
*/
|
|
function isOfferable(branch: RootUnionSchema): boolean {
|
|
const properties = propertiesOf(branch);
|
|
return (
|
|
properties !== null &&
|
|
Object.keys(properties).length > 0 &&
|
|
admitsObject(branch)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Merge two declarations of the same property name.
|
|
*
|
|
* Where a base and a branch both name a property, JSON Schema applies **both**
|
|
* — so keeping only the branch's would silently drop the base's constraints
|
|
* (root `{ minimum: 0 }` plus branch `{ maximum: 10 }` must keep the floor).
|
|
* A shallow union preserves every keyword only one side states, with the
|
|
* branch winning a keyword both state, being the more specific declaration.
|
|
*
|
|
* A *contradiction* is not resolvable this way and is not resolved here — see
|
|
* {@link contradicts}, which declines such a union outright.
|
|
*/
|
|
function mergeProperty(
|
|
baseProperty: unknown,
|
|
branchProperty: unknown,
|
|
): unknown {
|
|
const a = toBranch(baseProperty);
|
|
const b = toBranch(branchProperty);
|
|
if (a === null || b === null) {
|
|
return branchProperty;
|
|
}
|
|
return { ...a, ...b };
|
|
}
|
|
|
|
/**
|
|
* Whether a branch states a property `type` its base declaration rules out.
|
|
*
|
|
* The two are conjunctive, so `string` under a base `number` describes a value
|
|
* that cannot exist. No form can express that, and flattening it would render
|
|
* one of the two types and accept values the schema rejects — so the caller
|
|
* declines the union instead of picking a side.
|
|
*/
|
|
function contradicts(base: RootUnionSchema, branch: RootUnionSchema): boolean {
|
|
const baseProperties = propertiesOf(base) ?? {};
|
|
const branchProperties = propertiesOf(branch) ?? {};
|
|
return Object.entries(branchProperties).some(([name, branchProperty]) => {
|
|
const a = toBranch(baseProperties[name]);
|
|
const b = toBranch(branchProperty);
|
|
if (a === null || b === null) return false;
|
|
return (
|
|
typeof a.type === "string" &&
|
|
typeof b.type === "string" &&
|
|
a.type !== b.type
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Merge a composition branch's `properties` and `required` into a base schema.
|
|
*
|
|
* Both keywords are **conjunctive** where they meet: a value satisfying an
|
|
* `allOf` branch satisfies the base *and* the branch, and a value matching a
|
|
* union branch must satisfy the root's own constraints too. So properties union
|
|
* — a name both declare merged through {@link mergeProperty} rather than
|
|
* replaced — and `required` unions.
|
|
*/
|
|
function mergeBranch<T extends RootUnionSchema>(
|
|
base: T,
|
|
branch: RootUnionSchema,
|
|
): ResolvedSchema<T> {
|
|
const baseProperties = propertiesOf(base) ?? {};
|
|
const branchProperties = propertiesOf(branch) ?? {};
|
|
const properties: Record<string, unknown> = { ...baseProperties };
|
|
for (const [name, branchProperty] of Object.entries(branchProperties)) {
|
|
properties[name] =
|
|
name in baseProperties
|
|
? mergeProperty(baseProperties[name], branchProperty)
|
|
: branchProperty;
|
|
}
|
|
const required = [
|
|
...(base.required ?? []),
|
|
...(branch.required ?? []).filter(
|
|
(name) => !(base.required ?? []).includes(name),
|
|
),
|
|
];
|
|
// One cast, owned here: a branch member is `unknown` on the wire, so its
|
|
// property schemas are whatever the server sent however `T` declares them —
|
|
// exactly the situation `normalizeNullableUnion`'s hoist documents. Every
|
|
// consumer reads a property schema defensively, and the merged object is
|
|
// otherwise structurally `T`.
|
|
return {
|
|
...base,
|
|
...(Object.keys(properties).length > 0 ? { properties } : {}),
|
|
...(required.length > 0 ? { required } : {}),
|
|
} as ResolvedSchema<T>;
|
|
}
|
|
|
|
/** Strip the composition keywords a resolved schema has absorbed. */
|
|
function withoutComposition<T extends RootUnionSchema>(
|
|
schema: T,
|
|
): ResolvedSchema<T> {
|
|
const { allOf: _allOf, anyOf: _anyOf, oneOf: _oneOf, ...rest } = schema;
|
|
return rest as ResolvedSchema<T>;
|
|
}
|
|
|
|
/**
|
|
* A branch's display name, in decreasing order of how deliberate it is:
|
|
*
|
|
* 1. the branch's own `title` — the author naming it outright;
|
|
* 2. the `const` of the discriminator property, when the root names one;
|
|
* 3. the `const` of the branch's only constant-valued property, which is what a
|
|
* discriminated union looks like without an OpenAPI `discriminator` (the
|
|
* `kind: { const: "email" }` shape) — restricted to a *single* candidate, so
|
|
* a branch with several constants is not labelled by an arbitrary one;
|
|
* 4. a positional fallback.
|
|
*/
|
|
function branchLabel(
|
|
branch: RootUnionSchema,
|
|
index: number,
|
|
discriminatorProperty: string | undefined,
|
|
): string {
|
|
if (typeof branch.title === "string" && branch.title.trim() !== "") {
|
|
return branch.title;
|
|
}
|
|
const properties = propertiesOf(branch) ?? {};
|
|
const constOf = (name: string): string | null => {
|
|
const property = toBranch(properties[name]) as { const?: unknown } | null;
|
|
const value = property?.const;
|
|
return typeof value === "string" || typeof value === "number"
|
|
? String(value)
|
|
: null;
|
|
};
|
|
if (discriminatorProperty !== undefined) {
|
|
const value = constOf(discriminatorProperty);
|
|
if (value !== null) return value;
|
|
}
|
|
const constants = Object.keys(properties)
|
|
.map((name) => constOf(name))
|
|
.filter((value): value is string => value !== null);
|
|
if (constants.length === 1) return constants[0];
|
|
return `Option ${index + 1}`;
|
|
}
|
|
|
|
/**
|
|
* Decompose a root schema into the object schema a form renders and, when the
|
|
* root is a union, the alternatives a picker offers.
|
|
*
|
|
* `allOf` is merged unconditionally — its branches are conjunctive, so there is
|
|
* one correct rendering and no choice to present. A `oneOf` / `anyOf` becomes
|
|
* `branches` only when **every** member is an object schema carrying fields:
|
|
* a union mixing renderable and unrenderable members would give a picker
|
|
* options that show nothing, and the whole point of this module is to stop
|
|
* producing a form that cannot express the call.
|
|
*
|
|
* A schema carrying **both** `oneOf` and `anyOf` is declined rather than
|
|
* half-read. The two are independent keywords a value must satisfy *together*,
|
|
* not two spellings of one union, so picking one and dropping the other builds
|
|
* a form that silently omits real constraints — worse than the empty form this
|
|
* module exists to replace, because it looks complete. Satisfying both honestly
|
|
* means offering the cross product of their alternatives, which no real schema
|
|
* has yet asked for and which produces a picker whose option labels are pairs;
|
|
* until something does, declining leaves the root `properties` rendering
|
|
* unchanged and claims nothing that is not true.
|
|
*/
|
|
export function resolveRootUnion<T extends RootUnionSchema>(
|
|
schema: T,
|
|
): ResolvedRootUnion<T> {
|
|
const merged = (schema.allOf ?? []).reduce<ResolvedSchema<T>>(
|
|
(acc, member) => {
|
|
const branch = toBranch(member);
|
|
return branch === null ? acc : mergeBranch(acc, branch);
|
|
},
|
|
schema as ResolvedSchema<T>,
|
|
);
|
|
const base = withoutComposition(merged);
|
|
|
|
if (schema.oneOf !== undefined && schema.anyOf !== undefined) {
|
|
return { base, branches: [] };
|
|
}
|
|
const members = schema.oneOf ?? schema.anyOf ?? [];
|
|
const branches = members.map(toBranch);
|
|
if (
|
|
branches.length === 0 ||
|
|
branches.some(
|
|
(branch) =>
|
|
branch === null || !isOfferable(branch) || contradicts(base, branch),
|
|
)
|
|
) {
|
|
return { base, branches: [] };
|
|
}
|
|
|
|
const discriminatorProperty = schema.discriminator?.propertyName;
|
|
return {
|
|
base,
|
|
branches: branches
|
|
.filter((branch): branch is RootUnionSchema => branch !== null)
|
|
.map((branch, index) => ({
|
|
// `base` is already composition-free and `mergeBranch` copies only
|
|
// `properties`/`required` off the branch, so the merge stays that way.
|
|
schema: mergeBranch(base, branch),
|
|
label: branchLabel(branch, index, discriminatorProperty),
|
|
declaredFields: Object.keys(propertiesOf(branch) ?? {}),
|
|
})),
|
|
};
|
|
}
|