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/plain-text-body-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"cf": patch
---

Describe plain-text `--body` correctly in command metadata

For commands that send `--body` as `text/plain`, command metadata used by
`cf tools` now gives the same description as `--help` instead of asking for
raw JSON. This includes media types with parameters such as
`text/plain;charset=UTF-8`. When no description is provided, both metadata
and `--help` use plain-text wording.
26 changes: 21 additions & 5 deletions packages/cli/generator/plugin/emit/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ function option(name: string, fields: [string, Value | undefined][]): string {

const quoted = (v: readonly string[]) => v.map((c) => `'${c}'`).join(", ");

export const JSON_BODY_DESCRIPTION =
"Raw JSON request body (bypasses individual flags)";

/** Matches the plain-text body classification in `wire()`. */
export const isPlainTextBody = (op: Ctx["op"]) =>
op.requestContentTypes.length > 0 &&
op.requestContentTypes.every((type) => type.startsWith("text/plain"));

export function bodyOptionDescription(op: Ctx["op"]): string {
if (
op.requestBodyDescription !== undefined &&
op.requestBodyDescription !== ""
) {
return op.requestBodyDescription;
}
return isPlainTextBody(op)
? "Plain-text request body (without JSON quotes)"
: JSON_BODY_DESCRIPTION;
}

/** `generateBuilderLines`. */
export function builderLines(ctx: Ctx): string[] {
const d = ctx.derived;
Expand Down Expand Up @@ -91,11 +111,7 @@ export function builderLines(ctx: Ctx): string[] {
);
}
if (d.hasBody) {
const desc =
op.requestBodyDescription !== undefined &&
op.requestBodyDescription !== ""
? op.requestBodyDescription
: "Raw JSON request body (bypasses individual flags)";
const desc = bodyOptionDescription(op);
lines.push(" .option('body', {");
lines.push(" type: 'string',");
lines.push(` description: '${escapeSingle(desc)}',`);
Expand Down
15 changes: 13 additions & 2 deletions packages/cli/generator/plugin/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ import {
scalar,
} from "./args.js";
import { requiresConfirmation } from "./confirmation.js";
import { builderLines } from "./emit/builder.js";
import {
bodyOptionDescription,
builderLines,
isPlainTextBody,
JSON_BODY_DESCRIPTION,
} from "./emit/builder.js";
import { ACCOUNT_OR_ZONE_HELP, handler, imports } from "./emit/handler.js";
import { context, escapeSingle } from "./emit/index.js";
import { operationLiteral, operationPath, usesPathTag } from "./emit/sdk.js";
Expand Down Expand Up @@ -346,7 +351,13 @@ class Walk {
["name", "body"],
["type", "string"],
["required", false],
["description", "Raw JSON request body (bypasses individual flags)"],
[
"description",
// `cf tools` schemas must not tell callers to send JSON here.
isPlainTextBody(op)
? bodyOptionDescription(op)
: JSON_BODY_DESCRIPTION,
],
])
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { stripTypeScriptTypes } from "node:module";
import { Script } from "node:vm";
import { describe, expect, it } from "vite-plus/test";
import yargs, { type CommandModule } from "yargs";
import { generateFiles } from "../../helpers/cli-plugin.js";
import type { CommandMeta } from "../../../lib/command-meta.js";

const JSON_BODY = "Raw JSON request body (bypasses individual flags)";
const PLAIN = "Send the workflow name as plain text, without JSON quotes.";
const PLAIN_BODY = "Plain-text request body (without JSON quotes)";

async function bodyDescriptions(contentTypes: string[], description?: string) {
const files = generateFiles({
openapi: "3.0.3",
info: { title: "Body descriptions", version: "1" },
paths: {
"/things": {
put: {
"x-fern-sdk-group-name": "things",
"x-fern-sdk-method-name": "update",
summary: "Update a thing",
requestBody: {
required: true,
...(description === undefined ? {} : { description }),
content: Object.fromEntries(
contentTypes.map((type) => [type, { schema: { type: "string" } }])
),
},
responses: { "200": { description: "OK" } },
},
},
},
});
const metadata = JSON.parse(files.get("_meta/commands.json") ?? "") as {
commands: CommandMeta[];
};
const bodyDescription = metadata.commands
.find((c) => c.command === "cf things update")
?.options.find((o) => o.name === "body")?.description;
const source = stripTypeScriptTypes(files.get("things/update.ts") ?? "")
.replace(/^import[\s\S]*?;\n/gm, "")
.replace("export default command;", "command;");
const command = new Script(source).runInNewContext() as CommandModule;
const help = await yargs(["update"]).command(command).wrap(null).getHelp();
return { metadata: bodyDescription, help };
}

describe.each([
{ contentTypes: ["text/plain"] },
{ contentTypes: ["text/plain;charset=UTF-8"] },
{ contentTypes: ["text/plain", "text/plain; charset=UTF-8"] },
])("plain-text --body descriptions ($contentTypes)", ({ contentTypes }) => {
it("uses the provided description in metadata and --help", async () => {
const result = await bodyDescriptions(contentTypes, PLAIN);
expect(result.metadata).toBe(PLAIN);
expect(result.help).toContain(PLAIN);
});

it.each([undefined, ""])(
"uses a plain-text fallback when the description is %j",
async (description) => {
const result = await bodyDescriptions(contentTypes, description);
expect(result.metadata).toBe(PLAIN_BODY);
expect(result.help).toContain(PLAIN_BODY);
expect(result.help).not.toContain(JSON_BODY);
}
);
});

describe("other --body metadata descriptions", () => {
it.each([
{ contentTypes: ["application/json"] },
{ contentTypes: ["application/octet-stream"] },
{ contentTypes: ["text/plain", "application/json"] },
{ contentTypes: ["application/json", "text/plain"] },
])("keeps the JSON wording for $contentTypes", async ({ contentTypes }) => {
const result = await bodyDescriptions(contentTypes, PLAIN);
expect(result.metadata).toBe(JSON_BODY);
});

it("omits --body when no content types are declared", async () => {
const result = await bodyDescriptions([], PLAIN);
expect(result.metadata).toBeUndefined();
expect(result.help).not.toContain("--body");
});
});
4 changes: 2 additions & 2 deletions packages/cli/src/commands/_generated/_meta/commands.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/src/commands/_generated/logs/query.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading