From ff778a3c2bd9af38bb0dc075b4ea3cf6bab13239 Mon Sep 17 00:00:00 2001 From: ematipico Date: Wed, 4 Nov 2026 10:10:43 +0000 Subject: [PATCH] feat: make CLI default, and less targets --- packages/cloudflare-api-site/README.md | 6 +- packages/cloudflare-api-site/e2e/site.e2e.ts | 19 +- .../cloudflare-api-site/e2e/visual.e2e.ts | 2 +- .../src/cloudflare.test.ts | 103 ++-------- .../cloudflare-api-site/src/cloudflare.ts | 188 +----------------- 5 files changed, 44 insertions(+), 274 deletions(-) diff --git a/packages/cloudflare-api-site/README.md b/packages/cloudflare-api-site/README.md index 7589f8f00..51345a2e6 100644 --- a/packages/cloudflare-api-site/README.md +++ b/packages/cloudflare-api-site/README.md @@ -97,7 +97,7 @@ snapshot: ```text /api/zero-trust/devices/ip-profiles/ /api/zero-trust/devices/ip-profiles/methods/list/ -/api/zero-trust/devices/ip-profiles/methods/list/?lang=typescript +/api/zero-trust/devices/ip-profiles/methods/list/?lang=curl ``` Omitting `version` selects the configured default version; omitting `lang` @@ -130,9 +130,9 @@ Agent routes use the same query policy and are rendered on demand: ```text /api/llms.txt /api/zero-trust/llms.txt -/api/zero-trust/llms.txt?lang=typescript +/api/zero-trust/llms.txt?lang=curl /api/zero-trust/devices/ip-profiles/methods/list.md -/api/zero-trust/devices/ip-profiles/methods/list.md?lang=typescript +/api/zero-trust/devices/ip-profiles/methods/list.md?lang=curl ``` Canonical human pages link to aggregate Markdown; a selected execution target diff --git a/packages/cloudflare-api-site/e2e/site.e2e.ts b/packages/cloudflare-api-site/e2e/site.e2e.ts index 6f05c6cc9..771c268b7 100644 --- a/packages/cloudflare-api-site/e2e/site.e2e.ts +++ b/packages/cloudflare-api-site/e2e/site.e2e.ts @@ -8,10 +8,10 @@ test.describe('routes', () => { ['/api/zero-trust/', 200, 'text/html'], ['/api/zero-trust/devices/ip-profiles/', 200, 'text/html'], [operation, 200, 'text/html'], - [`${operation}?lang=typescript`, 200, 'text/html'], + [`${operation}?lang=curl`, 200, 'text/html'], [`${operation}?lang=nope`, 404, 'text/plain'], ['/api/zero-trust/devices/ip-profiles/methods/list.md', 200, 'text/markdown'], - ['/api/zero-trust/devices/ip-profiles/methods/list.md?lang=typescript', 200, 'text/markdown'], + ['/api/zero-trust/devices/ip-profiles/methods/list.md?lang=curl', 200, 'text/markdown'], ['/api/llms.txt', 200, 'text/markdown'], ['/api/zero-trust/llms.txt', 200, 'text/markdown'], ['/api/tooling/', 200, 'text/html'], @@ -42,18 +42,21 @@ test('operation page renders the operation and its samples', async ({ page }) => await expect(page.locator('h1')).toHaveText('List IP profiles'); await expect(page.locator('.forge-verb')).toHaveText('GET'); await expect(page.locator('.forge-sample-code code.language-bash').first()).toContainText( - 'https://api.cloudflare.com/client/v4/accounts/', + 'cf zero-trust devices ip-profiles list', ); }); test('execution target selector switches the code sample', async ({ page }) => { await page.goto(operation); const target = page.locator('select[data-forge-target]'); - await expect(target).toHaveValue('curl'); - await target.selectOption('typescript'); - await expect(page).toHaveURL(/[?&]lang=typescript\b/); - await expect(page.locator('select[data-forge-target]')).toHaveValue('typescript'); - await expect(page.locator('.forge-sample-code code.language-bash')).toHaveCount(0); + await expect(target).toHaveValue('cf'); + await expect(target.locator('option')).toHaveText(['cf CLI', 'curl', 'Terraform']); + await target.selectOption('curl'); + await expect(page).toHaveURL(/[?&]lang=curl\b/); + await expect(page.locator('select[data-forge-target]')).toHaveValue('curl'); + await expect(page.locator('.forge-sample-code code.language-bash').first()).toContainText( + 'https://api.cloudflare.com/client/v4/accounts/', + ); }); test('operation links its agent Markdown', async ({ page, request }) => { diff --git a/packages/cloudflare-api-site/e2e/visual.e2e.ts b/packages/cloudflare-api-site/e2e/visual.e2e.ts index 13fe22017..705d7921e 100644 --- a/packages/cloudflare-api-site/e2e/visual.e2e.ts +++ b/packages/cloudflare-api-site/e2e/visual.e2e.ts @@ -5,7 +5,7 @@ const pages = { product: '/api/zero-trust/', resource: '/api/zero-trust/devices/ip-profiles/', operation: '/api/zero-trust/devices/ip-profiles/methods/list/', - 'operation-typescript': '/api/zero-trust/devices/ip-profiles/methods/list/?lang=typescript', + 'operation-curl': '/api/zero-trust/devices/ip-profiles/methods/list/?lang=curl', tooling: '/api/tooling/', 'not-found': '/api/nope.txt', }; diff --git a/packages/cloudflare-api-site/src/cloudflare.test.ts b/packages/cloudflare-api-site/src/cloudflare.test.ts index 1e83889b2..4b8b908ab 100644 --- a/packages/cloudflare-api-site/src/cloudflare.test.ts +++ b/packages/cloudflare-api-site/src/cloudflare.test.ts @@ -229,12 +229,9 @@ test('optional request bodies remain present in examples when the endpoint decla }; const snippets = cloudflareSnippetsByTarget(optionalInput); const curl = snippets.get('curl'); - const ts = snippets.get('typescript'); - assert.ok(curl && ts); + assert.ok(curl); assert.match(curl, /Content-Type: application\/json/); assert.match(curl, /-d /); - assert.match(ts, /name: ""/); - assert.match(ts, /type: "A"/); }); test('curl shell-quotes apostrophes in request media types and JSON bodies', () => { @@ -293,7 +290,6 @@ 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 ['typescript', 'python', 'ruby']) assert.equal(rawSnippets.get(target), null); assert.equal(cloudflareSnippetsByTarget(multipart).get('curl'), null); }); @@ -306,31 +302,21 @@ test('curl: includes required query params and omits optional query params', () test('dated versions intentionally retain version-neutral snippets while upstream does not enforce versions', () => { const snippets = cloudflareSnippetsByTarget({ ...listInput, snapshotId: '2027-01-01.air' }); - for (const target of ['curl', 'cf', 'typescript', 'python', 'ruby']) { + for (const target of ['curl', 'cf']) { assert.equal(typeof snippets.get(target), 'string'); assert.doesNotMatch(snippets.get(target) ?? '', /api-version/); } }); -test('nested body values remain structured in curl and SDK samples', () => { - const snippets = cloudflareSnippetsByTarget(nestedCreateInput); - const curl = snippets.get('curl'); - const ts = snippets.get('typescript'); - const py = snippets.get('python'); - const rb = snippets.get('ruby'); - assert.ok(curl && ts && py && rb); +test('nested body values remain structured in curl samples', () => { + const curl = cloudflareSnippetsByTarget(nestedCreateInput).get('curl'); + assert.ok(curl); const payload = curl.match(/-d '(.+)'/)?.[1]; assert.ok(payload); assert.deepEqual(JSON.parse(payload), { settings: { enabled: true }, rules: [{ action: 'block' }] }); - assert.match(ts, /settings: {"enabled":true}/); - assert.match(ts, /rules: \[{"action":"block"}\]/); - assert.match(py, /settings={"enabled": True}/); - assert.match(py, /rules=\[{"action": "block"}\]/); - assert.match(rb, /settings: {"enabled" => true}/); - assert.match(rb, /rules: \[{"action" => "block"}\]/); }); -test('SDK property aliases resolve argument collisions without changing HTTP wire fields', () => { +test('SDK property aliases do not change HTTP wire fields', () => { const input: SnippetInput = { accessorPath: ['accounts'], methodName: 'create', @@ -377,17 +363,14 @@ test('SDK property aliases resolve argument collisions without changing HTTP wir }, }, }; - const snippets = cloudflareSnippetsByTarget(input); - const curlPayload = snippets.get('curl')?.match(/-d '(.+)'/)?.[1]; - const ts = snippets.get('typescript'); - assert.ok(curlPayload && ts); + const curlPayload = cloudflareSnippetsByTarget(input) + .get('curl') + ?.match(/-d '(.+)'/)?.[1]; + assert.ok(curlPayload); assert.deepEqual(JSON.parse(curlPayload), { account_id: 0, settings: { account_id: 0 } }); - assert.match(ts, /account_id: ""/); - assert.match(ts, /account_id_body: 0/); - assert.match(ts, /settings: {"nested_account_id":0}/); }); -test('root array and union bodies render structurally in curl and suppress unsupported SDK samples', () => { +test('root array and union bodies render structurally in curl', () => { const arraySnippets = cloudflareSnippetsByTarget(rootArrayCreateInput); const unionSnippets = cloudflareSnippetsByTarget(rootUnionCreateInput); const arrayPayload = arraySnippets.get('curl')?.match(/-d '(.+)'/)?.[1]; @@ -395,42 +378,6 @@ test('root array and union bodies render structurally in curl and suppress unsup assert.ok(arrayPayload && unionPayload); assert.deepEqual(JSON.parse(arrayPayload), [{ id: '' }]); assert.deepEqual(JSON.parse(unionPayload), { source: '' }); - for (const target of ['typescript', 'python', 'ruby']) { - assert.equal(arraySnippets.get(target), null); - assert.equal(unionSnippets.get(target), null); - } -}); - -test('typescript: builds the accessor chain and required-arg object', () => { - const ts = cloudflareSnippetsByTarget(listInput).get('typescript'); - assert.ok(ts); - const target = CLOUDFLARE_TARGETS.find((candidate) => candidate.id === 'typescript'); - assert.ok(target?.packageName); - assert.ok(ts.includes(`from "${target.packageName}"`)); - assert.match(ts, /new CloudflareApiClient\(\)/); - assert.match(ts, /await client\.dns\.records\.list\(/); - assert.match(ts, /zone_id: ""/); -}); - -test('SDKs include required query params and omit optional query params', () => { - const snippets = cloudflareSnippetsByTarget(queryInput); - const ts = snippets.get('typescript'); - const py = snippets.get('python'); - const rb = snippets.get('ruby'); - assert.ok(ts && py && rb); - assert.match(ts, /type: "A"/); - assert.match(py, /type="A"/); - assert.match(rb, /type: "A"/); - for (const snippet of [ts, py, rb]) assert.doesNotMatch(snippet, /page[=:]/); -}); - -test('typescript: normalizes hyphenated Fern group and method names to lower camel case', () => { - const ts = cloudflareSnippetsByTarget({ - ...listInput, - accessorPath: ['dns', 'zone-transfers', 'primary-zones'], - methodName: 'force-notify', - }).get('typescript'); - assert.match(ts ?? '', /client\.dns\.zoneTransfers\.primaryZones\.forceNotify\(/); }); // The cf samples come from the metadata of the cf package the site depends on. @@ -486,34 +433,20 @@ test('cf: renders the command named like the operation when several send it', () assert.match(cloudflareSnippetsByTarget(status('pause')).get('cf') ?? '', /^cf workflows instances pause /); }); -test('python: snake_cased accessor with keyword args', () => { - const py = cloudflareSnippetsByTarget(listInput).get('python'); - assert.ok(py); - assert.match(py, /from cloudflare import Cloudflare/); - assert.match(py, /result = client\.dns\.records\.list\(/); - assert.match(py, /zone_id=""/); -}); - -test('ruby: snake_cased accessor with hash args', () => { - const rb = cloudflareSnippetsByTarget(listInput).get('ruby'); - assert.ok(rb); - assert.match(rb, /Cloudflare::Client\.new/); - assert.match(rb, /result = client\.dns\.records\.list\(/); - assert.match(rb, /zone_id: ""/); -}); - -test('provider renders every target in tab order; go/terraform are coming-soon', () => { +test('provider renders every target in tab order with cf CLI first; terraform is coming-soon', () => { const snippets = cloudflareSnippets(listInput); assert.deepEqual( snippets.map((snippet) => snippet.targetId), CLOUDFLARE_TARGETS.map((target) => target.id), ); - for (const target of ['curl', 'cf', 'typescript', 'python', 'ruby']) { + assert.deepEqual( + CLOUDFLARE_TARGETS.map((target) => target.id), + ['cf', 'curl', 'terraform'], + ); + for (const target of ['cf', 'curl']) { assert.equal(typeof snippets.find((snippet) => snippet.targetId === target)?.code, 'string'); } - for (const target of ['go', 'terraform']) { - assert.equal(snippets.find((snippet) => snippet.targetId === target)?.code, null); - } + assert.equal(snippets.find((snippet) => snippet.targetId === 'terraform')?.code, null); }); test('formatIdentifier is acronym-aware and title-cases the rest', () => { diff --git a/packages/cloudflare-api-site/src/cloudflare.ts b/packages/cloudflare-api-site/src/cloudflare.ts index caeded74f..f56c044c1 100644 --- a/packages/cloudflare-api-site/src/cloudflare.ts +++ b/packages/cloudflare-api-site/src/cloudflare.ts @@ -3,8 +3,8 @@ * * astro-fern is product-agnostic: it knows how to resolve, route and render an * OpenAPI spec, but ships no Cloudflare knowledge. Everything Cloudflare-shaped - * — the acronym-aware label formatter, the API base URL / auth header, the SDK - * package names, the execution-target set, and the `result` response envelope + * — the acronym-aware label formatter, the API base URL / auth header, the + * execution-target set, and the `result` response envelope * — lives here and is passed to the content recipe in `content.config.ts`. * * This module has no Astro/runtime dependencies, so its behaviour is unit-tested @@ -20,7 +20,6 @@ import { placeholderValue, type SnippetInput, type SnippetOperation, - type SnippetParam, type SnippetProvider, type SnippetRequestRepresentation, type SnippetRenderer, @@ -91,22 +90,13 @@ export function cloudflareFormatIdentifier(identifier: string): string { //#region Execution targets + snippet renderers -/** Cloudflare's execution targets, in tab order. Target ids match renderer keys. */ -const TYPESCRIPT_PACKAGE_NAME = '@cloudflare/forge-sdk-ts'; - +/** + * Cloudflare's execution targets, in tab order. Target ids match renderer keys. + * The first target is the default when no `?lang=` is selected. + */ export const CLOUDFLARE_TARGETS: FernTargetConfig[] = [ - { id: 'curl', kind: 'http', label: 'curl', language: 'bash' }, { id: 'cf', kind: 'cli', label: 'cf CLI', language: 'bash' }, - { - id: 'typescript', - kind: 'sdk', - label: 'TypeScript', - language: 'typescript', - packageName: TYPESCRIPT_PACKAGE_NAME, - }, - { id: 'python', kind: 'sdk', label: 'Python', language: 'python' }, - { id: 'ruby', kind: 'sdk', label: 'Ruby', language: 'ruby' }, - { id: 'go', kind: 'sdk', label: 'Go', language: 'go' }, + { id: 'curl', kind: 'http', label: 'curl', language: 'bash' }, { id: 'terraform', kind: 'tool', label: 'Terraform', language: 'hcl' }, ]; @@ -123,41 +113,6 @@ export function cloudflareOperationRoutingPreference({ return routingPriority >= 0 && /^\/accounts(?:\/|$)/.test(path) ? 1 : 0; } -const toSnake = (value: string): string => value.replace(/-/g, '_'); - -function toLowerCamel(value: string): string { - const [first = '', ...rest] = value.split(/[^A-Za-z0-9]+/).filter(Boolean); - return `${first.charAt(0).toLowerCase()}${first.slice(1)}${rest - .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`) - .join('')}`; -} - -function pyLiteral(value: unknown): string { - if (value === null) return 'None'; - if (typeof value === 'boolean') return value ? 'True' : 'False'; - if (typeof value === 'number') return String(value); - if (Array.isArray(value)) return `[${value.map(pyLiteral).join(', ')}]`; - if (typeof value === 'object') { - return `{${Object.entries(value) - .map(([key, entry]) => `${JSON.stringify(key)}: ${pyLiteral(entry)}`) - .join(', ')}}`; - } - return JSON.stringify(String(value)); -} - -function rbLiteral(value: unknown): string { - if (value === null) return 'nil'; - if (typeof value === 'boolean') return value ? 'true' : 'false'; - if (typeof value === 'number') return String(value); - if (Array.isArray(value)) return `[${value.map(rbLiteral).join(', ')}]`; - if (typeof value === 'object') { - return `{${Object.entries(value) - .map(([key, entry]) => `${JSON.stringify(key)} => ${rbLiteral(entry)}`) - .join(', ')}}`; - } - return JSON.stringify(String(value)); -} - function isMultipartMediaType(mediaType: string): boolean { return mediaType.split(';', 1)[0]?.trim().toLowerCase().startsWith('multipart/') === true; } @@ -178,39 +133,6 @@ function httpRequestRepresentation(op: SnippetOperation): SnippetRequestRepresen ); } -interface SdkBodyField { - name: string; - value: unknown; -} - -function sdkPlaceholderValue(param: SnippetParam): unknown { - const variant = param.variants?.[0]; - if (variant) return sdkPlaceholderValue(variant); - if (param.children) { - return Object.fromEntries( - param.children - .filter((child) => child.required) - .map((child) => [child.sdkName ?? child.name, sdkPlaceholderValue(child)]), - ); - } - if (param.items) return [sdkPlaceholderValue(param.items)]; - return placeholderValue(param); -} - -/** `undefined` means no body; `null` means the SDK sample cannot express it. */ -function sdkBodyFields(op: SnippetOperation): SdkBodyField[] | null | undefined { - if (!op.requestBody) return undefined; - const representations = op.requestBody.representations.filter((representation) => - isJsonMediaType(representation.mediaType), - ); - const representation = representations.find((candidate) => candidate.schema); - if (!representation?.schema) return null; - if (!representation.schema.children) return null; - return representation.schema.children - .filter((param) => param.required) - .map((param) => ({ name: param.sdkName ?? param.name, value: sdkPlaceholderValue(param) })); -} - const renderCurl: SnippetRenderer = ({ op }) => { const url = `${CLOUDFLARE_API_BASE}${interpolatePath(op.path)}`; const verb = op.method.toUpperCase(); @@ -251,93 +173,9 @@ const renderCf: SnippetRenderer = ({ accessorPath, methodName, op }) => { return lines.join(' \\\n '); }; -const renderTypeScript: SnippetRenderer = ({ op, accessorPath, methodName }) => { - const bodyFields = sdkBodyFields(op); - if (bodyFields === null) return null; - const accessor = ['client', ...accessorPath.map(toLowerCamel), toLowerCamel(methodName)].join('.'); - const argFields: string[] = []; - for (const param of op.pathParams) argFields.push(` ${param.name}: ${JSON.stringify(`<${param.name}>`)},`); - for (const param of op.queryParams) { - if (!param.required) continue; - if (argFields.some((field) => field.trimStart().startsWith(`${param.name}:`))) continue; - argFields.push(` ${param.name}: ${JSON.stringify(placeholderValue(param))},`); - } - for (const { name, value } of bodyFields ?? []) { - if (argFields.some((field) => field.trimStart().startsWith(`${name}:`))) continue; - argFields.push(` ${name}: ${JSON.stringify(value)},`); - } - const args = argFields.length > 0 ? `{\n${argFields.join('\n')}\n}` : ''; - return [ - `import { CloudflareApiClient } from "${TYPESCRIPT_PACKAGE_NAME}";`, - '', - 'const client = new CloudflareApiClient();', - '', - `const result = await ${accessor}(${args});`, - ].join('\n'); -}; - -// The Python SDK's resource/method names come from the same x-fern-sdk-* metadata -// Fern uses to generate it, so a snake_cased accessor + keyword args is faithful. -const renderPython: SnippetRenderer = ({ op, accessorPath, methodName }) => { - const bodyFields = sdkBodyFields(op); - if (bodyFields === null) return null; - const accessor = ['client', ...accessorPath.map(toSnake), toSnake(methodName)].join('.'); - const args: string[] = []; - for (const param of op.pathParams) args.push(` ${toSnake(param.name)}=${JSON.stringify(`<${param.name}>`)},`); - for (const param of op.queryParams) { - if (!param.required) continue; - const name = toSnake(param.name); - if (args.some((arg) => arg.trimStart().startsWith(`${name}=`))) continue; - args.push(` ${name}=${pyLiteral(placeholderValue(param))},`); - } - for (const { name, value } of bodyFields ?? []) { - const top = toSnake(name); - if (args.some((arg) => arg.trimStart().startsWith(`${top}=`))) continue; - args.push(` ${top}=${pyLiteral(value)},`); - } - const body = args.length > 0 ? `\n${args.join('\n')}\n` : ''; - return [ - 'import os', - 'from cloudflare import Cloudflare', - '', - 'client = Cloudflare(api_token=os.environ.get("CLOUDFLARE_API_TOKEN"))', - '', - `result = ${accessor}(${body})`, - ].join('\n'); -}; - -// Same faithful pattern as Python: snake_cased accessor + keyword (hash) args. -const renderRuby: SnippetRenderer = ({ op, accessorPath, methodName }) => { - const bodyFields = sdkBodyFields(op); - if (bodyFields === null) return null; - const accessor = ['client', ...accessorPath.map(toSnake), toSnake(methodName)].join('.'); - const args: string[] = []; - for (const param of op.pathParams) args.push(` ${toSnake(param.name)}: ${JSON.stringify(`<${param.name}>`)},`); - for (const param of op.queryParams) { - if (!param.required) continue; - const name = toSnake(param.name); - if (args.some((arg) => arg.trimStart().startsWith(`${name}:`))) continue; - args.push(` ${name}: ${rbLiteral(placeholderValue(param))},`); - } - for (const { name, value } of bodyFields ?? []) { - const top = toSnake(name); - if (args.some((arg) => arg.trimStart().startsWith(`${top}:`))) continue; - args.push(` ${top}: ${rbLiteral(value)},`); - } - const body = args.length > 0 ? `\n${args.join('\n')}\n` : ''; - return [ - 'require "cloudflare"', - '', - 'client = Cloudflare::Client.new(api_token: ENV["CLOUDFLARE_API_TOKEN"])', - '', - `result = ${accessor}(${body})`, - ].join('\n'); -}; - -// Coming-soon surfaces: their SDKs use params structs / builders (Go) or a -// declarative resource model (Terraform) that can't be faithfully derived -// without the generated SDK. `null` renders a placeholder tab; swap in -// Fern-generated snippets here once available. +// Terraform's declarative resource model can't be derived from a single +// operation. `null` renders a placeholder; CodeSample.astro renders the +// Terraform declarations from the generated provider docs instead. const comingSoon: SnippetRenderer = () => null; // API version selection currently changes documentation only. The live API does @@ -348,12 +186,8 @@ const comingSoon: SnippetRenderer = () => null; /** Cloudflare's per-operation snippet provider for Fern content. */ export const cloudflareSnippets: SnippetProvider = createSnippetProvider( { - curl: { label: 'curl', syntax: 'bash', render: renderCurl }, cf: { label: 'cf CLI', syntax: 'bash', render: renderCf }, - typescript: { label: 'TypeScript', syntax: 'ts', render: renderTypeScript }, - python: { label: 'Python', syntax: 'python', render: renderPython }, - ruby: { label: 'Ruby', syntax: 'ruby', render: renderRuby }, - go: { label: 'Go', syntax: 'go', render: comingSoon }, + curl: { label: 'curl', syntax: 'bash', render: renderCurl }, terraform: { label: 'Terraform', syntax: 'hcl', render: comingSoon }, }, CLOUDFLARE_TARGETS.map((target) => target.id),