Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/nested-union-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"cf": patch
---

Validate nested tagged unions when assembling request bodies from flags.

Preserve branch-specific required fields, closed-object rules, and available
array limits, including unions selected by enum values without an explicit
discriminator. Apply the checks to requests and dry-run previews.
Preserve the alternatives of enclosing unions and retain explicit `--body`
input as an escape hatch from these projected checks.
9 changes: 9 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,12 @@ to know before editing `generator/plugin/`.
`type: "string", array: true` so users can do `--keys k1 k2` or repeat
the flag. Query serialization follows the operation's `explode` setting.

Nested tagged unions are validated after per-field flags are assembled into
the request's wire shape, before sending or showing a dry-run preview. The
checks preserve enclosing union alternatives. Explicit `--body` input bypasses
these projected union checks so callers can supply shapes the flags cannot
represent; the API validates the supplied body.

### `@file` token (universal file ingestion)

Every string-typed body flag (and multipart string field) accepts
Expand Down Expand Up @@ -660,6 +666,9 @@ When stdin is a TTY and a required body field isn't supplied:
`--<flag> is required` error (with enum errors listing the
choices).

Fields required only by a selected nested union branch fail body validation
when missing, including on a TTY; they do not trigger interactive prompts.

This is the primary closure of wrangler's "interactive wizard" gap
for the common case (single missing required field). Multi-step
branching wizards (`pipelines setup`, `ai-search create`) require a bounded
Expand Down
148 changes: 148 additions & 0 deletions packages/cli/generator/plugin/body-unions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { obj, type Value } from "./js.js";
import { deref, unwrapNull } from "./operation.js";
import type { ObjectType, Type } from "@cloudflare/forge/plugin";

export type BodyUnion = {
path: string[];
} & (
| {
selector: string;
variants: { values: (string | number | boolean)[]; schema: Value }[];
}
| { schema: Value }
);

// Distinct required enum values select a branch even without an OpenAPI
// discriminator.
function taggedMembers(
type: Type
): { selector: string; members: ObjectType[] } | undefined {
const union = unwrapNull(deref(type));
if (union.kind !== "union" || !union.exclusive) return undefined;
const members = union.members.map((m) => unwrapNull(deref(m)));
if (
members.length < 2 ||
!members.every((m): m is ObjectType => m.kind === "object")
)
return undefined;
for (const candidate of members[0]?.fields ?? []) {
const seen: (string | number | boolean)[] = [];
if (
members.every((member) => {
const field = member.fields.find(
(f) => f.wireName === candidate.wireName
);
if (!field?.required) return false;
const t = unwrapNull(deref(field.type));
if (t.kind !== "enum" || t.values.some((v) => seen.includes(v)))
return false;
seen.push(...t.values);
return true;
})
)
return { selector: candidate.wireName, members };
}
return undefined;
}

// Project only constraints the Rust model retains. Nested unions use anyOf:
// a projection can lose constraints, so counting matches could reject valid input.
function schema(type: Type, active: readonly Type[] = []): Value {
const t = deref(type);
if (active.includes(t) || active.length > 16) return obj();
const next = [...active, t];
switch (t.kind) {
case "scalar":
return t.scalar === "unknown"
? obj()
: obj([
[
"type",
t.scalar === "number"
? "number"
: t.scalar === "boolean" || t.scalar === "null"
? t.scalar
: "string",
],
]);
case "enum":
return obj([["enum", [...t.values]]]);
case "array":
return obj([
["type", "array"],
["items", schema(t.elem, next)],
...(t.maxItems === undefined
? []
: [["maxItems", t.maxItems] as const]),
]);
case "object":
return obj([
["type", "object"],
[
"properties",
obj(t.fields.map((f) => [f.wireName, schema(f.type, next)])),
],
[
"required",
t.fields
.filter((f) => f.required && !f.readOnly)
.map((f) => f.wireName),
],
[
"additionalProperties",
t.extra === undefined ? false : schema(t.extra, next),
],
]);
case "union":
return obj([["anyOf", t.members.map((m) => schema(m, next))]]);
case "intersection":
return obj([["allOf", t.members.map((m) => schema(m, next))]]);
case "model":
return obj();
}
}

