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
2 changes: 1 addition & 1 deletion packages/cloudflare-api-site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"@iconify-json/simple-icons": "^1.2.99",
"astro": "^7.4.1",
"astro-fern": "workspace:*",
"cf": "1.0.0-beta.19",
"cf": "1.0.0-beta.27",
"clsx": "^2.1.1",
"fern-forge": "workspace:*",
"hast-util-sanitize": "^5.0.2",
Expand Down
79 changes: 79 additions & 0 deletions packages/cloudflare-api-site/src/cli-arguments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';

/** A positional or an option of a cf command. */
export interface CliArgument {
name: string;
/** The position of a positional. */
position?: number;
type: 'string' | 'number' | 'boolean';
required: boolean;
}

/** A command of the cf CLI, as its metadata describes it. */
export interface CliCommand {
/** The command, such as `cf agent-memory recall`. */
command: string;
/** The words after `cf`, such as `["agent-memory", "recall"]`. */
fullPath: string[];
/** The HTTP method of the operation it sends, in uppercase. */
httpMethod?: string;
/** The path template of the operation it sends. */
apiPath?: string;
/** The positionals. */
arguments?: CliArgument[];
options?: CliArgument[];
}

interface CliMetadata {
commands: CliCommand[];
}

// The cf package doesn't export its metadata, so it is read from the
// package's directory: `dist/_meta/commands.json` lists every command with the
// positionals and options `cf <command> --help` shows.
function readCliMetadata(file: string): CliMetadata {
const packageJson = createRequire(import.meta.url).resolve('cf/package.json');
return JSON.parse(readFileSync(join(dirname(packageJson), 'dist', '_meta', file), 'utf8')) as CliMetadata;
}

const operationKey = (method: string, path: string): string => `${method.toUpperCase()} ${path}`;

let byOperation: Map<string, CliCommand[]> | undefined;

/**
* The commands cf generates from the API, by the operation they send.
* Hand-written commands are left out: the site documents them separately.
*/
function commandsByOperation(): Map<string, CliCommand[]> {
if (byOperation) return byOperation;
const handWritten = new Set(readCliMetadata('hand-written-commands.json').commands.map((c) => c.command));
byOperation = new Map();
for (const command of readCliMetadata('commands.json').commands) {
if (!command.httpMethod || !command.apiPath || handWritten.has(command.command)) continue;
const key = operationKey(command.httpMethod, command.apiPath);
byOperation.set(key, [...(byOperation.get(key) ?? []), command]);
}
return byOperation;
}

// The site's accessor paths and method names are the operation's SDK names,
// which cf turns into kebab-case.
const comparable = (words: readonly string[]): string =>
words
.join('')
.replace(/[^A-Za-z0-9]/g, '')
.toLowerCase();

/**
* Returns the cf command that sends the operation `method` `path`, or
* `undefined` when cf generates none. When several commands send the
* operation, returns the one named `words`, or else the first.
*/
export function cliCommandFor(method: string, path: string, words: readonly string[]): CliCommand | undefined {
const commands = commandsByOperation().get(operationKey(method, path));
if (!commands) return undefined;
const wanted = comparable(words);
return commands.find((command) => comparable(command.fullPath) === wanted) ?? commands[0];
}
58 changes: 50 additions & 8 deletions packages/cloudflare-api-site/src/cloudflare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,10 +229,8 @@ test('optional request bodies remain present in examples when the endpoint decla
};
const snippets = cloudflareSnippetsByTarget(optionalInput);
const curl = snippets.get('curl');
const cf = snippets.get('cf');
const ts = snippets.get('typescript');
assert.ok(curl && ts);
assert.equal(cf, null);
assert.match(curl, /Content-Type: application\/json/);
assert.match(curl, /-d /);
assert.match(ts, /name: "<name>"/);
Expand Down Expand Up @@ -295,7 +293,7 @@ test('curl uses file input for raw bodies and does not fabricate required multip
const rawSnippets = cloudflareSnippetsByTarget(raw);
assert.match(rawSnippets.get('curl') ?? '', /Content-Type: application\/octet-stream/);
assert.match(rawSnippets.get('curl') ?? '', /--data-binary @request-body/);
for (const target of ['cf', 'typescript', 'python', 'ruby']) assert.equal(rawSnippets.get(target), null);
for (const target of ['typescript', 'python', 'ruby']) assert.equal(rawSnippets.get(target), null);
assert.equal(cloudflareSnippetsByTarget(multipart).get('curl'), null);
});

Expand Down Expand Up @@ -435,13 +433,57 @@ test('typescript: normalizes hyphenated Fern group and method names to lower cam
assert.match(ts ?? '', /client\.dns\.zoneTransfers\.primaryZones\.forceNotify\(/);
});

