diff --git a/.changeset/nested-union-validation.md b/.changeset/nested-union-validation.md new file mode 100644 index 0000000000..19a7faeb62 --- /dev/null +++ b/.changeset/nested-union-validation.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 295c56392f..65a5de47d1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 @@ -660,6 +666,9 @@ When stdin is a TTY and a required body field isn't supplied: `-- 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 diff --git a/packages/cli/generator/plugin/body-unions.ts b/packages/cli/generator/plugin/body-unions.ts new file mode 100644 index 0000000000..40d8b85256 --- /dev/null +++ b/packages/cli/generator/plugin/body-unions.ts @@ -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, [], []); +} diff --git a/packages/cli/generator/plugin/emit/handler.ts b/packages/cli/generator/plugin/emit/handler.ts index b70223f5a0..202aa637f7 100644 --- a/packages/cli/generator/plugin/emit/handler.ts +++ b/packages/cli/generator/plugin/emit/handler.ts @@ -284,13 +284,17 @@ export function dryRun(ctx: Ctx): string[] { } lines.push(` ? ${parseBodyExpression(ctx)}`); 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,"); } @@ -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"]; @@ -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"]); diff --git a/packages/cli/generator/plugin/files.ts b/packages/cli/generator/plugin/files.ts index e080cf7e5c..8ab33729e2 100644 --- a/packages/cli/generator/plugin/files.ts +++ b/packages/cli/generator/plugin/files.ts @@ -40,6 +40,7 @@ import { type Obj, obj, quote, + stringify, stringifyPretty, trimEndMatches, type Value, @@ -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;\n"; + const declarations = + "type Args = InferArgs;\n" + + ((op.bodyUnions?.length ?? 0) > 0 && ctx.derived.hasBodyParams + ? `const bodyUnions: Parameters[1] = ${stringify(op.bodyUnions ?? [])};\n` + : ""); const telemetryPath = group !== undefined && group !== "" ? `${resource} ${group.replaceAll("/", " ")} ${method.name}` diff --git a/packages/cli/generator/plugin/operation.ts b/packages/cli/generator/plugin/operation.ts index 0ae871e56f..7dd9bd2f5c 100644 --- a/packages/cli/generator/plugin/operation.ts +++ b/packages/cli/generator/plugin/operation.ts @@ -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 { @@ -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. */ @@ -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) => [ diff --git a/packages/cli/src/__tests__/generator/plugin/nested-unions.test.ts b/packages/cli/src/__tests__/generator/plugin/nested-unions.test.ts new file mode 100644 index 0000000000..a0d3626f5a --- /dev/null +++ b/packages/cli/src/__tests__/generator/plugin/nested-unions.test.ts @@ -0,0 +1,482 @@ +import { stripTypeScriptTypes } from "node:module"; +import { Script } from "node:vm"; +import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; +import yargs, { type CommandModule } from "yargs"; +import { + compactBody, + parseBody, + setNestedValue, +} from "../../../lib/body-parser.js"; +import { validateBodyUnions } from "../../../lib/body-unions.js"; +import { resolveFileToken } from "../../../lib/input-validation.js"; +import { generateFiles } from "../../helpers/cli-plugin.js"; + +type Json = Record; +const id = "b07a1f7c-1b34-4fba-9c57-f8033b913973"; +function scope(explicit: boolean): Json { + return { + ...(explicit ? { discriminator: { propertyName: "type" } } : {}), + oneOf: [ + { + type: "object", + additionalProperties: false, + required: ["type"], + properties: { type: { type: "string", enum: ["default"] } }, + }, + { + type: "object", + additionalProperties: false, + required: ["type", "dataset_ids"], + properties: { + type: { type: "string", enum: ["datasets"] }, + dataset_ids: { + type: "array", + minItems: 1, + maxItems: 50, + items: { type: "string", format: "uuid" }, + "x-fern-property-name": "ids", + }, + }, + }, + ], + }; +} +function generate(schema: Json, schemas: Json = {}): Map { + const paths: Json = {}; + for (const [verb, name] of [ + ["post", "create"], + ["put", "update"], + ] as const) { + paths[`/things/${name}`] = { + [verb]: { + summary: `${name} a thing`, + "x-fern-sdk-group-name": "things", + "x-fern-sdk-method-name": name, + requestBody: { + required: true, + content: { + "application/json": { schema }, + }, + }, + responses: { + "200": { + description: "OK", + content: { "application/json": { schema: { type: "object" } } }, + }, + }, + }, + }; + } + return generateFiles({ + openapi: "3.0.3", + info: { title: "Nested unions", version: "1" }, + paths, + components: { schemas }, + }); +} + +// Execute the regenerated builder and handler, replacing only I/O. +function command(code: string) { + const send = vi.fn( + async (_operation: unknown, _request: { body: unknown }) => undefined + ); + const preview = vi.fn((_detail: { body: unknown }) => {}); + const source = stripTypeScriptTypes( + code + .slice(code.indexOf("function builder")) + .replace("export default command;", "command;") + ); + const module = new Script(source).runInNewContext({ + compactBody, + parseBody, + setNestedValue, + validateBodyUnions, + resolveFileToken, + formatDryRun: preview, + operation: {}, + createCommandClient: async () => ({ send }), + requestApi: async ( + _client: unknown, + _method: string, + _path: string, + request: { body: unknown } + ) => send({}, request), + runWithTelemetry: async ( + _meta: unknown, + _argv: unknown, + task: () => Promise + ) => task(), + withProgress: async (_label: string, task: () => Promise) => + task(), + formatOutput: () => {}, + }) as CommandModule; + return { + send, + preview, + run: async (argv: string[]) => + yargs(argv) + .exitProcess(false) + .command(module) + .strict() + .fail((message, error) => { + throw error ?? new Error(message); + }) + .parseAsync(), + }; +} + +describe.each([false, true])( + "nested unions (explicit discriminator and reference: %s)", + (explicit) => { + let files: Map; + beforeAll(() => { + files = generate( + { + type: "object", + properties: { + settings: { + type: "object", + properties: { + scope: explicit + ? { $ref: "#/components/schemas/Scope" } + : scope(explicit), + }, + }, + }, + }, + explicit ? { Scope: scope(explicit) } : {} + ); + }); + describe.each(["create", "update"])("%s", (name) => { + const flag = (suffix: string) => `--settings-scope-${suffix}`; + it.each([false, true])( + "accepts valid scopes (dry run: %s)", + async (dryRun) => { + for (const [flags, expected] of [ + [[], undefined], + [[flag("type"), "default"], { type: "default" }], + [ + [flag("type"), "datasets", flag("ids"), id], + { type: "datasets", dataset_ids: [id] }, + ], + [ + [ + flag("type"), + "datasets", + flag("ids"), + ...Array(50).fill(id), + ], + { type: "datasets", dataset_ids: Array(50).fill(id) }, + ], + ] as const) { + const c = command(files.get(`things/${name}.ts`) ?? ""); + await c.run([name, ...flags, ...(dryRun ? ["--dry-run"] : [])]); + const body = dryRun + ? c.preview.mock.calls[0]?.[0]?.body + : c.send.mock.calls[0]?.[1]?.body; + expect(body).toEqual( + expected === undefined ? {} : { settings: { scope: expected } } + ); + if (dryRun) { + expect(c.send).not.toHaveBeenCalled(); + } + } + } + ); + it.each([false, true])( + "rejects invalid scopes before preview or send (dry run: %s)", + async (dryRun) => { + for (const [flags, error] of [ + [[flag("type"), "datasets"], /dataset_ids.*required/], + [ + [flag("type"), "default", flag("ids"), id], + /dataset_ids.*not allowed/, + ], + [[flag("ids"), id], /settings-scope-type.*required/], + [ + [ + flag("type"), + "datasets", + flag("ids"), + ...Array(51).fill(id), + ], + /at most 50/, + ], + ] as const) { + const c = command(files.get(`things/${name}.ts`) ?? ""); + await expect( + c.run([name, ...flags, ...(dryRun ? ["--dry-run"] : [])]) + ).rejects.toThrow(error); + expect(c.send).not.toHaveBeenCalled(); + expect(c.preview).not.toHaveBeenCalled(); + } + } + ); + it.each([false, true])( + "keeps complete --body input available (dry run: %s)", + async (dryRun) => { + const c = command(files.get(`things/${name}.ts`) ?? ""); + const body = { + settings: { scope: { type: "datasets", dataset_ids: [id] } }, + }; + await c.run([ + name, + "--body", + JSON.stringify(body), + ...(dryRun ? ["--dry-run"] : []), + ]); + expect( + dryRun + ? c.preview.mock.calls[0]?.[0]?.body + : c.send.mock.calls[0]?.[1]?.body + ).toEqual(body); + } + ); + it.each([false, true])( + "lets --body bypass projected union validation (dry run: %s)", + async (dryRun) => { + const c = command(files.get(`things/${name}.ts`) ?? ""); + const body = { settings: { scope: { type: "datasets" } } }; + await c.run([ + name, + "--body", + JSON.stringify(body), + ...(dryRun ? ["--dry-run"] : []), + ]); + expect( + dryRun + ? c.preview.mock.calls[0]?.[0]?.body + : c.send.mock.calls[0]?.[1]?.body + ).toEqual(body); + } + ); + }); + } +); + +describe.each([false, true])("outer union (at root: %s)", (root) => { + let files: Map; + beforeAll(() => { + const outer = { + oneOf: [ + { + type: "object", + additionalProperties: false, + required: ["scope"], + properties: { scope: scope(false) }, + }, + { + type: "object", + additionalProperties: false, + required: ["fallback"], + properties: { + fallback: { type: "string" }, + scope: { + type: "object", + additionalProperties: false, + required: ["type"], + properties: { + type: { type: "string", enum: ["default", "datasets"] }, + }, + }, + }, + }, + ], + }; + files = generate( + root ? outer : { type: "object", properties: { settings: outer } } + ); + }); + for (const name of ["create", "update"]) { + it.each([false, true])( + `${name} preserves outer alternatives (dry run: %s)`, + async (dryRun) => { + const prefix = root ? "--" : "--settings-"; + for (const [flags, expected] of [ + [[`${prefix}scope-type`, "default"], { scope: { type: "default" } }], + [ + [`${prefix}scope-type`, "datasets", `${prefix}scope-ids`, id], + { scope: { type: "datasets", dataset_ids: [id] } }, + ], + [ + [`${prefix}fallback`, "chosen", `${prefix}scope-type`, "datasets"], + { fallback: "chosen", scope: { type: "datasets" } }, + ], + ] as const) { + const c = command(files.get(`things/${name}.ts`) ?? ""); + await c.run([name, ...flags, ...(dryRun ? ["--dry-run"] : [])]); + expect( + dryRun + ? c.preview.mock.calls[0]?.[0]?.body + : c.send.mock.calls[0]?.[1]?.body + ).toEqual(root ? expected : { settings: expected }); + } + } + ); + it.each([false, true])( + `${name} rejects invalid nested branches (dry run: %s)`, + async (dryRun) => { + const prefix = root ? "--" : "--settings-"; + const c = command(files.get(`things/${name}.ts`) ?? ""); + await expect( + c.run([ + name, + `${prefix}scope-type`, + "datasets", + ...(dryRun ? ["--dry-run"] : []), + ]) + ).rejects.toThrow(root ? /Invalid request body/ : /Invalid settings/); + expect(c.send).not.toHaveBeenCalled(); + expect(c.preview).not.toHaveBeenCalled(); + } + ); + } +}); + +describe("tagged union detection", () => { + it.each([ + [ + "overlapping enums", + { + oneOf: [ + { + type: "object", + required: ["type"], + properties: { type: { enum: ["a"] } }, + }, + { + type: "object", + required: ["type"], + properties: { type: { enum: ["a", "b"] } }, + }, + ], + }, + ], + [ + "optional selector", + { + oneOf: [ + { + type: "object", + required: ["type"], + properties: { type: { enum: ["a"] } }, + }, + { type: "object", properties: { type: { enum: ["b"] } } }, + ], + }, + ], + [ + "untagged objects", + { + oneOf: [ + { type: "object", properties: { name: { type: "string" } } }, + { type: "object", properties: { id: { type: "string" } } }, + ], + }, + ], + [ + "non-object member", + { + oneOf: [ + { + type: "object", + required: ["type"], + properties: { type: { enum: ["a"] } }, + }, + { type: "string" }, + ], + }, + ], + [ + "nonexclusive union", + { + anyOf: [ + { + type: "object", + required: ["type"], + properties: { type: { enum: ["a"] } }, + }, + { + type: "object", + required: ["type"], + properties: { type: { enum: ["b"] } }, + }, + ], + }, + ], + [ + "single member", + { + oneOf: [ + { + type: "object", + required: ["type"], + properties: { type: { enum: ["a"] } }, + }, + ], + }, + ], + ])("leaves %s without tagged validation", (_name, selection) => { + const files = generate({ + type: "object", + properties: { label: { type: "string" }, scope: selection }, + }); + for (const name of ["create", "update"]) { + expect(files.get(`things/${name}.ts`)).not.toContain( + "validateBodyUnions" + ); + } + }); + it.each([false, true])( + "validates boolean selectors (dry run: %s)", + async (dryRun) => { + const files = generate({ + type: "object", + properties: { + scope: { + oneOf: [ + { + type: "object", + additionalProperties: false, + required: ["enabled"], + properties: { enabled: { type: "boolean", enum: [false] } }, + }, + { + type: "object", + additionalProperties: false, + required: ["enabled", "ids"], + properties: { + enabled: { type: "boolean", enum: [true] }, + ids: { type: "array", items: { type: "string" } }, + }, + }, + ], + }, + }, + }); + for (const name of ["create", "update"]) { + for (const [flags, expected] of [ + [["--scope-enabled=false"], { enabled: false }], + [ + ["--scope-enabled", "--scope-ids", id], + { enabled: true, ids: [id] }, + ], + ] as const) { + const c = command(files.get(`things/${name}.ts`) ?? ""); + await c.run([name, ...flags, ...(dryRun ? ["--dry-run"] : [])]); + expect( + dryRun + ? c.preview.mock.calls[0]?.[0]?.body + : c.send.mock.calls[0]?.[1]?.body + ).toEqual({ scope: expected }); + } + const c = command(files.get(`things/${name}.ts`) ?? ""); + await expect( + c.run([name, "--scope-enabled", ...(dryRun ? ["--dry-run"] : [])]) + ).rejects.toThrow(/ids.*required/); + expect(c.send).not.toHaveBeenCalled(); + expect(c.preview).not.toHaveBeenCalled(); + } + } + ); +}); diff --git a/packages/cli/src/commands/_generated/builds/workers/create.ts b/packages/cli/src/commands/_generated/builds/workers/create.ts index ddecc26f0d..a3db810bd0 100644 --- a/packages/cli/src/commands/_generated/builds/workers/create.ts +++ b/packages/cli/src/commands/_generated/builds/workers/create.ts @@ -12,6 +12,7 @@ import { resolveAccountIdSilent, } from "#lib/auth.js"; import { compactBody, parseBody, parseJsonMap } from "#lib/body-parser.js"; +import { validateBodyUnions } from "#lib/body-unions.js"; import { formatDryRun } from "#lib/dry-run.js"; import { resolveFileToken } from "#lib/input-validation.js"; import { LOCAL_ACCOUNT_ID } from "#lib/local.js"; @@ -161,6 +162,62 @@ function builder(yargs: Argv) { } type Args = InferArgs; +const bodyUnions: Parameters[1] = [ + { + path: ["git_repository"], + selector: "provider_type", + variants: [ + { + values: ["github", "gitlab"], + schema: { + type: "object", + properties: { + branch: { type: "string" }, + provider_account_id: { type: "string" }, + provider_account_name: { type: "string" }, + provider_type: { enum: ["github", "gitlab"] }, + repo_id: { type: "string" }, + repo_name: { type: "string" }, + }, + required: [ + "branch", + "provider_account_id", + "provider_account_name", + "provider_type", + "repo_id", + "repo_name", + ], + additionalProperties: false, + }, + }, + { + values: ["gitlab_internal", "origin"], + schema: { + type: "object", + properties: { + branch: { type: "string" }, + grant_id: { type: "string" }, + provider_account_id: { type: "string" }, + provider_account_name: { type: "string" }, + provider_type: { enum: ["gitlab_internal", "origin"] }, + repo_id: { type: "string" }, + repo_name: { type: "string" }, + }, + required: [ + "branch", + "grant_id", + "provider_account_id", + "provider_account_name", + "provider_type", + "repo_id", + "repo_name", + ], + additionalProperties: false, + }, + }, + ], + }, +]; const command: CommandModule = { command: "create", @@ -193,138 +250,145 @@ const command: CommandModule = { body: argv.body !== undefined ? parseBody(argv.body) - : compactBody( - { - git_repository: { - branch: resolveFileToken( - argv["git-repository-branch"] as string | undefined, - "git-repository-branch", - "text" - ), - provider_account_id: resolveFileToken( - argv["git-repository-provider-account-id"] as - | string - | undefined, - "git-repository-provider-account-id", - "text" - ), - provider_account_name: resolveFileToken( - argv["git-repository-provider-account-name"] as - | string - | undefined, - "git-repository-provider-account-name", - "text" - ), - provider_type: argv["git-repository-provider-type"], - repo_id: resolveFileToken( - argv["git-repository-repo-id"] as string | undefined, - "git-repository-repo-id", - "text" - ), - repo_name: resolveFileToken( - argv["git-repository-repo-name"] as - | string - | undefined, - "git-repository-repo-name", - "text" - ), - grant_id: resolveFileToken( - argv["git-repository-grant-id"] as string | undefined, - "git-repository-grant-id", - "text" - ), - }, - previews_base_config: { - build_caching_enabled: - argv["previews-base-config-build-caching-enabled"], - build_command: resolveFileToken( - argv["previews-base-config-build-command"] as - | string - | undefined, - "previews-base-config-build-command", - "text" - ), - build_token_uuid: resolveFileToken( - argv["previews-base-config-build-token-uuid"] as - | string - | undefined, - "previews-base-config-build-token-uuid", - "text" - ), - deploy_command: resolveFileToken( - argv["previews-base-config-deploy-command"] as - | string - | undefined, - "previews-base-config-deploy-command", - "text" - ), - environment_variables: parseJsonMap( - argv["previews-base-config-environment-variables"], - "previews-base-config-environment-variables" - ), - path_excludes: - argv["previews-base-config-path-excludes"], - path_includes: - argv["previews-base-config-path-includes"], - root_directory: resolveFileToken( - argv["previews-base-config-root-directory"] as - | string - | undefined, - "previews-base-config-root-directory", - "text" - ), - }, - previews_enabled: argv["previews-enabled"], - production_settings: { - build_caching_enabled: - argv["production-settings-build-caching-enabled"], - build_command: resolveFileToken( - argv["production-settings-build-command"] as - | string - | undefined, - "production-settings-build-command", - "text" - ), - build_token_uuid: resolveFileToken( - argv["production-settings-build-token-uuid"] as - | string - | undefined, - "production-settings-build-token-uuid", - "text" - ), - deploy_command: resolveFileToken( - argv["production-settings-deploy-command"] as - | string - | undefined, - "production-settings-deploy-command", - "text" - ), - environment_variables: parseJsonMap( - argv["production-settings-environment-variables"], - "production-settings-environment-variables" - ), - path_excludes: - argv["production-settings-path-excludes"], - path_includes: - argv["production-settings-path-includes"], - root_directory: resolveFileToken( - argv["production-settings-root-directory"] as - | string - | undefined, - "production-settings-root-directory", + : validateBodyUnions( + compactBody( + { + git_repository: { + branch: resolveFileToken( + argv["git-repository-branch"] as string | undefined, + "git-repository-branch", + "text" + ), + provider_account_id: resolveFileToken( + argv["git-repository-provider-account-id"] as + | string + | undefined, + "git-repository-provider-account-id", + "text" + ), + provider_account_name: resolveFileToken( + argv["git-repository-provider-account-name"] as + | string + | undefined, + "git-repository-provider-account-name", + "text" + ), + provider_type: argv["git-repository-provider-type"], + repo_id: resolveFileToken( + argv["git-repository-repo-id"] as + | string + | undefined, + "git-repository-repo-id", + "text" + ), + repo_name: resolveFileToken( + argv["git-repository-repo-name"] as + | string + | undefined, + "git-repository-repo-name", + "text" + ), + grant_id: resolveFileToken( + argv["git-repository-grant-id"] as + | string + | undefined, + "git-repository-grant-id", + "text" + ), + }, + previews_base_config: { + build_caching_enabled: + argv["previews-base-config-build-caching-enabled"], + build_command: resolveFileToken( + argv["previews-base-config-build-command"] as + | string + | undefined, + "previews-base-config-build-command", + "text" + ), + build_token_uuid: resolveFileToken( + argv["previews-base-config-build-token-uuid"] as + | string + | undefined, + "previews-base-config-build-token-uuid", + "text" + ), + deploy_command: resolveFileToken( + argv["previews-base-config-deploy-command"] as + | string + | undefined, + "previews-base-config-deploy-command", + "text" + ), + environment_variables: parseJsonMap( + argv["previews-base-config-environment-variables"], + "previews-base-config-environment-variables" + ), + path_excludes: + argv["previews-base-config-path-excludes"], + path_includes: + argv["previews-base-config-path-includes"], + root_directory: resolveFileToken( + argv["previews-base-config-root-directory"] as + | string + | undefined, + "previews-base-config-root-directory", + "text" + ), + }, + previews_enabled: argv["previews-enabled"], + production_settings: { + build_caching_enabled: + argv["production-settings-build-caching-enabled"], + build_command: resolveFileToken( + argv["production-settings-build-command"] as + | string + | undefined, + "production-settings-build-command", + "text" + ), + build_token_uuid: resolveFileToken( + argv["production-settings-build-token-uuid"] as + | string + | undefined, + "production-settings-build-token-uuid", + "text" + ), + deploy_command: resolveFileToken( + argv["production-settings-deploy-command"] as + | string + | undefined, + "production-settings-deploy-command", + "text" + ), + environment_variables: parseJsonMap( + argv["production-settings-environment-variables"], + "production-settings-environment-variables" + ), + path_excludes: + argv["production-settings-path-excludes"], + path_includes: + argv["production-settings-path-includes"], + root_directory: resolveFileToken( + argv["production-settings-root-directory"] as + | string + | undefined, + "production-settings-root-directory", + "text" + ), + }, + script_tag: resolveFileToken( + argv["script-tag"] as string | undefined, + "script-tag", "text" ), }, - script_tag: resolveFileToken( - argv["script-tag"] as string | undefined, - "script-tag", - "text" - ), - }, - [ - ["previews_base_config", "environment_variables"], - ["production_settings", "environment_variables"], - ] + [ + ["previews_base_config", "environment_variables"], + ["production_settings", "environment_variables"], + ] + ), + bodyUnions ), }); return; @@ -561,6 +625,7 @@ const command: CommandModule = { ["production_settings", "environment_variables"], ] ); + validateBodyUnions(bodyData, bodyUnions); const result = await withProgress(`Creating`, async () => client.send(operation, { body: bodyData, account_id: accountId }) ); diff --git a/packages/cli/src/commands/_generated/cloudforce-one/priority-intelligence/create.ts b/packages/cli/src/commands/_generated/cloudforce-one/priority-intelligence/create.ts index 98a2bf1ec7..d618136557 100644 --- a/packages/cli/src/commands/_generated/cloudforce-one/priority-intelligence/create.ts +++ b/packages/cli/src/commands/_generated/cloudforce-one/priority-intelligence/create.ts @@ -17,6 +17,7 @@ import { parseNestedArray, parseObjectArray, } from "#lib/body-parser.js"; +import { validateBodyUnions } from "#lib/body-unions.js"; import { formatDryRun } from "#lib/dry-run.js"; import { resolveFileToken } from "#lib/input-validation.js"; import { LOCAL_ACCOUNT_ID } from "#lib/local.js"; @@ -183,6 +184,39 @@ function builder(yargs: Argv) { } type Args = InferArgs; +const bodyUnions: Parameters[1] = [ + { + path: ["dataset_scope"], + selector: "type", + variants: [ + { + values: ["default"], + schema: { + type: "object", + properties: { type: { enum: ["default"] } }, + required: ["type"], + additionalProperties: false, + }, + }, + { + values: ["datasets"], + schema: { + type: "object", + properties: { + dataset_ids: { + type: "array", + items: { type: "string" }, + maxItems: 50, + }, + type: { enum: ["datasets"] }, + }, + required: ["dataset_ids", "type"], + additionalProperties: false, + }, + }, + ], + }, +]; const command: CommandModule = { command: "create", @@ -215,53 +249,56 @@ const command: CommandModule = { body: argv.body !== undefined ? parseBody(argv.body) - : compactBody({ - dataset_scope: { - type: argv["dataset-scope-type"], - dataset_ids: argv["dataset-scope-dataset-ids"], - }, - digest_interval_hours: argv["digest-interval-hours"], - intent: { - clauses: parseObjectArray( - argv["intent-clauses"], - "intent-clauses" + : validateBodyUnions( + compactBody({ + dataset_scope: { + type: argv["dataset-scope-type"], + dataset_ids: argv["dataset-scope-dataset-ids"], + }, + digest_interval_hours: argv["digest-interval-hours"], + intent: { + clauses: parseObjectArray( + argv["intent-clauses"], + "intent-clauses" + ), + indicators: argv["intent-indicators"], + unsupported_terms: argv["intent-unsupported-terms"], + }, + labels: argv["labels"], + model: resolveFileToken( + argv["model"] as string | undefined, + "model", + "text" + ), + notification_mode: argv["notification-mode"], + priority: argv["priority"], + query: { + branches: parseNestedArray( + argv["query-branches"], + "query-branches" + ), + version: argv["query-version"], + }, + query_source: argv["query-source"], + requirement: resolveFileToken( + argv["requirement"] as string | undefined, + "requirement", + "text" + ), + status: argv["status"], + title: resolveFileToken( + argv["title"] as string | undefined, + "title", + "text" ), - indicators: argv["intent-indicators"], - unsupported_terms: argv["intent-unsupported-terms"], - }, - labels: argv["labels"], - model: resolveFileToken( - argv["model"] as string | undefined, - "model", - "text" - ), - notification_mode: argv["notification-mode"], - priority: argv["priority"], - query: { - branches: parseNestedArray( - argv["query-branches"], - "query-branches" + tlp: resolveFileToken( + argv["tlp"] as string | undefined, + "tlp", + "text" ), - version: argv["query-version"], - }, - query_source: argv["query-source"], - requirement: resolveFileToken( - argv["requirement"] as string | undefined, - "requirement", - "text" - ), - status: argv["status"], - title: resolveFileToken( - argv["title"] as string | undefined, - "title", - "text" - ), - tlp: resolveFileToken( - argv["tlp"] as string | undefined, - "tlp", - "text" - ), - }), + }), + bodyUnions + ), }); return; } @@ -329,6 +366,7 @@ const command: CommandModule = { "text" ), }); + validateBodyUnions(bodyData, bodyUnions); const result = await withProgress(`Creating`, async () => client.send(operation, { body: bodyData, account_id: accountId }) ); diff --git a/packages/cli/src/commands/_generated/cloudforce-one/priority-intelligence/update.ts b/packages/cli/src/commands/_generated/cloudforce-one/priority-intelligence/update.ts index eef31f068f..60e79d37c5 100644 --- a/packages/cli/src/commands/_generated/cloudforce-one/priority-intelligence/update.ts +++ b/packages/cli/src/commands/_generated/cloudforce-one/priority-intelligence/update.ts @@ -17,6 +17,7 @@ import { parseNestedArray, parseObjectArray, } from "#lib/body-parser.js"; +import { validateBodyUnions } from "#lib/body-unions.js"; import { formatDryRun } from "#lib/dry-run.js"; import { resolveFileToken } from "#lib/input-validation.js"; import { LOCAL_ACCOUNT_ID } from "#lib/local.js"; @@ -181,6 +182,39 @@ function builder(yargs: Argv) { } type Args = InferArgs; +const bodyUnions: Parameters[1] = [ + { + path: ["dataset_scope"], + selector: "type", + variants: [ + { + values: ["default"], + schema: { + type: "object", + properties: { type: { enum: ["default"] } }, + required: ["type"], + additionalProperties: false, + }, + }, + { + values: ["datasets"], + schema: { + type: "object", + properties: { + dataset_ids: { + type: "array", + items: { type: "string" }, + maxItems: 50, + }, + type: { enum: ["datasets"] }, + }, + required: ["dataset_ids", "type"], + additionalProperties: false, + }, + }, + ], + }, +]; const command: CommandModule = { command: "update ", @@ -214,54 +248,57 @@ const command: CommandModule = { body: argv.body !== undefined ? parseBody(argv.body) - : compactBody({ - confirm_query: argv["confirm-query"], - dataset_scope: { - type: argv["dataset-scope-type"], - dataset_ids: argv["dataset-scope-dataset-ids"], - }, - digest_interval_hours: argv["digest-interval-hours"], - intent: { - clauses: parseObjectArray( - argv["intent-clauses"], - "intent-clauses" + : validateBodyUnions( + compactBody({ + confirm_query: argv["confirm-query"], + dataset_scope: { + type: argv["dataset-scope-type"], + dataset_ids: argv["dataset-scope-dataset-ids"], + }, + digest_interval_hours: argv["digest-interval-hours"], + intent: { + clauses: parseObjectArray( + argv["intent-clauses"], + "intent-clauses" + ), + indicators: argv["intent-indicators"], + unsupported_terms: argv["intent-unsupported-terms"], + }, + labels: argv["labels"], + model: resolveFileToken( + argv["model"] as string | undefined, + "model", + "text" + ), + notification_mode: argv["notification-mode"], + priority: argv["priority"], + query: { + branches: parseNestedArray( + argv["query-branches"], + "query-branches" + ), + version: argv["query-version"], + }, + query_source: argv["query-source"], + requirement: resolveFileToken( + argv["requirement"] as string | undefined, + "requirement", + "text" + ), + status: argv["status"], + title: resolveFileToken( + argv["title"] as string | undefined, + "title", + "text" ), - indicators: argv["intent-indicators"], - unsupported_terms: argv["intent-unsupported-terms"], - }, - labels: argv["labels"], - model: resolveFileToken( - argv["model"] as string | undefined, - "model", - "text" - ), - notification_mode: argv["notification-mode"], - priority: argv["priority"], - query: { - branches: parseNestedArray( - argv["query-branches"], - "query-branches" + tlp: resolveFileToken( + argv["tlp"] as string | undefined, + "tlp", + "text" ), - version: argv["query-version"], - }, - query_source: argv["query-source"], - requirement: resolveFileToken( - argv["requirement"] as string | undefined, - "requirement", - "text" - ), - status: argv["status"], - title: resolveFileToken( - argv["title"] as string | undefined, - "title", - "text" - ), - tlp: resolveFileToken( - argv["tlp"] as string | undefined, - "tlp", - "text" - ), - }), + }), + bodyUnions + ), }); return; } @@ -328,6 +365,7 @@ const command: CommandModule = { "text" ), }); + validateBodyUnions(bodyData, bodyUnions); const result = await withProgress(`Updating`, async () => client.send(operation, { body: bodyData, diff --git a/packages/cli/src/commands/_generated/email-sending/suppressions/create.ts b/packages/cli/src/commands/_generated/email-sending/suppressions/create.ts index 837e167f36..2be429b241 100644 --- a/packages/cli/src/commands/_generated/email-sending/suppressions/create.ts +++ b/packages/cli/src/commands/_generated/email-sending/suppressions/create.ts @@ -12,6 +12,7 @@ import { resolveAccountIdSilent, } from "#lib/auth.js"; import { compactBody, parseBody } from "#lib/body-parser.js"; +import { validateBodyUnions } from "#lib/body-unions.js"; import { formatDryRun } from "#lib/dry-run.js"; import { resolveFileToken } from "#lib/input-validation.js"; import { LOCAL_ACCOUNT_ID } from "#lib/local.js"; @@ -79,6 +80,35 @@ function builder(yargs: Argv) { } type Args = InferArgs; +const bodyUnions: Parameters[1] = [ + { + path: ["scope"], + selector: "type", + variants: [ + { + values: ["account"], + schema: { + type: "object", + properties: { type: { enum: ["account"] } }, + required: ["type"], + additionalProperties: false, + }, + }, + { + values: ["sending_domain"], + schema: { + type: "object", + properties: { + type: { enum: ["sending_domain"] }, + value: { type: "string" }, + }, + required: ["type", "value"], + additionalProperties: false, + }, + }, + ], + }, +]; const command: CommandModule = { command: "create", @@ -105,31 +135,34 @@ const command: CommandModule = { body: argv.body !== undefined ? parseBody(argv.body) - : compactBody({ - email: resolveFileToken( - argv["email"] as string | undefined, - "email", - "text" - ), - expires_at: resolveFileToken( - argv["expires-at"] as string | undefined, - "expires-at", - "text" - ), - note: resolveFileToken( - argv["note"] as string | undefined, - "note", - "text" - ), - scope: { - type: argv["scope-type"], - value: resolveFileToken( - argv["scope-value"] as string | undefined, - "scope-value", + : validateBodyUnions( + compactBody({ + email: resolveFileToken( + argv["email"] as string | undefined, + "email", + "text" + ), + expires_at: resolveFileToken( + argv["expires-at"] as string | undefined, + "expires-at", + "text" + ), + note: resolveFileToken( + argv["note"] as string | undefined, + "note", "text" ), - }, - }), + scope: { + type: argv["scope-type"], + value: resolveFileToken( + argv["scope-value"] as string | undefined, + "scope-value", + "text" + ), + }, + }), + bodyUnions + ), }); return; } @@ -178,6 +211,7 @@ const command: CommandModule = { ), }, }); + validateBodyUnions(bodyData, bodyUnions); const result = await withProgress(`Creating`, async () => client.send(operation, { body: bodyData, account_id: accountId }) ); diff --git a/packages/cli/src/commands/_generated/k2/streams/create.ts b/packages/cli/src/commands/_generated/k2/streams/create.ts index 45f7446bf3..75abfffa81 100644 --- a/packages/cli/src/commands/_generated/k2/streams/create.ts +++ b/packages/cli/src/commands/_generated/k2/streams/create.ts @@ -12,6 +12,7 @@ import { resolveAccountIdSilent, } from "#lib/auth.js"; import { compactBody, parseBody } from "#lib/body-parser.js"; +import { validateBodyUnions } from "#lib/body-unions.js"; import { formatDryRun } from "#lib/dry-run.js"; import { resolveFileToken } from "#lib/input-validation.js"; import { LOCAL_ACCOUNT_ID } from "#lib/local.js"; @@ -99,6 +100,32 @@ function builder(yargs: Argv) { } type Args = InferArgs; +const bodyUnions: Parameters[1] = [ + { + path: ["worker_binding"], + selector: "enabled", + variants: [ + { + values: [false], + schema: { + type: "object", + properties: { enabled: { enum: [false] } }, + required: ["enabled"], + additionalProperties: false, + }, + }, + { + values: [true], + schema: { + type: "object", + properties: { enabled: { enum: [true] } }, + required: ["enabled"], + additionalProperties: false, + }, + }, + ], + }, +]; const command: CommandModule = { command: "create", @@ -130,24 +157,27 @@ const command: CommandModule = { body: argv.body !== undefined ? parseBody(argv.body) - : compactBody({ - http: { - authentication: argv["http-authentication"], - cors: { - origins: argv["http-cors-origins"], + : validateBodyUnions( + compactBody({ + http: { + authentication: argv["http-authentication"], + cors: { + origins: argv["http-cors-origins"], + }, + enabled: argv["http-enabled"], + }, + name: resolveFileToken( + argv["name"] as string | undefined, + "name", + "text" + ), + retention_seconds: argv["retention-seconds"], + worker_binding: { + enabled: argv["worker-binding-enabled"], }, - enabled: argv["http-enabled"], - }, - name: resolveFileToken( - argv["name"] as string | undefined, - "name", - "text" - ), - retention_seconds: argv["retention-seconds"], - worker_binding: { - enabled: argv["worker-binding-enabled"], - }, - }), + }), + bodyUnions + ), }); return; } @@ -189,6 +219,7 @@ const command: CommandModule = { enabled: argv["worker-binding-enabled"], }, }); + validateBodyUnions(bodyData, bodyUnions); const result = await withProgress(`Creating`, async () => client.send(operation, { body: bodyData, account_id: accountId }) ); diff --git a/packages/cli/src/commands/_generated/k2/streams/update.ts b/packages/cli/src/commands/_generated/k2/streams/update.ts index 07c4da36a4..73a853985c 100644 --- a/packages/cli/src/commands/_generated/k2/streams/update.ts +++ b/packages/cli/src/commands/_generated/k2/streams/update.ts @@ -12,6 +12,7 @@ import { resolveAccountIdSilent, } from "#lib/auth.js"; import { compactBody, parseBody } from "#lib/body-parser.js"; +import { validateBodyUnions } from "#lib/body-unions.js"; import { formatDryRun } from "#lib/dry-run.js"; import { LOCAL_ACCOUNT_ID } from "#lib/local.js"; import { formatOutput } from "#lib/output.js"; @@ -97,6 +98,32 @@ function builder(yargs: Argv) { } type Args = InferArgs; +const bodyUnions: Parameters[1] = [ + { + path: ["worker_binding"], + selector: "enabled", + variants: [ + { + values: [false], + schema: { + type: "object", + properties: { enabled: { enum: [false] } }, + required: ["enabled"], + additionalProperties: false, + }, + }, + { + values: [true], + schema: { + type: "object", + properties: { enabled: { enum: [true] } }, + required: ["enabled"], + additionalProperties: false, + }, + }, + ], + }, +]; const command: CommandModule = { command: "update ", @@ -128,19 +155,22 @@ const command: CommandModule = { body: argv.body !== undefined ? parseBody(argv.body) - : compactBody({ - http: { - authentication: argv["http-authentication"], - cors: { - origins: argv["http-cors-origins"], + : validateBodyUnions( + compactBody({ + http: { + authentication: argv["http-authentication"], + cors: { + origins: argv["http-cors-origins"], + }, + enabled: argv["http-enabled"], + }, + retention_seconds: argv["retention-seconds"], + worker_binding: { + enabled: argv["worker-binding-enabled"], }, - enabled: argv["http-enabled"], - }, - retention_seconds: argv["retention-seconds"], - worker_binding: { - enabled: argv["worker-binding-enabled"], - }, - }), + }), + bodyUnions + ), }); return; } @@ -175,6 +205,7 @@ const command: CommandModule = { enabled: argv["worker-binding-enabled"], }, }); + validateBodyUnions(bodyData, bodyUnions); const result = await withProgress(`Updating`, async () => client.send(operation, { body: bodyData, diff --git a/packages/cli/src/commands/_generated/pipelines/sinks/create.ts b/packages/cli/src/commands/_generated/pipelines/sinks/create.ts index bebf8a3785..d807c0bb77 100644 --- a/packages/cli/src/commands/_generated/pipelines/sinks/create.ts +++ b/packages/cli/src/commands/_generated/pipelines/sinks/create.ts @@ -12,6 +12,7 @@ import { resolveAccountIdSilent, } from "#lib/auth.js"; import { compactBody, parseBody, parseObjectArray } from "#lib/body-parser.js"; +import { validateBodyUnions } from "#lib/body-unions.js"; import { formatDryRun } from "#lib/dry-run.js"; import { resolveFileToken } from "#lib/input-validation.js"; import { LOCAL_ACCOUNT_ID } from "#lib/local.js"; @@ -332,6 +333,44 @@ function builder(yargs: Argv) { } type Args = InferArgs; +const bodyUnions: Parameters[1] = [ + { + path: ["format"], + selector: "type", + variants: [ + { + values: ["json"], + schema: { + type: "object", + properties: { + decimal_encoding: { enum: ["number", "string", "bytes"] }, + timestamp_format: { enum: ["rfc3339", "unix_millis"] }, + unstructured: { type: "boolean" }, + compression: { enum: ["uncompressed", "gzip"] }, + type: { enum: ["json"] }, + }, + required: ["type"], + additionalProperties: false, + }, + }, + { + values: ["parquet"], + schema: { + type: "object", + properties: { + compression: { + enum: ["uncompressed", "snappy", "gzip", "zstd", "lz4"], + }, + row_group_bytes: { anyOf: [{ type: "number" }, { type: "null" }] }, + type: { enum: ["parquet"] }, + }, + required: ["type"], + additionalProperties: false, + }, + }, + ], + }, +]; const command: CommandModule = { command: "create", @@ -368,116 +407,123 @@ const command: CommandModule = { body: argv.body !== undefined ? parseBody(argv.body) - : compactBody({ - config: { - account_id: resolveFileToken( - argv["sink-config-account-id"] as string | undefined, - "sink-config-account-id", - "text" - ), - bucket: resolveFileToken( - argv["sink-config-bucket"] as string | undefined, - "sink-config-bucket", - "text" - ), - credentials: { - access_key_id: resolveFileToken( - argv["sink-config-credentials-access-key-id"] as - | string - | undefined, - "sink-config-credentials-access-key-id", + : validateBodyUnions( + compactBody({ + config: { + account_id: resolveFileToken( + argv["sink-config-account-id"] as string | undefined, + "sink-config-account-id", "text" ), - secret_access_key: resolveFileToken( - argv["sink-config-credentials-secret-access-key"] as - | string - | undefined, - "sink-config-credentials-secret-access-key", + bucket: resolveFileToken( + argv["sink-config-bucket"] as string | undefined, + "sink-config-bucket", "text" ), - }, - file_naming: { - prefix: resolveFileToken( - argv["sink-config-file-naming-prefix"] as + credentials: { + access_key_id: resolveFileToken( + argv["sink-config-credentials-access-key-id"] as + | string + | undefined, + "sink-config-credentials-access-key-id", + "text" + ), + secret_access_key: resolveFileToken( + argv[ + "sink-config-credentials-secret-access-key" + ] as string | undefined, + "sink-config-credentials-secret-access-key", + "text" + ), + }, + file_naming: { + prefix: resolveFileToken( + argv["sink-config-file-naming-prefix"] as + | string + | undefined, + "sink-config-file-naming-prefix", + "text" + ), + strategy: argv["sink-config-file-naming-strategy"], + suffix: resolveFileToken( + argv["sink-config-file-naming-suffix"] as + | string + | undefined, + "sink-config-file-naming-suffix", + "text" + ), + }, + jurisdiction: resolveFileToken( + argv["sink-config-jurisdiction"] as | string | undefined, - "sink-config-file-naming-prefix", + "sink-config-jurisdiction", "text" ), - strategy: argv["sink-config-file-naming-strategy"], - suffix: resolveFileToken( - argv["sink-config-file-naming-suffix"] as - | string - | undefined, - "sink-config-file-naming-suffix", + partitioning: { + time_pattern: resolveFileToken( + argv["sink-config-partitioning-time-pattern"] as + | string + | undefined, + "sink-config-partitioning-time-pattern", + "text" + ), + }, + path: resolveFileToken( + argv["sink-config-path"] as string | undefined, + "sink-config-path", "text" ), - }, - jurisdiction: resolveFileToken( - argv["sink-config-jurisdiction"] as string | undefined, - "sink-config-jurisdiction", - "text" - ), - partitioning: { - time_pattern: resolveFileToken( - argv["sink-config-partitioning-time-pattern"] as - | string - | undefined, - "sink-config-partitioning-time-pattern", + rolling_policy: { + file_size_bytes: + argv["sink-config-rolling-policy-file-size-bytes"], + inactivity_seconds: + argv[ + "sink-config-rolling-policy-inactivity-seconds" + ], + interval_seconds: + argv["sink-config-rolling-policy-interval-seconds"], + }, + namespace: resolveFileToken( + argv["sink-config-namespace"] as string | undefined, + "sink-config-namespace", + "text" + ), + table_name: resolveFileToken( + argv["sink-config-table-name"] as string | undefined, + "sink-config-table-name", + "text" + ), + token: resolveFileToken( + argv["sink-config-token"] as string | undefined, + "sink-config-token", "text" ), }, - path: resolveFileToken( - argv["sink-config-path"] as string | undefined, - "sink-config-path", - "text" - ), - rolling_policy: { - file_size_bytes: - argv["sink-config-rolling-policy-file-size-bytes"], - inactivity_seconds: - argv["sink-config-rolling-policy-inactivity-seconds"], - interval_seconds: - argv["sink-config-rolling-policy-interval-seconds"], + format: { + decimal_encoding: argv["format-decimal-encoding"], + timestamp_format: argv["format-timestamp-format"], + unstructured: argv["format-unstructured"], + compression: argv["format-compression"], + type: argv["format-type"], + row_group_bytes: argv["format-row-group-bytes"], }, - namespace: resolveFileToken( - argv["sink-config-namespace"] as string | undefined, - "sink-config-namespace", + name: resolveFileToken( + argv["name"] as string | undefined, + "name", "text" ), - table_name: resolveFileToken( - argv["sink-config-table-name"] as string | undefined, - "sink-config-table-name", - "text" - ), - token: resolveFileToken( - argv["sink-config-token"] as string | undefined, - "sink-config-token", - "text" - ), - }, - format: { - decimal_encoding: argv["format-decimal-encoding"], - timestamp_format: argv["format-timestamp-format"], - unstructured: argv["format-unstructured"], - compression: argv["format-compression"], - type: argv["format-type"], - row_group_bytes: argv["format-row-group-bytes"], - }, - name: resolveFileToken( - argv["name"] as string | undefined, - "name", - "text" - ), - schema: { - fields: parseObjectArray( - argv["schema-fields"], - "schema-fields" - ), - inferred: argv["schema-inferred"], - }, - type: argv["type"], - }), + schema: { + fields: parseObjectArray( + argv["schema-fields"], + "schema-fields" + ), + inferred: argv["schema-inferred"], + }, + type: argv["type"], + }), + bodyUnions + ), sensitiveBodyPaths: [ ["config", "credentials", "secret_access_key"], ["config", "token"], @@ -615,6 +661,7 @@ const command: CommandModule = { }, type: argv["type"], }); + validateBodyUnions(bodyData, bodyUnions); const result = await withProgress(`Creating`, async () => client.send(operation, { body: bodyData, account_id: accountId }) ); diff --git a/packages/cli/src/commands/_generated/r2/super-slurper/jobs/create.ts b/packages/cli/src/commands/_generated/r2/super-slurper/jobs/create.ts index 4913415f1d..a44fa432f6 100644 --- a/packages/cli/src/commands/_generated/r2/super-slurper/jobs/create.ts +++ b/packages/cli/src/commands/_generated/r2/super-slurper/jobs/create.ts @@ -12,6 +12,7 @@ import { resolveAccountIdSilent, } from "#lib/auth.js"; import { compactBody, parseBody } from "#lib/body-parser.js"; +import { validateBodyUnions } from "#lib/body-unions.js"; import { formatDryRun } from "#lib/dry-run.js"; import { resolveFileToken } from "#lib/input-validation.js"; import { LOCAL_ACCOUNT_ID } from "#lib/local.js"; @@ -225,6 +226,101 @@ function builder(yargs: Argv) { } type Args = InferArgs; +const bodyUnions: Parameters[1] = [ + { + path: ["source"], + selector: "vendor", + variants: [ + { + values: ["s3"], + schema: { + type: "object", + properties: { + bucket: { type: "string" }, + endpoint: { anyOf: [{ type: "string" }, { type: "null" }] }, + keys: { + anyOf: [ + { type: "array", items: { type: "string" }, maxItems: 10000 }, + { type: "null" }, + ], + }, + pathPrefix: { anyOf: [{ type: "string" }, { type: "null" }] }, + region: { anyOf: [{ type: "string" }, { type: "null" }] }, + secret: { + type: "object", + properties: { + accessKeyId: { type: "string" }, + secretAccessKey: { type: "string" }, + }, + required: ["accessKeyId", "secretAccessKey"], + additionalProperties: false, + }, + vendor: { enum: ["s3"] }, + }, + required: ["bucket", "secret", "vendor"], + additionalProperties: false, + }, + }, + { + values: ["gcs"], + schema: { + type: "object", + properties: { + bucket: { type: "string" }, + keys: { + anyOf: [ + { type: "array", items: { type: "string" }, maxItems: 10000 }, + { type: "null" }, + ], + }, + pathPrefix: { anyOf: [{ type: "string" }, { type: "null" }] }, + secret: { + type: "object", + properties: { + clientEmail: { type: "string" }, + privateKey: { type: "string" }, + }, + required: ["clientEmail", "privateKey"], + additionalProperties: false, + }, + vendor: { enum: ["gcs"] }, + }, + required: ["bucket", "secret", "vendor"], + additionalProperties: false, + }, + }, + { + values: ["r2"], + schema: { + type: "object", + properties: { + bucket: { type: "string" }, + jurisdiction: { enum: ["default", "eu", "us", "fedramp"] }, + keys: { + anyOf: [ + { type: "array", items: { type: "string" }, maxItems: 10000 }, + { type: "null" }, + ], + }, + pathPrefix: { anyOf: [{ type: "string" }, { type: "null" }] }, + secret: { + type: "object", + properties: { + accessKeyId: { type: "string" }, + secretAccessKey: { type: "string" }, + }, + required: ["accessKeyId", "secretAccessKey"], + additionalProperties: false, + }, + vendor: { enum: ["r2"] }, + }, + required: ["bucket", "secret", "vendor"], + additionalProperties: false, + }, + }, + ], + }, +]; const command: CommandModule = { command: "create", @@ -258,89 +354,92 @@ const command: CommandModule = { body: argv.body !== undefined ? parseBody(argv.body) - : compactBody({ - overwrite: argv["overwrite"], - source: { - bucket: resolveFileToken( - argv["source-bucket"] as string | undefined, - "source-bucket", - "text" - ), - endpoint: resolveFileToken( - argv["source-endpoint"] as string | undefined, - "source-endpoint", - "text" - ), - keys: argv["source-keys"], - pathPrefix: resolveFileToken( - argv["source-path-prefix"] as string | undefined, - "source-path-prefix", - "text" - ), - region: resolveFileToken( - argv["source-region"] as string | undefined, - "source-region", - "text" - ), - secret: { - accessKeyId: resolveFileToken( - argv["source-secret-access-key-id"] as - | string - | undefined, - "source-secret-access-key-id", + : validateBodyUnions( + compactBody({ + overwrite: argv["overwrite"], + source: { + bucket: resolveFileToken( + argv["source-bucket"] as string | undefined, + "source-bucket", "text" ), - secretAccessKey: resolveFileToken( - argv["source-secret-secret-access-key"] as - | string - | undefined, - "source-secret-secret-access-key", + endpoint: resolveFileToken( + argv["source-endpoint"] as string | undefined, + "source-endpoint", "text" ), - clientEmail: resolveFileToken( - argv["source-secret-client-email"] as - | string - | undefined, - "source-secret-client-email", + keys: argv["source-keys"], + pathPrefix: resolveFileToken( + argv["source-path-prefix"] as string | undefined, + "source-path-prefix", "text" ), - privateKey: resolveFileToken( - argv["source-secret-private-key"] as - | string - | undefined, - "source-secret-private-key", + region: resolveFileToken( + argv["source-region"] as string | undefined, + "source-region", "text" ), + secret: { + accessKeyId: resolveFileToken( + argv["source-secret-access-key-id"] as + | string + | undefined, + "source-secret-access-key-id", + "text" + ), + secretAccessKey: resolveFileToken( + argv["source-secret-secret-access-key"] as + | string + | undefined, + "source-secret-secret-access-key", + "text" + ), + clientEmail: resolveFileToken( + argv["source-secret-client-email"] as + | string + | undefined, + "source-secret-client-email", + "text" + ), + privateKey: resolveFileToken( + argv["source-secret-private-key"] as + | string + | undefined, + "source-secret-private-key", + "text" + ), + }, + vendor: argv["source-vendor"], + jurisdiction: argv["source-jurisdiction"], }, - vendor: argv["source-vendor"], - jurisdiction: argv["source-jurisdiction"], - }, - target: { - bucket: resolveFileToken( - argv["target-bucket"] as string | undefined, - "target-bucket", - "text" - ), - jurisdiction: argv["target-jurisdiction"], - secret: { - accessKeyId: resolveFileToken( - argv["target-secret-access-key-id"] as - | string - | undefined, - "target-secret-access-key-id", - "text" - ), - secretAccessKey: resolveFileToken( - argv["target-secret-secret-access-key"] as - | string - | undefined, - "target-secret-secret-access-key", + target: { + bucket: resolveFileToken( + argv["target-bucket"] as string | undefined, + "target-bucket", "text" ), + jurisdiction: argv["target-jurisdiction"], + secret: { + accessKeyId: resolveFileToken( + argv["target-secret-access-key-id"] as + | string + | undefined, + "target-secret-access-key-id", + "text" + ), + secretAccessKey: resolveFileToken( + argv["target-secret-secret-access-key"] as + | string + | undefined, + "target-secret-secret-access-key", + "text" + ), + }, + vendor: argv["target-vendor"], }, - vendor: argv["target-vendor"], - }, - }), + }), + bodyUnions + ), sensitiveBodyPaths: [ ["source", "secret", "secretAccessKey"], ["source", "secret", "privateKey"], @@ -434,6 +533,7 @@ const command: CommandModule = { vendor: argv["target-vendor"], }, }); + validateBodyUnions(bodyData, bodyUnions); const result = await withProgress(`Creating`, async () => client.send(operation, { body: bodyData, account_id: accountId }) ); diff --git a/packages/cli/src/commands/_generated/workers/deployments/create.ts b/packages/cli/src/commands/_generated/workers/deployments/create.ts index 1795e87208..846eb62cb2 100644 --- a/packages/cli/src/commands/_generated/workers/deployments/create.ts +++ b/packages/cli/src/commands/_generated/workers/deployments/create.ts @@ -13,6 +13,7 @@ import { resolveAccountIdSilent, } from "#lib/auth.js"; import { compactBody, parseBody, parseObjectArray } from "#lib/body-parser.js"; +import { validateBodyUnions } from "#lib/body-unions.js"; import { formatDryRun } from "#lib/dry-run.js"; import { resolveFileToken } from "#lib/input-validation.js"; import { LOCAL_ACCOUNT_ID } from "#lib/local.js"; @@ -59,6 +60,35 @@ function builder(yargs: Argv) { } type Args = InferArgs; +const bodyUnions: Parameters[1] = [ + { + path: ["code_update_strategy"], + selector: "mode", + variants: [ + { + values: ["immediate"], + schema: { + type: "object", + properties: { mode: { enum: ["immediate"] } }, + required: ["mode"], + additionalProperties: false, + }, + }, + { + values: ["deferred"], + schema: { + type: "object", + properties: { + max_delay: { anyOf: [{ type: "number" }, { type: "null" }] }, + mode: { enum: ["deferred"] }, + }, + required: ["mode"], + additionalProperties: false, + }, + }, + ], + }, +]; const command: CommandModule = { command: "create", @@ -89,10 +119,13 @@ const command: CommandModule = { body: argv.body !== undefined ? parseBody(argv.body) - : compactBody({ - strategy: argv["strategy"], - versions: parseObjectArray(argv["versions"], "versions"), - }), + : validateBodyUnions( + compactBody({ + strategy: argv["strategy"], + versions: parseObjectArray(argv["versions"], "versions"), + }), + bodyUnions + ), }); return; } @@ -138,6 +171,7 @@ const command: CommandModule = { strategy: argv["strategy"], versions: parseObjectArray(argv["versions"], "versions"), }); + validateBodyUnions(bodyData, bodyUnions); const qs = new URLSearchParams( Object.entries(queryParams) .filter(([, v]) => v !== undefined) diff --git a/packages/cli/src/commands/_generated/zero-trust/dlp/email/account-mapping/create.ts b/packages/cli/src/commands/_generated/zero-trust/dlp/email/account-mapping/create.ts index 84dad06237..f6164eeeb3 100644 --- a/packages/cli/src/commands/_generated/zero-trust/dlp/email/account-mapping/create.ts +++ b/packages/cli/src/commands/_generated/zero-trust/dlp/email/account-mapping/create.ts @@ -12,6 +12,7 @@ import { resolveAccountIdSilent, } from "#lib/auth.js"; import { compactBody, parseBody } from "#lib/body-parser.js"; +import { validateBodyUnions } from "#lib/body-unions.js"; import { formatDryRun } from "#lib/dry-run.js"; import { resolveFileToken } from "#lib/input-validation.js"; import { LOCAL_ACCOUNT_ID } from "#lib/local.js"; @@ -51,6 +52,38 @@ function builder(yargs: Argv) { } type Args = InferArgs; +const bodyUnions: Parameters[1] = [ + { + path: ["auth_requirements"], + selector: "type", + variants: [ + { + values: ["Org"], + schema: { + type: "object", + properties: { + allowed_microsoft_organizations: { + type: "array", + items: { type: "string" }, + }, + type: { enum: ["Org"] }, + }, + required: ["allowed_microsoft_organizations", "type"], + additionalProperties: false, + }, + }, + { + values: ["NoAuth"], + schema: { + type: "object", + properties: { type: { enum: ["NoAuth"] } }, + required: ["type"], + additionalProperties: false, + }, + }, + ], + }, +]; const command: CommandModule = { command: "create", @@ -77,15 +110,18 @@ const command: CommandModule = { body: argv.body !== undefined ? parseBody(argv.body) - : compactBody({ - auth_requirements: { - allowed_microsoft_organizations: - argv[ - "auth-requirements-allowed-microsoft-organizations" - ], - type: argv["auth-requirements-type"], - }, - }), + : validateBodyUnions( + compactBody({ + auth_requirements: { + allowed_microsoft_organizations: + argv[ + "auth-requirements-allowed-microsoft-organizations" + ], + type: argv["auth-requirements-type"], + }, + }), + bodyUnions + ), }); return; } @@ -117,6 +153,7 @@ const command: CommandModule = { type: argv["auth-requirements-type"], }, }); + validateBodyUnions(bodyData, bodyUnions); const result = await withProgress(`Creating`, async () => client.send(operation, { body: bodyData, account_id: accountId }) ); diff --git a/packages/cli/src/lib/body-unions.ts b/packages/cli/src/lib/body-unions.ts new file mode 100644 index 0000000000..410345265f --- /dev/null +++ b/packages/cli/src/lib/body-unions.ts @@ -0,0 +1,55 @@ +import { validateJsonSchema, type JsonSchema } from "./schema-flags.js"; + +type BodyUnion = { + readonly path: readonly string[]; +} & ( + | { + readonly selector: string; + readonly variants: readonly { + readonly values: readonly (string | number | boolean)[]; + readonly schema: JsonSchema; + }[]; + } + | { readonly schema: JsonSchema } +); + +/** Validate tagged unions after flags have been assembled into their wire shape. */ +export function validateBodyUnions( + body: T, + unions: readonly BodyUnion[] +): T { + for (const union of unions) { + let value: unknown = body; + for (const key of union.path) { + value = + value !== null && typeof value === "object" && Object.hasOwn(value, key) + ? (value as Record)[key] + : undefined; + } + if (value === undefined) { + continue; + } + let constraint: JsonSchema; + if ("schema" in union) { + constraint = union.schema; + } else { + const selected = + value !== null && typeof value === "object" + ? (value as Record)[union.selector] + : undefined; + const variant = union.variants.find((v) => + v.values.some((choice) => choice === selected) + ); + constraint = variant?.schema ?? { + oneOf: union.variants.map((v) => v.schema), + }; + } + const errors = validateJsonSchema(constraint, value); + if (errors.length > 0) { + throw new Error( + `Invalid ${union.path.join(".") || "request body"}: ${errors.join("; ")}. Pass --body with a complete request body if it cannot be supplied as flags.` + ); + } + } + return body; +}