export function bodyUnions(type: Type): BodyUnion[] {
const visit = (
raw: Type,
path: string[],
active: readonly Type[]
): BodyUnion[] => {
const t = unwrapNull(deref(raw));
if (active.includes(t) || path.length > 4) return [];
const next = [...active, t];
const tagged = path.length > 0 ? taggedMembers(t) : undefined;
if (tagged) {
return [
{
path,
selector: tagged.selector,
variants: tagged.members.map((member) => {
const field = member.fields.find(
(f) => f.wireName === tagged.selector
);
const enumType = field && unwrapNull(deref(field.type));
return {
values: enumType?.kind === "enum" ? [...enumType.values] : [],
schema: schema(member),
};
}),
},
];
}
if (t.kind === "object") {
return t.fields.flatMap((field) =>
field.readOnly ? [] : visit(field.type, [...path, field.wireName], next)
);
}
if (t.kind === "intersection" || t.kind === "union") {
const nested = t.members.flatMap((member) => visit(member, path, next));
// A nested union's requirements only apply within its outer alternative.
return t.kind === "union" && nested.length > 0
? [{ path, schema: schema(t) }]
: nested;
}
return [];
};
return visit(type, [], []);
}
14 changes: 12 additions & 2 deletions packages/cli/generator/plugin/emit/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,13 +284,17 @@ export function dryRun(ctx: Ctx): string[] {
}
lines.push(` ? ${parseBodyExpression(ctx)}`);
Comment thread
petebacondarwin marked this conversation as resolved.
Resolved
if (d.hasBodyParams) {
lines.push(" : compactBody(");
lines.push(
` : ${(op.bodyUnions?.length ?? 0) > 0 ? "validateBodyUnions(" : ""}compactBody(`
);
const object = bodyObject(ctx, "\t\t\t\t");
object[object.length - 1] += preservedBodyPaths(ctx);
for (const line of object) {
lines.push(` ${line}`);
}
lines.push(" ),");
lines.push(
` )${(op.bodyUnions?.length ?? 0) > 0 ? ", bodyUnions)" : ""},`
);
} else {
lines.push(" : undefined,");
}
Expand Down Expand Up @@ -833,6 +837,9 @@ function bodyAssembly(ctx: Ctx): string[] {
);
}
}
if ((ctx.op.bodyUnions?.length ?? 0) > 0) {
lines.push(" validateBodyUnions(bodyData, bodyUnions);");
}
// Like a typed call, which sends a body that needs none of its fields
// empty, rather than leaving it out.
const opts = ["body: bodyData"];
Expand Down Expand Up @@ -1087,6 +1094,9 @@ export function imports(ctx: Ctx): Imports {
}
i.from(`${lib}/output.js`, ["formatOutput"]);
i.from(`${lib}/dry-run.js`, ["formatDryRun"]);
if ((ctx.op.bodyUnions?.length ?? 0) > 0 && d.hasBodyParams) {
i.from(`${lib}/body-unions.js`, ["validateBodyUnions"]);
}
i.from(`${lib}/progress.js`, ["withProgress"]);
i.from(`${lib}/telemetry/index.js`, ["runWithTelemetry"]);
i.typeFrom(`${lib}/telemetry/index.js`, ["ArgClassification"]);
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/generator/plugin/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
type Obj,
obj,
quote,
stringify,
stringifyPretty,
trimEndMatches,
type Value,
Expand Down Expand Up @@ -430,7 +431,11 @@ class Walk {
ctx.hasAccountOrZoneScope
);
const builderBody = `return yargs\n .usage(${quote(usage)})\n${builder.join("\n")};`;
const declarations = "type Args = InferArgs<typeof builder>;\n";
const declarations =
"type Args = InferArgs<typeof builder>;\n" +
((op.bodyUnions?.length ?? 0) > 0 && ctx.derived.hasBodyParams
? `const bodyUnions: Parameters<typeof validateBodyUnions>[1] = ${stringify(op.bodyUnions ?? [])};\n`
: "");
const telemetryPath =
group !== undefined && group !== ""
? `${resource} ${group.replaceAll("/", " ")} ${method.name}`
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/generator/plugin/operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* the runtime's `Operation` describes.
*/

import { bodyUnions, type BodyUnion } from "./body-unions.js";
import { bodyProperties, discriminator, requiredNames } from "./body.js";
import { nameWords, obj, type Obj, type Value } from "./js.js";
import type {
Expand Down Expand Up @@ -142,6 +143,7 @@ export interface OperationInfo {
requestBodyDescription?: string;
multipart?: MultipartInfo;
bodyDiscriminator?: BodyDiscriminator;
bodyUnions?: BodyUnion[];
maxItems?: number;
requestBodyIsArray: boolean;
/** Each response by status: the success responses, then the others. */
Expand Down Expand Up @@ -213,6 +215,7 @@ export function resolve(method: Method): OperationInfo {
requestBodyDescription: body && requestBodyDescription(body),
multipart: body && multipart(body),
bodyDiscriminator: jsonResolved && discriminator(jsonResolved),
bodyUnions: jsonResolved && bodyUnions(jsonResolved),
maxItems: unwrapped?.kind === "array" ? unwrapped.maxItems : undefined,
requestBodyIsArray: isArray,
responses: [...method.responses, ...method.errors].map((r) => [
Expand Down
Loading
Loading