test('cf: positionals are the path params', () => {
assert.equal(cloudflareSnippetsByTarget(listInput).get('cf'), 'cf dns records list <zone_id>');
// The cf samples come from the metadata of the cf package the site depends on.

test('cf: renders the command with the positionals and options it requires', () => {
const recall: SnippetInput = {
op: op({
path: '/accounts/{account_id}/agent-memory/namespaces/{namespace_name}/profiles/{profile_name}/recall',
method: 'post',
}),
accessorPath: ['agent-memory'],
methodName: 'recall',
};
assert.equal(
cloudflareSnippetsByTarget(recall).get('cf'),
['cf agent-memory recall <profile-name>', '--namespace-name <namespace-name>', '--query <query>'].join(' \\\n '),
);
// The zone is passed with the global --zone option, which samples leave out.
assert.equal(cloudflareSnippetsByTarget(listInput).get('cf'), 'cf dns records list');
});

test('cf: renders required boolean options without a value', () => {
const edit: SnippetInput = {
op: op({ path: '/zones/{zone_id}/acm/total_tls', method: 'post' }),
accessorPath: ['acm', 'total-tls'],
methodName: 'edit',
};
assert.equal(cloudflareSnippetsByTarget(edit).get('cf'), 'cf acm total-tls edit \\\n --enabled');
});

test('cf: renders nothing for operations without a generated cf command', () => {
const unknown: SnippetInput = { ...listInput, op: op({ path: '/zones/{zone_id}/unknown' }) };
assert.equal(cloudflareSnippetsByTarget(unknown).get('cf'), null);
// `cf ai run` is hand-written: the site documents hand-written commands separately.
const aiRun: SnippetInput = {
op: op({ path: '/accounts/{account_id}/ai/run', method: 'post' }),
accessorPath: ['ai'],
methodName: 'run',
};
assert.equal(cloudflareSnippetsByTarget(aiRun).get('cf'), null);
});

test('cf: omits commands that require unsupported flags or request bodies', () => {
assert.equal(cloudflareSnippetsByTarget(queryInput).get('cf'), null);
assert.equal(cloudflareSnippetsByTarget(createInput).get('cf'), null);
test('cf: renders the command named like the operation when several send it', () => {
const status = (methodName: string): SnippetInput => ({
op: op({
path: '/accounts/{account_id}/workflows/{workflow_name}/instances/{instance_id}/status',
method: 'patch',
}),
accessorPath: ['workflows', 'instances'],
methodName,
});
assert.match(cloudflareSnippetsByTarget(status('terminate')).get('cf') ?? '', /^cf workflows instances terminate /);
assert.match(cloudflareSnippetsByTarget(status('pause')).get('cf') ?? '', /^cf workflows instances pause /);
});

test('python: snake_cased accessor with keyword args', () => {
Expand Down
18 changes: 15 additions & 3 deletions packages/cloudflare-api-site/src/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* This module has no Astro/runtime dependencies, so its behaviour is unit-tested
* with `node --experimental-strip-types --test` (see `cloudflare.test.ts`).
*/
import { cliCommandFor } from './cli-arguments.ts';
import {
createSnippetProvider,
type FernOperationRoutingPreferenceContext,
Expand Down Expand Up @@ -233,10 +234,21 @@ const renderCurl: SnippetRenderer = ({ op }) => {
return args.join(' \\\n ');
};

// The sample is the cf command that sends the operation, with the positionals
// and options it requires, as the cf CLI's own metadata lists them.
const renderCf: SnippetRenderer = ({ accessorPath, methodName, op }) => {
if (op.requestBody || op.queryParams.some((param) => param.required)) return null;
const positionals = op.pathParams.map((param) => `<${param.name}>`).join(' ');
return ['cf', ...accessorPath, methodName, positionals].filter(Boolean).join(' ');
const command = cliCommandFor(op.method, op.path, [...accessorPath, methodName]);
if (!command) return null;
const head = [command.command];
for (const positional of [...(command.arguments ?? [])].sort((a, b) => (a.position ?? 0) - (b.position ?? 0))) {
if (positional.required) head.push(`<${positional.name}>`);
}
const lines = [head.join(' ')];
for (const option of command.options ?? []) {
if (option.required)
lines.push(option.type === 'boolean' ? `--${option.name}` : `--${option.name} <${option.name}>`);
}
return lines.join(' \\\n ');
};

const renderTypeScript: SnippetRenderer = ({ op, accessorPath, methodName }) => {
Expand Down
Loading
Loading